"""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