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