Files
planer2/backend/inhalt.py
2026-07-27 16:43:11 +02:00

244 lines
9.6 KiB
Python

"""Inhaltsprüfung: gleicht die Karten mit dem Quelltext ab.
Die Stilprüfung fragt „ist das gut geschrieben", diese hier fragt „stimmt das
überhaupt". Ziel ist, dass alle Karten zusammen alles abbilden, was das Programm
kann — und nichts, was es nicht kann.
In drei Schritten, weil ein einzelner nicht funktioniert hat:
1. **Inventar** — das Modell liest nur den Quelltext und listet die Fähigkeiten.
Ohne die Karten daneben, sonst bestätigt es bloß, was schon dasteht. Jede
Fähigkeit braucht eine wörtlich zitierte Codezeile; ohne Beleg fällt sie weg.
Mehrere Durchgänge werden vereinigt: hier zählt Vollständigkeit.
2. **Abdeckung** — je Fähigkeit eine kurze Frage: deckt eine bestehende Funktion
das ab? Eine Ja/Nein-Frage ist weit stabiler als offenes Suchen. Nur wenn alle
Stimmen „nein" sagen, entsteht ein Vorschlag.
Geprüft wird nur eine Richtung: was fehlt. Die Gegenrichtung — „diese Funktion hat
keinen Code, weg damit" — ist bewusst nicht eingebaut. Sie ließ sich nicht belegen
(ein Nichtvorhandensein lässt sich nicht zitieren), urteilte von Runde zu Runde
anders und schlug dabei vor, gültige Inhalte zu löschen. Karten dürfen außerdem
Dinge festhalten, die kein Knopf sind — Schreibregeln etwa.
"""
import asyncio
import difflib
import re
import agents
import config
import quellcode
# Ohne Drossel liefen alle Punkte gleichzeitig los und der Provider antwortete
# mit 429. Die ausgefallenen Stimmen sahen dann aus wie „alles abgedeckt".
_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 240 Anfragen je Lauf ist ein Ausrutscher normal, ein Abbruch wäre es nicht."""
for versuch in range(2):
try:
return _json(await _frage(system, nachricht, temperatur + 0.1 * versuch))
except ValueError:
continue
return None
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."""
# Achtung: dieser Text geht unformatiert als System-Prompt raus. Doppelte Klammern
# wären hier keine Escapes, sondern landeten wörtlich in der Antwort.
# Sechs Stimmen, fünf müssen „fehlt" sagen.
#
# Einstimmigkeit war zu scharf: gemessen kippt dieselbe Frage auch bei Temperatur 0
# gelegentlich, und eine einzelne Ausreißerstimme löschte damit auch klare Funde.
# Eindeutige Stellen antworten 6:0 oder 0:6, Grenzfälle etwa 3:3 — die Schwelle bei
# fünf trennt beides und übersteht einen Ausrutscher.
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)
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. Ziel, Regeln und
Testschritte sagen es dagegen ausdrücklich.
"""
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)]
async def luecken(punkte: list, features: list) -> list:
"""Interaktionspunkte, die einstimmig von keiner Funktion abgedeckt sind."""
liste = _funktionsliste(features)
karten = {f["sektionen"]["titel"]: f for f in features}
getroffen = set() # Funktionen, die eine Stimme als Treffer genannt hat
async def eine(punkt):
urteile = await _abdeckung(punkt, liste)
for u in urteile:
if u.get("abgedeckt") and u.get("funktion"):
getroffen.add(str(u["funktion"]).strip())
if len(urteile) < len(URTEIL_TEMPERATUREN):
# Nicht verschweigen: eine ausgefallene Stimme sähe sonst aus wie
# „ist abgedeckt" und der Lauf meldete stillschweigend zu wenig.
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]
karte = max(set(ziele), key=ziele.count)
treffer = karten.get(karte) or _naechste_karte(karte, karten)
if not treffer:
return None
titel = [str(u.get("titel") or "").strip() for u in urteile if u.get("titel")]
if 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]
luecken.getroffen = getroffen
# Zwei Punkte 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 _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
def probleme(features: list, fehlend: list) -> list:
"""Aus den Lücken die Ergänzungen an den Funktionslisten ableiten."""
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
# die halbe Liste verschieben und aus einer Ergänzung einen Umbau machen.
# Angezeigt wird ohnehin alphabetisch.
neu = list(dict.fromkeys(ist + [x["titel"] for x in dazu]))
belege = " · ".join(x["beleg"] for x in dazu)
for p in pruefung._stellen("funktionen", "\n".join(ist), "\n".join(neu),
_grund(dazu), belege):
p["feature"] = f["id"]
p["funktion"] = ""
raus.append(p)
return raus
def _grund(dazu: list) -> str:
return (f"{len(dazu)} Fähigkeit ohne Funktion" if len(dazu) == 1
else f"{len(dazu)} Fähigkeiten ohne Funktion")
async def pruefe(features: list, quelle: dict, quelltext: str):
"""Der ganze Abgleich: Interaktionspunkte, Lücken, Überhang."""
punkte = quellcode.interaktionen(quelle)
if not punkte:
raise RuntimeError("Keine Interaktionspunkte im Quelltext gefunden")
fehlend = await luecken(punkte, features)
return probleme(features, fehlend), punkte