Files
planer/backend/gates.py
2026-07-22 16:12:23 +02:00

311 lines
15 KiB
Python

"""Mechanische Gates (tokenfrei): prüfen LLM-Scan-Ausgaben gegen den echten Code.
Port von phase0/pruefe.py als Bibliothek. Phase-0-validiert: fängt erfundene Anker,
umgeschriebene Zitate (auch Minimal-Umformungen) und Format-Drift; Escaping-Rauschen
der Modelle wird vor dem Vergleich normalisiert.
"""
import difflib
import re
from pathlib import Path
KANTEN_TYPEN = {"ruft-auf", "nutzt", "wird-genutzt-von", "löst-aus", "wird-ausgelöst-von"}
SCHLUESSEL = ("beschreibung", "input", "output") # Pflichtfelder je Einheit
def entschaerfe(s: str) -> str:
"""Escaping-Rauschen normalisieren: \\"\" und \\\\\\ (Erkenntnis 11)."""
return s.replace('\\"', '"').replace("\\\\", "\\")
def _beleg_text(rest: str) -> str:
"""Beleg-Inhalt aus dem Zeilenrest: äußere Anführungszeichen abstreifen, Rest wörtlich.
Kein Quote-Delimiter-Parsing — Belege enthalten selbst Anführungszeichen/Regex
(Abnahme-Befund 17), die Zeile gehört ab `beleg:` komplett dem Zitat."""
rest = rest.strip()
if len(rest) >= 2 and rest.startswith('"') and rest.endswith('"'):
rest = rest[1:-1]
elif rest.startswith('"'):
rest = rest[1:]
return entschaerfe(rest)
def parse_einheiten(text: str) -> tuple[list[dict], list[str], list[str]]:
"""Einheiten-Markdown → (einheiten, zusatz_anker, struktur_fehler).
Einheit: {id, zeile, felder: {beschreibung/input/output}, belege: [(nr, zitat)],
kanten: [(nr, typ, ziel)], anker: [ids]}. Streng schreiben, tolerant parsen."""
einheiten: list[dict] = []
zusatz_anker: list[str] = []
fehler: list[str] = []
aktuelle = None
modus = None
for nr, zeile in enumerate(text.splitlines(), 1):
# Einheiten-Kopf: `## datei::symbol` oder Datei-Einheit `## pfad.py` (Skripte
# ohne def/class — Befund 18)
if zeile.startswith("## ") and ("::" in zeile or re.fullmatch(r"## \S+\.\w+", zeile.strip())):
aktuelle = {"id": zeile[3:].strip(), "zeile": nr, "felder": {}, "belege": [],
"kanten": [], "anker": []}
einheiten.append(aktuelle)
modus = None
elif zeile.startswith(("anker:", "ergaenze-anker:")):
aid = zeile.split(":", 1)[1].strip().split(" zu ")[0].strip()
zusatz_anker.append(aid)
if aktuelle is not None and zeile.startswith("anker:"):
aktuelle["anker"].append(aid)
elif aktuelle is None:
continue
elif m := re.match(r"(beschreibung|input|output):(.*)$", zeile):
aktuelle["felder"][m.group(1)] = m.group(2).strip()
modus = m.group(1)
elif zeile.strip() in ("entscheidungen:", "fakten:"):
modus = "punkte"
elif zeile.strip() == "kanten:":
modus = "kanten"
elif m := re.match(r"\s+beleg:\s*(.+)$", zeile):
aktuelle["belege"].append((nr, _beleg_text(m.group(1))))
elif modus == "punkte" and (m := re.search(r"\bbeleg:\s*(.+)$", zeile)):
aktuelle["belege"].append((nr, _beleg_text(m.group(1)))) # tolerant: beleg inline
elif modus == "kanten" and (m := re.match(r"- ([\wäöü-]+):\s*(\S+)\s*$", zeile)):
aktuelle["kanten"].append((nr, m.group(1), m.group(2)))
elif modus == "kanten" and zeile.startswith("- "):
fehler.append(f"Z{nr}: Kante unlesbar (Zusatztext?): {zeile.strip()!r}")
return einheiten, zusatz_anker, fehler
def _symbol_in_datei(symbol: str, quelle: str, top_level_nur: bool) -> bool:
if "." in symbol: # qualifizierte Methode `Klasse.methode` (Abnahme-Befund)
klasse, _, methode = symbol.partition(".")
return (_symbol_in_datei(klasse, quelle, top_level_nur)
and re.search(rf"def\s+{re.escape(methode)}\b", quelle) is not None)
if top_level_nur:
muster = rf"^(?:async\s+)?(?:def|class)\s+{re.escape(symbol)}\b|^{re.escape(symbol)}\s*[:=]"
else: # Zusatz-Anker dürfen verschachtelt sein (Innenfunktionen, Erkenntnis M3-Lauf)
muster = rf"(?:def|class)\s+{re.escape(symbol)}\b|^{re.escape(symbol)}\s*[:=]"
return re.search(muster, quelle, re.M) is not None
def pruefe_fragment(text: str, wurzel: Path, chunk_datei: str,
quelle_cache: dict | None = None) -> tuple[list[str], list[str], list[dict]]:
"""Gates für EIN Scan-Fragment: Struktur, Anker-Existenz, Zitat-Treue, Pflichtfelder,
Kanten-Grammatik. → (fehler, warnungen, einheiten). Kanten-ZIELE in fremden Dateien
werden nur auf Datei-Existenz geprüft (das Symbol prüft der Scan der Zieldatei)."""
cache = quelle_cache if quelle_cache is not None else {}
def quelle(datei: str) -> str | None:
if datei not in cache:
p = wurzel / datei
cache[datei] = p.read_text(encoding="utf-8", errors="replace") if p.is_file() else None
return cache[datei]
einheiten, zusatz_anker, fehler = parse_einheiten(text)
warnungen: list[str] = []
if not einheiten:
fehler.append("keine Einheit geparst (Format-Drift oder leere Antwort)")
def pruefe_id(eid: str, kontext: str, top_level: bool):
datei, _, symbol = eid.partition("::")
q = quelle(datei)
if q is None:
fehler.append(f"{kontext}: Datei fehlt: {datei}")
elif symbol and not _symbol_in_datei(symbol, q, top_level):
fehler.append(f"{kontext}: Symbol '{symbol}' nicht in {datei}")
for e in einheiten:
kontext = e["id"]
if not e["id"].startswith(chunk_datei + "::") and e["id"] != chunk_datei:
fehler.append(f"{kontext}: Einheit gehört nicht zur gescannten Datei {chunk_datei}")
continue
pruefe_id(e["id"], f"Z{e['zeile']} einheit", top_level=True)
for feld in SCHLUESSEL:
if not e["felder"].get(feld):
(fehler if feld == "beschreibung" else warnungen).append(f"{kontext}: {feld}: fehlt")
q = quelle(chunk_datei) or ""
for nr, beleg in e["belege"]:
if beleg not in q:
fehler.append(f"{kontext} Z{nr}: Zitat nicht wörtlich in {chunk_datei}: \"{beleg[:50]}\"")
if not e["belege"]:
warnungen.append(f"{kontext}: keine Belege")
for aid in e["anker"]:
pruefe_id(aid, f"{kontext} anker", top_level=False)
for nr, typ, ziel in e["kanten"]:
if typ not in KANTEN_TYPEN:
fehler.append(f"{kontext} Z{nr}: unbekannter Kanten-Typ '{typ}'")
ziel_datei = ziel.partition("::")[0]
if ziel_datei == chunk_datei:
pruefe_id(ziel, f"{kontext} Z{nr} kante", top_level=False)
elif quelle(ziel_datei) is None:
fehler.append(f"{kontext} Z{nr} kante: Datei fehlt: {ziel_datei}")
for aid in zusatz_anker:
if not aid.startswith(chunk_datei + "::"):
fehler.append(f"ergaenze-anker außerhalb der gescannten Datei: {aid}")
return fehler, warnungen, einheiten
def fehlende_symbole(chunk_symbole: list[str], einheiten: list[dict],
zusatz_anker: list[str]) -> list[str]:
"""Abdeckung eines Chunks: welche seiner Top-Level-Symbole sind keiner Einheit
zugeordnet (weder als ID noch als Anker)?"""
zugeordnet = {e["id"].partition("::")[2] for e in einheiten}
zugeordnet |= {a.partition("::")[2] for a in zusatz_anker}
zugeordnet |= {a.partition("::")[2] for e in einheiten for a in e["anker"]}
return [s for s in chunk_symbole if s not in zugeordnet]
def repariere_kanten(fragment: str, wurzel: Path, chunk_datei: str,
quelle_cache: dict | None = None) -> tuple[str, list[str]]:
"""Mechanische Reparatur VOR den Gates: Kanten mit ungültigem Ziel (erfundene Datei,
fehlendes Symbol, fehlender datei::-Prefix) werden GESTRICHEN statt das ganze
Fragment scheitern zu lassen — Kanten sind Zusatz-Metadaten, keine Belege.
→ (bereinigtes_fragment, gestrichene_zeilen)."""
cache = quelle_cache if quelle_cache is not None else {}
def quelle(datei: str) -> str | None:
if datei not in cache:
p = wurzel / datei
cache[datei] = p.read_text(encoding="utf-8", errors="replace") if p.is_file() else None
return cache[datei]
def ziel_ok(ziel: str) -> bool:
if "::" not in ziel:
return False
datei, _, symbol = ziel.partition("::")
q = quelle(datei)
if q is None:
return False
return datei != chunk_datei or _symbol_in_datei(symbol, q, top_level_nur=False)
gestrichen: list[str] = []
zeilen_neu: list[str] = []
for zeile in fragment.splitlines():
m = re.match(r"- ([\wäöü-]+):\s*(\S+)\s*$", zeile)
if m and m.group(1) in KANTEN_TYPEN and not ziel_ok(m.group(2)):
gestrichen.append(zeile.strip())
continue
# Freitext hinter einem Kanten-Typ ("- löst-aus: SystemExit bei …") → streichen
m_frei = re.match(r"- ([\wäöü-]+):\s+\S+\s+\S", zeile)
if m_frei and m_frei.group(1) in KANTEN_TYPEN:
gestrichen.append(zeile.strip())
continue
if zeile.startswith(("anker:", "ergaenze-anker:")): # kaputte Anker-Zeilen ebenso
aid = zeile.split(":", 1)[1].strip().split(" zu ")[0].strip()
datei, _, symbol = aid.partition("::")
q = quelle(datei) if datei else None
if datei != chunk_datei or not symbol or q is None \
or not _symbol_in_datei(symbol, q, top_level_nur=False):
gestrichen.append(zeile.strip())
continue
zeilen_neu.append(zeile)
return "\n".join(zeilen_neu), gestrichen
_BELEG_RE = re.compile(r"(\bbeleg:\s*)(.+)$")
def repariere_belege(fragment: str, wurzel: Path, chunk_datei: str,
quelle_cache: dict | None = None) -> tuple[str, list[str]]:
"""Mechanische Zitat-Reparatur: ein Beleg, der nicht wörtlich in der Quelle steht,
wird per Fuzzy-Match auf die ähnlichste Quellzeile GESCHNAPPT und durch deren
wörtlichen Text ersetzt (Abnahme-Befund: Modelle schreiben z. B. `:=` zu `=` um —
ein Zeichen daneben). Unter der Ähnlichkeitsschwelle bleibt der Beleg unverändert
und fällt im Gate durch → Befund-Feedback. → (fragment, korrigierte_belege)."""
cache = quelle_cache if quelle_cache is not None else {}
if chunk_datei not in cache:
p = wurzel / chunk_datei
cache[chunk_datei] = p.read_text(encoding="utf-8", errors="replace") if p.is_file() else None
quelle = cache[chunk_datei]
if quelle is None:
return fragment, []
zeilen = [z.strip() for z in quelle.splitlines() if z.strip()]
korrigiert: list[str] = []
def snap(m: re.Match) -> str:
zitat = _beleg_text(m.group(2))
if not zitat or zitat in quelle:
return m.group(0)
# Fenster-Matching: Abdeckung des Zitats durch gemeinsame Blöcke mit einer
# Quellzeile — funktioniert auch für kurze Zitate aus langen Zeilen.
beste, beste_deckung = None, 0.0
for z in zeilen:
sm = difflib.SequenceMatcher(None, zitat.lower(), z.lower(), autojunk=False)
if sm.real_quick_ratio() * max(len(zitat), len(z)) < len(zitat) * 0.8:
continue
bloecke = [b for b in sm.get_matching_blocks() if b.size > 0]
deckung = sum(b.size for b in bloecke) / len(zitat)
if deckung > beste_deckung:
beste_deckung = deckung
start = min(b.b for b in bloecke)
ende = max(b.b + b.size for b in bloecke)
beste = z[start:ende] # wörtliches Quell-Fenster
if beste is None or beste_deckung < 0.8 or beste not in quelle:
return m.group(0)
korrigiert.append(f"{zitat!r}{beste[:60]!r}")
return m.group(1) + '"' + beste[:78] + '"'
neu = "\n".join(_BELEG_RE.sub(snap, zeile) for zeile in fragment.splitlines())
return neu, korrigiert
def repariere_referenzen(sichten_text: str, bekannte_ids: set[str]) -> tuple[str, list[str]]:
"""Mechanische Reparatur einer Sicht: unbekannte Einheiten-Referenzen werden aus
`einheiten:`-/`tragende-einheiten:`-Zeilen und Flow-Schritten GESTRICHEN; verliert
ein Teilfeature/Schritt alle Referenzen, fällt die Zeile bzw. der `###`-Block weg.
(Abnahme-Befund: Sichten referenzieren Einheiten toter Chunks — per Feedback nicht
heilbar, weil die IDs schlicht nicht existieren.) → (text, gestrichene_refs)."""
gestrichen: list[str] = []
# Symbol → IDs: falscher Datei-Prefix bei eindeutigem Symbol wird KORRIGIERT statt
# gestrichen (Befund 19: repair.py-Einheit als board_inventory.py referenziert)
je_symbol: dict[str, list[str]] = {}
for eid in bekannte_ids:
je_symbol.setdefault(eid.partition("::")[2], []).append(eid)
def filtere(refs: str) -> list[str]:
gueltig = []
for ref in refs.split(","):
ref = ref.strip()
if not ref:
continue
if ref in bekannte_ids:
gueltig.append(ref)
continue
kandidaten = je_symbol.get(ref.partition("::")[2], [])
if len(kandidaten) == 1:
gueltig.append(kandidaten[0])
else:
gestrichen.append(ref)
return gueltig
zeilen_neu: list[str] = []
for zeile in sichten_text.splitlines():
if m := re.match(r"^(einheiten|tragende-einheiten):\s*(.+)$", zeile):
gueltig = filtere(m.group(2))
if gueltig:
zeilen_neu.append(f"{m.group(1)}: {', '.join(gueltig)}")
elif m.group(1) == "einheiten" and zeilen_neu and zeilen_neu[-1].startswith("### "):
zeilen_neu.pop() # Teilfeature ohne einzige gültige Referenz fällt weg
else:
zeilen_neu.append(f"{m.group(1)}:")
elif m := re.match(r"^(\d+\.\s.+\s—\s)(.+)$", zeile):
gueltig = filtere(m.group(2))
if gueltig:
zeilen_neu.append(m.group(1) + ", ".join(gueltig))
# Schritt ohne gültige Referenz fällt weg
else:
zeilen_neu.append(zeile)
return "\n".join(zeilen_neu), gestrichen
def pruefe_referenzen(sichten_text: str, bekannte_ids: set[str]) -> list[str]:
"""Referenz-Integrität einer Sicht: `einheiten:`-/`tragende-einheiten:`-Zeilen und
Flow-Schritte (`1. … — id, id`) dürfen nur bekannte Einheiten-IDs nennen."""
fehler = []
referenz_zeilen = [m.group(1) for m in
re.finditer(r"^(?:einheiten|tragende-einheiten):\s*(.+)$", sichten_text, re.M)]
referenz_zeilen += [m.group(1) for m in
re.finditer(r"^\d+\.\s.+\s—\s(.+)$", sichten_text, re.M)]
for zeile in referenz_zeilen:
for ref in zeile.split(","):
ref = ref.strip()
if ref and ref not in bekannte_ids:
fehler.append(f"unbekannte Einheiten-Referenz: {ref}")
return fehler