Add a pre-create content gate to gitea_create_issue that fails closed with
BLOCKED + DIAGNOSE when the title/body is a vague reference to out-of-band
draft content ("the drafted issue", "the prepared issue", "the issue we
discussed", "the previous draft") and no durable source pointer (existing
issue/PR/comment, checked-in file, URL, or scratchpad path) is present.
- issue_content_gate.py: assess_issue_content / pre_create_issue_content_gate
with vague-phrase detection, durable-source-pointer escape hatch, and a
domination check so long specific bodies are not falsely blocked.
- gitea_create_issue: runs the gate after preflight purity, before duplicate
search; new allow_incomplete_content override (off by default). Title-only
creation stays allowed (no empty-body requirement) to preserve behavior.
- tests/test_issue_content_gate.py: unit + MCP integration coverage.
- create-issue.md: documents the enforced preflight with valid/invalid
prompt examples.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
181 lines
6.3 KiB
Python
181 lines
6.3 KiB
Python
"""Pre-create issue content gate (#582).
|
|
|
|
Blocks issue creation when the title/body is a *vague reference* to
|
|
out-of-band content the LLM cannot prove it has ("the drafted issue",
|
|
"the prepared issue", "the issue we discussed", "the previous draft")
|
|
with no durable source pointer.
|
|
|
|
The rule from #582: an LLM must not fabricate an issue from stale chat
|
|
memory. When the requested issue content is a vague reference and no
|
|
durable source pointer (existing issue/PR/comment, checked-in file, URL,
|
|
or scratchpad path) is present, the gate fails closed and the caller must
|
|
return BLOCKED + DIAGNOSE naming the exact vague/missing fields.
|
|
|
|
This gate intentionally does NOT require a non-empty body: title-only
|
|
issue creation remains allowed for callers that explicitly want it. It
|
|
only blocks content that is a placeholder reference to something the
|
|
current context does not contain.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import unicodedata
|
|
|
|
# Vague reference phrases that point at out-of-band draft content. Stored
|
|
# normalized (lowercase, punctuation-stripped, whitespace-collapsed) so they
|
|
# can be matched against normalized field text.
|
|
VAGUE_REFERENCE_PHRASES: tuple[str, ...] = (
|
|
"the drafted issue",
|
|
"drafted issue",
|
|
"the prepared issue",
|
|
"prepared issue",
|
|
"the issue we discussed",
|
|
"the issue we talked about",
|
|
"the one we discussed",
|
|
"the previous draft",
|
|
"previous draft",
|
|
"the draft above",
|
|
"the issue above",
|
|
"as discussed",
|
|
"as we discussed",
|
|
"as previously discussed",
|
|
"per our discussion",
|
|
"per our conversation",
|
|
"per our chat",
|
|
"see above",
|
|
"same as before",
|
|
"the issue from earlier",
|
|
"the issue i drafted",
|
|
"the issue you drafted",
|
|
)
|
|
|
|
# A field that only restates a vague phrase (optionally with a leading verb
|
|
# such as "create"/"add"/"open"/"file") is still a vague reference.
|
|
_LEADING_VERBS = ("create", "add", "open", "file", "make", "please", "the")
|
|
|
|
# Durable source pointers: if any of these appears, the content points at a
|
|
# retrievable source of truth and is NOT treated as a fabricated reference.
|
|
_DURABLE_SOURCE_REGEXES: tuple[re.Pattern[str], ...] = (
|
|
re.compile(r"#\d+"), # #402
|
|
re.compile(r"\b(?:issue|pull|pr)\s+#?\d+", re.I), # issue 402 / PR #17
|
|
re.compile(r"\bcomment\s+#?\d+", re.I), # comment 8155
|
|
re.compile(r"https?://\S+", re.I), # URL
|
|
re.compile( # checked-in file
|
|
r"[\w./-]+\.(?:py|md|json|txt|sh|ya?ml|toml|cfg|ini|rst)\b", re.I
|
|
),
|
|
re.compile(r"\bscratchpad\b", re.I), # scratchpad path
|
|
re.compile(r"(?:^|\s)/[\w./-]+"), # absolute path
|
|
)
|
|
|
|
|
|
def normalize_text(text: str) -> str:
|
|
"""Lowercase, punctuation-stripped, whitespace-collapsed text."""
|
|
norm = unicodedata.normalize("NFKC", (text or "").strip().lower())
|
|
norm = re.sub(r"[^\w\s]", " ", norm)
|
|
norm = re.sub(r"\s+", " ", norm).strip()
|
|
return norm
|
|
|
|
|
|
def has_durable_source_pointer(text: str) -> bool:
|
|
"""True when the raw text references a durable, retrievable source."""
|
|
raw = text or ""
|
|
return any(rx.search(raw) for rx in _DURABLE_SOURCE_REGEXES)
|
|
|
|
|
|
def find_vague_reference(text: str) -> str | None:
|
|
"""Return the vague phrase a field is dominated by, else ``None``.
|
|
|
|
A field is a vague reference only when it contains a known vague phrase,
|
|
carries no durable source pointer, and the phrase dominates the field
|
|
(the field is essentially just the phrase). Long, specific bodies that
|
|
merely contain "as discussed" in passing are not blocked.
|
|
"""
|
|
if has_durable_source_pointer(text):
|
|
return None
|
|
norm = normalize_text(text)
|
|
if not norm:
|
|
return None
|
|
stripped = norm
|
|
for verb in _LEADING_VERBS:
|
|
if stripped.startswith(verb + " "):
|
|
stripped = stripped[len(verb) + 1:]
|
|
for phrase in VAGUE_REFERENCE_PHRASES:
|
|
if phrase in norm and len(stripped) <= len(phrase) + 12:
|
|
return phrase
|
|
return None
|
|
|
|
|
|
def assess_issue_content(
|
|
title: str,
|
|
body: str = "",
|
|
*,
|
|
allow_incomplete: bool = False,
|
|
) -> dict:
|
|
"""Evaluate whether proposed issue content is durable enough to create.
|
|
|
|
Returns a dict with ``complete`` (bool), ``missing_fields``,
|
|
``vague_fields``, ``reasons``, ``diagnose`` (joined reasons), and
|
|
``has_durable_source_pointer``. ``allow_incomplete`` is an explicit
|
|
operator override that forces ``complete`` True.
|
|
"""
|
|
t = (title or "").strip()
|
|
b = (body or "").strip()
|
|
missing_fields: list[str] = []
|
|
vague_fields: list[str] = []
|
|
reasons: list[str] = []
|
|
|
|
if not t:
|
|
missing_fields.append("title")
|
|
reasons.append("issue title is required")
|
|
else:
|
|
phrase = find_vague_reference(t)
|
|
if phrase:
|
|
vague_fields.append("title")
|
|
reasons.append(
|
|
f"title is a vague reference ('{phrase}') with no durable "
|
|
"content or source pointer"
|
|
)
|
|
|
|
if b:
|
|
phrase = find_vague_reference(b)
|
|
if phrase:
|
|
vague_fields.append("body")
|
|
reasons.append(
|
|
f"body is a vague reference ('{phrase}') with no durable "
|
|
"content or source pointer"
|
|
)
|
|
|
|
complete = allow_incomplete or (not missing_fields and not vague_fields)
|
|
return {
|
|
"complete": complete,
|
|
"missing_fields": missing_fields,
|
|
"vague_fields": vague_fields,
|
|
"reasons": reasons,
|
|
"diagnose": "; ".join(reasons),
|
|
"has_durable_source_pointer": has_durable_source_pointer(b)
|
|
or has_durable_source_pointer(t),
|
|
"override_applied": bool(
|
|
allow_incomplete and (missing_fields or vague_fields)
|
|
),
|
|
}
|
|
|
|
|
|
def pre_create_issue_content_gate(
|
|
title: str,
|
|
body: str = "",
|
|
*,
|
|
allow_incomplete: bool = False,
|
|
) -> dict:
|
|
"""Content gate wrapper mirroring the duplicate gate shape.
|
|
|
|
``performed`` is True when the content is durable enough to create (or an
|
|
explicit override was supplied). When False, the caller must return
|
|
BLOCKED + DIAGNOSE and must not create the issue.
|
|
"""
|
|
assessment = assess_issue_content(
|
|
title, body, allow_incomplete=allow_incomplete
|
|
)
|
|
assessment["performed"] = assessment["complete"]
|
|
return assessment
|