Files
planer2/backend/inhalt.py
2026-07-28 08:36:49 +02:00

357 lines
14 KiB
Python

"""Zwei Abgleiche zwischen Karten und Quelltext, in beide Richtungen.
**Feature im Code** — steht hinter jeder Funktion einer Karte auch Code?
**Code im Feature** — hat jede Stelle im Code auch eine Funktion auf einer Karte?
Beide arbeiten mit derselben Beweislast, nur andersherum.
Ein Vorhandensein lässt sich zitieren, ein Nichtvorhandensein nicht. Darum fragt
„Feature im Code" sechsmal unabhängig nach dem Beleg und meldet erst, wenn keine
einzige Stimme eine Codezeile findet, die auch wirklich dasteht. Ein geprüfter Fund
genügt zum Freispruch — sechs Fehlversuche sind die Anklage. Ein früherer Versuch
ließ das Modell direkt urteilen und schlug dabei vor, gültige Inhalte zu löschen.
„Code im Feature" fragt umgekehrt je Stelle, ob eine bestehende Funktion sie
abdeckt, und meldet ab fünf von sechs Nein-Stimmen. Einstimmigkeit war zu scharf:
dieselbe Frage kippt auch bei Temperatur 0 gelegentlich.
Was der Code selbst weiß, entscheidet der Code: die Liste der Bedienstellen kommt
aus `quellcode.interaktionen()`, jeder Beleg wird gegen die Datei geschlagen.
"""
import asyncio
import difflib
import re
import agents
import config
import quellcode
# Ohne Drossel liefen alle Anfragen gleichzeitig los und der Provider antwortete
# mit 429. Die ausgefallenen Stimmen sahen dann aus wie „nichts gefunden".
_schranke = None
def _drossel():
global _schranke
if _schranke is None:
_schranke = asyncio.Semaphore(config.PRUEFUNG_PARALLEL)
return _schranke
async def _frage(system: str, nachricht: str, temperatur: float) -> str:
async with _drossel():
return await agents.frage(system, nachricht, temperatur)
async def _urteil(system: str, nachricht: str, temperatur: float):
"""Eine Stimme als JSON. Unlesbares wird einmal neu gefragt, dann aufgegeben —
bei hunderten Anfragen je Lauf ist ein Ausrutscher normal, ein Abbruch nicht."""
for versuch in range(3):
try:
return _json(await _frage(system, nachricht, temperatur + 0.1 * versuch))
except ValueError:
continue
return None
# Achtung: diese Texte gehen unformatiert als System-Prompt raus. Doppelte Klammern
# wären hier keine Escapes, sondern landeten wörtlich in der Antwort.
SUCHE_SYSTEM = """Du suchst im Quelltext den Code zu einer beschriebenen Funktion.
Antworte ausschließlich mit JSON:
{"gefunden": true, "datei": "...", "beleg": "..."}
oder
{"gefunden": false, "grund": "..."}
- datei: der Pfad genau so, wie er über der Datei steht
- beleg: eine Zeile aus dieser Datei, Zeichen für Zeichen abgeschrieben
- grund: höchstens 8 Wörter, wenn es den Code nicht gibt
Suche gründlich und im ganzen Quelltext. Es zählt nicht nur, was einen Knopf hat:
Hintergrundlogik, Parser, Dateiverwaltung, Prüfregeln und Anzeigeverhalten sind
genauso Code. Ein erfundener Beleg ist wertlos — er wird nachgeschlagen."""
SUCHE_NACHRICHT = """{quelle}
Ende des Quelltextes.
Eine Karte beschreibt diese Funktion:
{funktion}
Finde die Stelle im Quelltext, die das umsetzt. Antworte ausschließlich mit dem
JSON-Objekt."""
ABDECKUNG_SYSTEM = """Du entscheidest, ob eine Stelle im Programm schon von einer
beschriebenen Funktion abgedeckt ist. Antworte ausschließlich mit JSON."""
ABDECKUNG = """An dieser Stelle kann eine Person etwas auslösen:
{art} in {datei}, Zeile {zeile}
```
{umfeld}
```
{rumpf}
Die Karten des Projekts beschreiben bisher das hier:
{funktionen}
Beschreibt eine dieser Funktionen, was an der Stelle oben passiert?
Wortwahl darf abweichen; entscheidend ist die Sache. Ziel, Regeln und Testschritte
einer Funktion gehören mit zu dem, was sie abdeckt.
Antworte ausschließlich mit JSON:
{{"abgedeckt": true, "funktion": "..."}}
oder
{{"abgedeckt": false, "karte": "...", "titel": "..."}}
- funktion: der bestehende Funktionstitel, wörtlich, wenn abgedeckt
- karte: der Titel der Karte, in die die Fähigkeit thematisch gehört
- titel: wie die neue Funktion heißen soll — Objekt und Verb im Infinitiv,
höchstens 5 Wörter
Rein technische Aufhänger gelten als abgedeckt: Speichern beim Verlassen eines
Feldes, Abbrechen mit Escape, Gesundheitsprüfungen, interne Weiterleitung.
Im Zweifel abgedeckt. Nur was einer Person wirklich fehlt, soll dazukommen."""
# Sechs unabhängige Versuche, den Beleg zu finden. Eine einzige Stimme, die etwas
# findet, genügt zum Freispruch — die Anklage lautet auf Löschen, da wiegt ein
# Fehlurteil schwerer als ein übersehener Rest.
SUCHE_TEMPERATUREN = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0)
# So viele Stimmen müssen überhaupt geantwortet haben, bevor angeklagt wird
ANKLAGE_AB = 4
# Sechs Stimmen, fünf müssen „fehlt" sagen. Eindeutige Stellen antworten 6:0 oder
# 0:6, Grenzfälle etwa 3:3 — die Schwelle bei fünf trennt beides.
URTEIL_TEMPERATUREN = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0)
FEHLT_AB = 5
# Ab dieser Ähnlichkeit gelten zwei Titel als dieselbe Sache
GLEICH = 0.75
def _normal(text: str) -> str:
return re.sub(r"[^a-zä-ü0-9 ]", "", (text or "").lower()).strip()
def _aehnlich(a: str, b: str) -> float:
return difflib.SequenceMatcher(None, _normal(a), _normal(b)).ratio()
def _json(antwort: str) -> dict:
import pruefung # zur Laufzeit, sonst Ringschluss
return pruefung._json_lesen(antwort)
# ---------------------------------------------------------------- Feature im Code
def _funktionstext(karte: str, f: dict) -> str:
"""Alles, was die Funktion behauptet — danach wird im Code gesucht."""
zeilen = [f"Karte: {karte}", f'Funktion: {f["text"]}']
if f["ziel"]:
zeilen.append(f'Ziel: {f["ziel"]}')
for feld in ("regeln", "testen"):
if f[feld]:
eintraege = [z.strip("- ").strip() for z in f[feld].splitlines() if z.strip()]
zeilen.append(f'{feld.capitalize()}: {"; ".join(eintraege)}')
return "\n".join(zeilen)
async def feature_im_code(features: list, quelle: dict, quelltext: str,
zaehl=None) -> list:
"""Funktionen, für die keine Stimme einen nachprüfbaren Beleg findet."""
auftraege = [(f, x) for f in features for x in f["sektionen"]["funktionen"]]
async def eine(paar):
feature, x = paar
nachricht = SUCHE_NACHRICHT.format(
quelle=quelltext,
funktion=_funktionstext(feature["sektionen"]["titel"], x))
proben = await asyncio.gather(*(
_urteil(SUCHE_SYSTEM, nachricht, t) for t in SUCHE_TEMPERATUREN
), return_exceptions=True)
if zaehl:
zaehl()
gruende = []
brauchbar = belegt = behauptet = 0
for u in proben:
if not isinstance(u, dict):
continue
brauchbar += 1
if u.get("gefunden"):
# Auch ein misslungenes Zitat ist ein Widerspruch. Ein Modell
# verschreibt sich beim Abschreiben von Backticks — daraus eine
# Löschempfehlung zu machen, wäre grob falsch.
behauptet += 1
if quellcode.belegt(quelle, str(u.get("datei") or "").strip(),
str(u.get("beleg") or "").strip()):
belegt += 1
elif u.get("grund"):
gruende.append(str(u["grund"]).strip())
if belegt:
return None # geprüfter Beleg: die Funktion steht im Code
if behauptet:
return None # widersprüchliche Stimmen: nichts behaupten
if brauchbar < ANKLAGE_AB:
# Zu wenige Stimmen für eine Löschempfehlung. Lieber laut scheitern
# als eine gültige Funktion wegen eines Ausfalls anzuklagen.
raise RuntimeError(
f'{x["text"]}: nur {brauchbar} von '
f"{len(SUCHE_TEMPERATUREN)} Stimmen brauchbar")
return {"feature": feature["id"], "titel": x["text"],
"grund": gruende[0] if gruende else "Kein Beleg im Code gefunden"}
return [x for x in await asyncio.gather(*(eine(a) for a in auftraege)) if x]
def probleme_feature(features: list, ohne_code: list) -> list:
"""Ein Vorschlag je Karte: die betroffenen Funktionen aus der Liste nehmen."""
import pruefung
raus = []
for f in features:
ist = [x["text"] for x in f["sektionen"]["funktionen"]]
betroffen = [x for x in ohne_code if x["feature"] == f["id"]]
if not betroffen:
continue
weg = [x["titel"] for x in betroffen]
neu = [t for t in ist if t not in weg]
grund = (f"{len(weg)} Funktion ohne Code" if len(weg) == 1
else f"{len(weg)} Funktionen ohne Code")
beleg = " · ".join(f'{x["titel"]}: {x["grund"]}' for x in betroffen)
for p in pruefung._stellen("funktionen", "\n".join(ist), "\n".join(neu),
grund, beleg):
p["feature"] = f["id"]
p["funktion"] = ""
raus.append(p)
return raus
# ---------------------------------------------------------------- Code im Feature
def _funktionsliste(features: list) -> str:
"""Die Karten mit allem, was sie sagen.
Nur die Titel reichten nicht: der Richter musste raten, ob eine Funktion eine
Stelle abdeckt, und urteilte von Runde zu Runde anders.
"""
teile = []
for f in features:
s = f["sektionen"]
block = [f'## Karte: {s["titel"]}']
if s["ziel"]:
block.append(f'Ziel: {s["ziel"]}')
for x in s["funktionen"]:
block.append(f'\n### {x["text"]} (Karte: {s["titel"]})')
if x["ziel"]:
block.append(f'Ziel: {x["ziel"]}')
for feld in ("regeln", "testen"):
if x[feld]:
zeilen = ", ".join(z.strip("- ").strip()
for z in x[feld].splitlines() if z.strip())
block.append(f'{feld.capitalize()}: {zeilen}')
teile.append("\n".join(block))
return "\n\n".join(teile) or "—"
async def _abdeckung(punkt: dict, liste: str) -> list:
rumpf = f'\nDahinter steht:\n\n```\n{punkt["rumpf"]}\n```\n' if punkt["rumpf"] else ""
nachricht = ABDECKUNG.format(funktionen=liste, art=punkt["art"],
datei=punkt["datei"], zeile=punkt["zeile"],
umfeld=punkt["umfeld"], rumpf=rumpf)
proben = await asyncio.gather(*(
_urteil(ABDECKUNG_SYSTEM, nachricht, t) for t in URTEIL_TEMPERATUREN
), return_exceptions=True)
return [p for p in proben if isinstance(p, dict)]
def _naechste_karte(name: str, karten: dict):
if not name:
return None
treffer = max(karten, key=lambda k: _aehnlich(name, k))
return karten[treffer] if _aehnlich(name, treffer) > 0.6 else None
async def code_im_feature(features: list, punkte: list, zaehl=None) -> list:
"""Bedienstellen, die von keiner Funktion abgedeckt sind."""
liste = _funktionsliste(features)
karten = {f["sektionen"]["titel"]: f for f in features}
async def eine(punkt):
urteile = await _abdeckung(punkt, liste)
if zaehl:
zaehl()
if len(urteile) < FEHLT_AB:
# Nicht verschweigen: ausgefallene Stimmen sähen sonst aus wie „ist
# abgedeckt" und der Lauf meldete stillschweigend zu wenig. Ein
# einzelner Ausfall darf den Lauf aber nicht abreißen — es zählt, ob
# noch genug Stimmen übrig sind, um die Schwelle zu erreichen.
raise RuntimeError(
f'{punkt["schluessel"]}: nur {len(urteile)} von '
f"{len(URTEIL_TEMPERATUREN)} Stimmen brauchbar")
if sum(u.get("abgedeckt") is False for u in urteile) < FEHLT_AB:
return None
ziele = [str(u.get("karte") or "").strip() for u in urteile]
name = max(set(ziele), key=ziele.count)
treffer = karten.get(name) or _naechste_karte(name, karten)
titel = [str(u.get("titel") or "").strip() for u in urteile if u.get("titel")]
if not treffer or not titel:
return None
return {"feature": treffer["id"],
"titel": max(titel, key=lambda t: sum(_aehnlich(t, x) for x in titel)),
"beleg": f'{punkt["datei"]}:{punkt["zeile"]}'}
gefunden = [x for x in await asyncio.gather(*(eine(p) for p in punkte)) if x]
# Zwei Stellen können auf dieselbe fehlende Funktion zeigen
raus = []
for x in gefunden:
gleich = next((y for y in raus if y["feature"] == x["feature"]
and _aehnlich(y["titel"], x["titel"]) > GLEICH), None)
if gleich:
gleich["beleg"] += f', {x["beleg"]}'
else:
raus.append(x)
return raus
def probleme_code(features: list, fehlend: list) -> list:
"""Ein Vorschlag je Karte: die fehlenden Funktionen anhängen."""
import pruefung
raus = []
for f in features:
ist = [x["text"] for x in f["sektionen"]["funktionen"]]
dazu = [x for x in fehlend if x["feature"] == f["id"]]
if not dazu:
continue
# Bestehende Reihenfolge behalten, Neues hinten anhängen — sortieren würde
# aus einer Ergänzung einen Umbau machen. Angezeigt wird ohnehin alphabetisch.
neu = list(dict.fromkeys(ist + [x["titel"] for x in dazu]))
grund = (f"{len(dazu)} Stelle ohne Funktion" if len(dazu) == 1
else f"{len(dazu)} Stellen ohne Funktion")
beleg = " · ".join(x["beleg"] for x in dazu)
for p in pruefung._stellen("funktionen", "\n".join(ist), "\n".join(neu),
grund, beleg):
p["feature"] = f["id"]
p["funktion"] = ""
raus.append(p)
return raus
# ---------------------------------------------------------------- Einstiege
async def pruefe_feature_im_code(features: list, quelle: dict, quelltext: str,
zaehl=None) -> list:
ohne = await feature_im_code(features, quelle, quelltext, zaehl)
return probleme_feature(features, ohne)
async def pruefe_code_im_feature(features: list, quelle: dict, zaehl=None) -> list:
punkte = quellcode.interaktionen(quelle)
if not punkte:
raise RuntimeError("Keine Bedienstellen im Quelltext gefunden")
return probleme_code(features, await code_im_feature(features, punkte, zaehl))