"""Zwei Abgleiche zwischen Karten und Quelltext, in beide Richtungen. **Feature im Code** — steht hinter jeder Funktion einer Karte auch Code? Dazu: beschreiben zwei Funktionen dieselbe Fähigkeit? **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. Jedes Urteil wandert in `ledger`. Der nächste Lauf fragt nur noch, was er nicht schon weiß — dadurch liefert derselbe Stand dasselbe Ergebnis. Auch das unentschiedene Urteil wird gemerkt: es galt als abgedeckt, und ein Münzwurf, der jede Runde neu fällt, ist schlimmer als einer, der einmal fällt. Ändert sich die Stelle im Code, wird sie ohnehin neu beurteilt. """ import asyncio import difflib import re from collections import Counter import agents import config import ledger 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 = """Diese Stelle im Programm lässt sich 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, und die Regeln der Karte ebenso: eine Zusicherung wie „alphabetisch sortiert" ist beschrieben, auch wenn kein Knopf dazu gehört. 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. Fehlerbehandlung ebenso — eine Fehlermeldung ist kein eigener Auftrag, sondern gehört zu der Fähigkeit, bei der etwas schiefging. Im Zweifel abgedeckt. Nur was einer Person wirklich fehlt, soll dazukommen.""" DUBLETTE_SYSTEM = """Du entscheidest, ob zwei Funktionsbeschreibungen dieselbe Fähigkeit meinen. Antworte ausschließlich mit JSON.""" DUBLETTE = """Zwei Funktionen aus den Karten eines Projekts: A {text_a} B {text_b} Aufgefallen sind sie so: {anlass} Beantworte der Reihe nach, allein aus Titel und Testschritten: 1. Woran arbeitet A? Der Gegenstand, an dem sich etwas ändert. 2. Woran arbeitet B? 3. Meinen 1 und 2 denselben Gegenstand? 4. Was ist anders, nachdem A ausgeführt wurde? 5. Was ist anders, nachdem B ausgeführt wurde? 6. Beschreiben 4 und 5 denselben Zustand? Es zählt das Ergebnis, nicht das Wort: verschiedene Wörter für dasselbe Ergebnis sind gleich, ein Gegenstück nicht. Antworte ausschließlich mit JSON: {{"gegenstand_a": "...", "gegenstand_b": "...", "gegenstand_gleich": true, "ergebnis_a": "...", "ergebnis_b": "...", "ergebnis_gleich": true}} Urteile nicht selbst über die Dublette. Ob es eine ist, wird aus deinen Antworten auf 3 und 6 errechnet.""" # 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 # So viele Stimmen müssen geantwortet haben, bevor überhaupt geurteilt wird. Das # Urteil wird gemerkt und gilt dann dauerhaft — dafür reichen zwei Überlebende nicht. URTEIL_AB = 4 # Ab dieser Ähnlichkeit gelten zwei Titel als dieselbe Sache GLEICH = 0.75 # Drei Stimmen über ein Paar, einstimmig für die Dublette. Ein Löschvorschlag darf # nicht an einer Einzelmeinung hängen. DUBLETTE_TEMPERATUREN = (0.0, 0.3, 0.6) # Kandidatenkanal „ähnlicher Titel". Höher als GLEICH: hier zahlt jedes Paar drei # Anfragen, und „Feature anlegen"/„Feature entfernen" liegen schon bei 0,75. TITEL_KANAL = 0.85 # Kandidatenkanal „ähnlicher Inhalt". Anteil der Wörter, die die kürzere der beiden # Beschreibungen mit der längeren teilt. Überlappung statt Jaccard, weil eine knappe # Dublette sonst an der Länge der ausführlicheren scheitert. INHALT_KANAL = 0.7 # Wörter, die in so vielen Funktionen vorkommen, tragen nichts zur Unterscheidung # bei. „Vollmodus öffnen", „Text ändern" stehen fast überall — ohne diesen Filter # ähnelt jede Bearbeiten-Funktion jeder anderen zu 90 %. FLOSKEL_ANTEIL = 0.2 # Deckel gegen Explosion bei vielen Funktionen; was darüber liegt, meldet der Lauf DUBLETTEN_CAP = 40 # Ein gemerktes Urteil gehört zu der Frage, auf die es geantwortet hat. Wird die # Frage umgeschrieben, ist das Urteil von gestern — deshalb hängt der Fingerabdruck # am Wortlaut des Prompts. Sonst müsste man daran denken, eine Zahl hochzuzählen. _SUCHE_STAND = ledger.abdruck(SUCHE_SYSTEM, SUCHE_NACHRICHT) _ABDECKUNG_STAND = ledger.abdruck(ABDECKUNG_SYSTEM, ABDECKUNG) _DUBLETTE_STAND = ledger.abdruck(DUBLETTE_SYSTEM, DUBLETTE) 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) async def _sammle(aufgaben: list, was: str, melde=None) -> list: """Alle Stellen beurteilen, auch wenn einzelne platzen. Vorher riss die erste kaputte Stelle den ganzen Lauf ab. Zwei Folgen, beide schlecht: die schon bezahlten Urteile der anderen waren weg, und das Gedächtnis wurde gespeichert, während die übrigen Anfragen noch liefen — es blieb leer. Jetzt wird gewartet, bis alle durch sind. Was ging, zählt; was nicht ging, steht in der Meldung. Der nächste Lauf holt nur den Rest nach. """ ergebnisse = await asyncio.gather(*aufgaben, return_exceptions=True) kaputt = [e for e in ergebnisse if isinstance(e, BaseException)] if kaputt and melde: melde(f"{len(kaputt)} von {len(ergebnisse)} {was} nicht beurteilt: " f"{kaputt[0]}", len(kaputt)) return [x for x in ergebnisse if x and not isinstance(x, BaseException)] # ---------------------------------------------------------------- Feature im Code def _funktionstext(karte: str, f: dict) -> str: """Alles, was die Funktion behauptet — danach wird im Code gesucht. Ohne Karte, wenn die anderswo schon dasteht: doppelt genannt zog sie beim Dublettenurteil mehr Gewicht auf sich als Titel und Testschritte zusammen. """ zeilen = [f"Karte: {karte}"] if karte else [] zeilen.append(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) def _quellabdruck(quelle: dict) -> str: return ledger.abdruck(_SUCHE_STAND, *(f"{p}\n{t}" for p, t in sorted(quelle.items()))) async def feature_im_code(features: list, quelle: dict, quelltext: str, stand: dict, zaehl=None, melde=None) -> list: """Funktionen, für die keine Stimme einen nachprüfbaren Beleg findet. Ein gemerkter Beleg zählt weiter, solange die zitierte Zeile noch dasteht — dafür braucht es kein Modell, das prüft `quellcode.belegt()` nach. Eine gemerkte Fehlanzeige gilt nur, solange sich am Quelltext nichts geändert hat: sie fordert zum Löschen auf, und dafür muss der Befund frisch sein. """ quellstand = _quellabdruck(quelle) auftraege = [(f, x) for f in features for x in f["sektionen"]["funktionen"]] bekannt = {ledger.schluessel(f["id"], x["text"]) for f, x in auftraege} stand["belege"] = ledger.behalte(stand["belege"], bekannt) stand["ohne_code"] = ledger.behalte(stand["ohne_code"], bekannt) async def eine(paar): feature, x = paar schluessel = ledger.schluessel(feature["id"], x["text"]) text = _funktionstext(feature["sektionen"]["titel"], x) wie_gehabt = ledger.abdruck(text) gemerkt = stand["belege"].get(schluessel) if gemerkt and gemerkt["abdruck"] == wie_gehabt and quellcode.belegt( quelle, gemerkt["datei"], gemerkt["beleg"]): if zaehl: zaehl() return None gemerkt = stand["ohne_code"].get(schluessel) if gemerkt and gemerkt["abdruck"] == wie_gehabt \ and gemerkt["quelle"] == quellstand: if zaehl: zaehl() return {"feature": feature["id"], "titel": x["text"], "art": "ohne_code", "grund": gemerkt["grund"]} nachricht = SUCHE_NACHRICHT.format(quelle=quelltext, funktion=text) proben = await asyncio.gather(*( _urteil(SUCHE_SYSTEM, nachricht, t) for t in SUCHE_TEMPERATUREN ), return_exceptions=True) if zaehl: zaehl() gruende = [] brauchbar = behauptet = 0 beleg = None 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 datei = str(u.get("datei") or "").strip() zitat = str(u.get("beleg") or "").strip() if beleg is None and quellcode.belegt(quelle, datei, zitat): beleg = {"abdruck": wie_gehabt, "datei": datei, "beleg": zitat} elif u.get("grund"): gruende.append(str(u["grund"]).strip()) stand["belege"].pop(schluessel, None) stand["ohne_code"].pop(schluessel, None) if beleg: stand["belege"][schluessel] = beleg return None # geprüfter Beleg: die Funktion steht im Code if behauptet: return None # widersprüchliche Stimmen: nichts behaupten und nichts merken 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") grund = gruende[0] if gruende else "Kein Beleg im Code gefunden" stand["ohne_code"][schluessel] = {"abdruck": wie_gehabt, "quelle": quellstand, "grund": grund} return {"feature": feature["id"], "titel": x["text"], "art": "ohne_code", "grund": grund} return await _sammle([eine(a) for a in auftraege], "Funktionen", melde) # ---------------------------------------------------------------- Dubletten def _funktionen(features: list) -> dict: """schluessel -> alles, was zur Funktion gehört.""" raus = {} for f in features: for x in f["sektionen"]["funktionen"]: raus[ledger.schluessel(f["id"], x["text"])] = { "feature": f["id"], "karte": f["sektionen"]["titel"], "titel": x["text"], "eintrag": x, "text": _funktionstext("", x), # Ohne die Feldnamen: „Regeln:" steht über jedem Block, und eine # Funktion namens „Regeln anpassen" verlöre sonst ihr eigenes Wort "worte": " ".join((f["sektionen"]["titel"], x["text"], x["ziel"], x["regeln"], x["testen"]))} return raus def _gewicht(f: dict) -> tuple: """Wer mehr sagt, bleibt stehen. Bei Gleichstand entscheidet der Name — nicht schön, aber vorhersagbar; ein Zufallssieger machte den Vorschlag unstet.""" x = f["eintrag"] zeilen = len([z for z in (x["regeln"] + "\n" + x["testen"]).splitlines() if z.strip()]) return (zeilen, len(x["ziel"]), f["titel"]) _WORT = re.compile(r"[a-zä-üß0-9]{4,}") # Beugung abschneiden statt richtig stemmen: „entfernen" und „entfernt" sind # dasselbe Wort, und ein Stemmer legte die Sprache im Code fest. STAMM = 5 def _kerne(alle: dict) -> dict: """Je Funktion die Wörter, die sie von den anderen unterscheiden. Was in jeder fünften Funktion steht, fliegt raus. Sonst misst der Vergleich nur noch die gemeinsame Schrittfolge, und „Titel anpassen" und „Ziel anpassen" stimmen zu hundert Prozent überein. """ mengen = {k: set(_WORT.findall(v["worte"].lower())) for k, v in alle.items()} haeufig = Counter(wort for m in mengen.values() for wort in m) grenze = max(3, int(len(mengen) * FLOSKEL_ANTEIL)) return {k: {w[:STAMM] for w in m if haeufig[w] < grenze} for k, m in mengen.items()} def _ueberlappung(a: set, b: set) -> float: return len(a & b) / min(len(a), len(b)) if a and b else 0.0 def _kandidaten(alle: dict, belege: dict) -> list[tuple]: """Paare, die überhaupt eine Dublette sein könnten, mit dem Anlass. Drei Kanäle, weil eine Dublette auf drei Arten auffällt: dieselbe Codezeile als Beleg, fast derselbe Titel, fast dieselben Wörter. Der letzte fängt, was die ersten beiden durchlassen — „Funktion löschen" und „Funktion entfernen" haben weder denselben Titel noch zwingend denselben Beleg. Jedes Paar zu fragen wäre quadratisch und träfe fast nie. Erst billig eingrenzen, dann einmal teuer urteilen. """ paare = {} def merke(a, b, anlass): if a != b: paare.setdefault(ledger.paarschluessel(a, b), (a, b, anlass)) nach_stelle = {} for name, eintrag in belege.items(): if name in alle: stelle = (eintrag["datei"], re.sub(r"\s+", " ", eintrag["beleg"]).strip()) nach_stelle.setdefault(stelle, []).append(name) for (datei, zeile), gruppe in nach_stelle.items(): gruppe = sorted(gruppe) for i, a in enumerate(gruppe): for b in gruppe[i + 1:]: merke(a, b, f"Beide belegen dieselbe Zeile in {datei}: {zeile}") kern = _kerne(alle) namen = sorted(alle) for i, a in enumerate(namen): for b in namen[i + 1:]: if _aehnlich(alle[a]["titel"], alle[b]["titel"]) >= TITEL_KANAL: merke(a, b, "Die Titel sind fast gleich.") elif _ueberlappung(kern[a], kern[b]) >= INHALT_KANAL: merke(a, b, "Beide Beschreibungen bestehen fast aus denselben Wörtern.") return [(k, *paare[k]) for k in sorted(paare)] async def _ist_dublette(a: dict, b: dict, anlass: str) -> bool: """Einstimmig gleicher Gegenstand und gleiches Ergebnis — sonst keine Dublette. Das Gesamturteil rechnet der Code aus den Teilantworten. Gefragt nach der Dublette an sich, widersprach eine Stimme ihrer eigenen Analyse: derselbe Gegenstand, dasselbe Tun, und trotzdem „nein". Zwei kleine Fragen beantwortet sie verlässlich, die große nicht. Gefragt wird nach dem Ergebnis, nicht nach dem Verb. „Löschen" und „entfernen" hielten zwei von drei Stimmen für verschiedenes Tun — was danach anders ist, beschrieben sie gleich. """ nachricht = DUBLETTE.format(text_a=a["text"], text_b=b["text"], anlass=anlass) proben = await asyncio.gather(*( _urteil(DUBLETTE_SYSTEM, nachricht, t) for t in DUBLETTE_TEMPERATUREN ), return_exceptions=True) stimmen = [p for p in proben if isinstance(p, dict)] if len(stimmen) < len(DUBLETTE_TEMPERATUREN): raise RuntimeError(f'Dublette {a["titel"]}/{b["titel"]}: ' f"nur {len(stimmen)} von " f"{len(DUBLETTE_TEMPERATUREN)} Stimmen brauchbar") return all(u.get("gegenstand_gleich") is True and u.get("ergebnis_gleich") is True for u in stimmen) async def dubletten(features: list, stand: dict, melde=None) -> list: """Funktionen, die eine andere Funktion doppelt beschreiben. Gemeldet wird immer die schwächere von beiden — die stärkere bleibt stehen. Ein einmal gefälltes Urteil bleibt gemerkt, auch das ablehnende: sonst zahlt jeder Lauf erneut für dieselbe Frage und antwortet womöglich anders. """ alle = _funktionen(features) paare = _kandidaten(alle, stand["belege"]) stand["paare"] = ledger.behalte(stand["paare"], {p[0] for p in paare}) if len(paare) > DUBLETTEN_CAP: if melde: melde(f"{len(paare)} Kandidatenpaare, geprüft wurden {DUBLETTEN_CAP}") paare = paare[:DUBLETTEN_CAP] async def urteile(paar): schluessel, a, b, anlass = paar links, rechts = alle[a], alle[b] wie_gehabt = ledger.abdruck(_DUBLETTE_STAND, links["text"], rechts["text"]) gemerkt = stand["paare"].get(schluessel) if gemerkt and gemerkt["abdruck"] == wie_gehabt: gleich = gemerkt["gleich"] else: gleich = await _ist_dublette(links, rechts, anlass) stand["paare"][schluessel] = {"abdruck": wie_gehabt, "gleich": gleich} if not gleich: return None schwach, stark = sorted((links, rechts), key=_gewicht) return {"feature": schwach["feature"], "titel": schwach["titel"], "art": "dublette", "grund": f'Beschreibt „{stark["titel"]}“ ' f'(Karte {stark["karte"]}) ein zweites Mal'} return await _sammle([urteile(p) for p in paare], "Funktionspaare", melde) def _wegfall_grund(betroffen: list) -> str: """Was die Karte verliert, in einem Halbsatz. Ohne Trennung nach Anlass stand über einer Dublette „ohne eigenen Code" — beides heißt streichen, aber aus verschiedenen Gründen, und der Grund entscheidet, ob man zustimmt.""" teile = [] for art, eins, viele in (("dublette", "Funktion doppelt", "Funktionen doppelt"), ("ohne_code", "Funktion ohne eigenen Code", "Funktionen ohne eigenen Code")): n = sum(x["art"] == art for x in betroffen) if n: teile.append(f"{n} {eins if n == 1 else viele}") return ", ".join(teile) 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, gesehen = [], set() for x in ohne_code: if x["feature"] == f["id"] and x["titel"] not in gesehen: gesehen.add(x["titel"]) betroffen.append(x) if not betroffen: continue weg = [x["titel"] for x in betroffen] neu = [t for t in ist if t not in weg] grund = _wegfall_grund(betroffen) 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. Die Regeln der Karte gehören dazu. Vieles, was ein Kunde bestellt, ist keine Bedienung, sondern eine Zusicherung — „eine Datei je Feature", „alphabetisch sortiert". Ohne sie sah der Richter den Code dahinter als unbeschrieben an. """ teile = [] for f in features: s = f["sektionen"] block = [f'## Karte: {s["titel"]}'] if s["ziel"]: block.append(f'Ziel: {s["ziel"]}') if s["regeln"]: zeilen = ", ".join(z.strip("- ").strip() for z in s["regeln"].splitlines() if z.strip()) block.append(f"Regeln der Karte: {zeilen}") 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, schwelle: float = 0.6): if not name or not karten: return None treffer = max(karten, key=lambda k: _aehnlich(name, k)) return karten[treffer] if _aehnlich(name, treffer) >= schwelle else None def _zielkarte(urteile: list, karten: dict): """Auf welche Karte gehört die fehlende Funktion? Die Stimmen schlagen oft Karten vor, die es nicht gibt („Export", „Datenverwaltung"). Vorher fiel der Befund dann weg — eine Stelle, die einstimmig fehlte, wurde verschwiegen, weil man sich über die Ablage nicht einig war. Jetzt gewinnt der erste Vorschlag, der sich auflösen lässt, und zur Not der ähnlichste. Falsch abgelegt kann man verschieben; verschwiegen nicht. """ namen = Counter(str(u.get("karte") or "").strip() for u in urteile if u.get("abgedeckt") is False and u.get("karte")) for name, _ in namen.most_common(): if treffer := karten.get(name) or _naechste_karte(name, karten): return treffer haeufigste = namen.most_common(1) return _naechste_karte(haeufigste[0][0], karten, schwelle=0.0) if haeufigste else None def _stellenabdruck(punkt: dict) -> str: return ledger.abdruck(_ABDECKUNG_STAND, punkt["beleg"], punkt["umfeld"], punkt["rumpf"]) async def code_im_feature(features: list, punkte: list, stand: dict, zaehl=None, melde=None) -> list: """Bedienstellen, die von keiner Funktion abgedeckt sind. Ein gemerktes Urteil gilt, solange es tragen kann: „abgedeckt durch X", solange X noch auf einer Karte steht; „fehlt als Y", solange es kein Y gibt. Beides fällt weg, sobald sich die Codestelle selbst ändert. """ liste = _funktionsliste(features) karten = {f["sektionen"]["titel"]: f for f in features} kennungen = {f["id"] for f in features} titel_da = [x["text"] for f in features for x in f["sektionen"]["funktionen"]] normal_da = {_normal(t) for t in titel_da} stand["abdeckung"] = ledger.behalte(stand["abdeckung"], {p["schluessel"] for p in punkte}) stand["ohne_karte"] = ledger.behalte(stand["ohne_karte"], {p["schluessel"] for p in punkte}) async def eine(punkt): schluessel = punkt["schluessel"] wie_gehabt = _stellenabdruck(punkt) gemerkt = stand["abdeckung"].get(schluessel) if gemerkt and gemerkt["abdruck"] == wie_gehabt \ and (not gemerkt["funktion"] or _normal(gemerkt["funktion"]) in normal_da): if zaehl: zaehl() return None gemerkt = stand["ohne_karte"].get(schluessel) if gemerkt and gemerkt["abdruck"] == wie_gehabt \ and gemerkt["feature"] in kennungen \ and not any(_aehnlich(gemerkt["titel"], t) > GLEICH for t in titel_da): if zaehl: zaehl() return {"feature": gemerkt["feature"], "titel": gemerkt["titel"], "beleg": gemerkt["beleg"]} urteile = await _abdeckung(punkt, liste) if zaehl: zaehl() nein = sum(u.get("abgedeckt") is False for u in urteile) ausgefallen = len(URTEIL_TEMPERATUREN) - len(urteile) if len(urteile) < URTEIL_AB or nein < FEHLT_AB <= nein + ausgefallen: # Zu dünn oder offen. Dünn: zwei überlebende Stimmen genügen rechnerisch # für „abgedeckt", weil die Schwelle unerreichbar wird — als Dauerurteil # wäre das zu wenig. Offen: die ausgefallenen Stimmen hätten es kippen # können. Beides nicht verschweigen; sonst sähe der Ausfall 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") stand["abdeckung"].pop(schluessel, None) stand["ohne_karte"].pop(schluessel, None) # Steht das Ergebnis schon fest, zählen die fehlenden Stimmen nicht mehr. # Vier Stimmen „abgedeckt" bei sechs möglichen können die Schwelle nicht # mehr reißen — daraus einen Fehlschlag zu machen, wäre Verschwendung. if nein < FEHLT_AB: # Abgedeckt — oder unentschieden, was dasselbe Ergebnis hat. Gemerkt # wird beides; der Name nur, wenn die Stimmen sich auf einen einigen. namen = Counter(str(u.get("funktion") or "").strip() for u in urteile if u.get("abgedeckt")) name, treffer = namen.most_common(1)[0] if namen else ("", 0) # Nur ein Name, den es wirklich gibt, taugt als Verfallsdatum. Deckt # eine Regel die Stelle ab, nennt die Stimme deren Wortlaut — der # stünde auf ewig ungültig in der Datei und würde jeden Lauf neu gefragt. gilt = treffer >= len(urteile) / 2 and _normal(name) in normal_da stand["abdeckung"][schluessel] = {"abdruck": wie_gehabt, "funktion": name if gilt else ""} return None treffer = _zielkarte(urteile, karten) titel = [str(u.get("titel") or "").strip() for u in urteile if u.get("titel")] if not treffer or not titel: return None gefunden = {"feature": treffer["id"], "titel": max(titel, key=lambda t: sum(_aehnlich(t, x) for x in titel)), "beleg": f'{punkt["datei"]}:{punkt["zeile"]}'} stand["ohne_karte"][schluessel] = {"abdruck": wie_gehabt, **gefunden} return gefunden gefunden = await _sammle([eine(p) for p in punkte], "Codestellen", melde) # 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(dict(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(projekt: str, features: list, quelle: dict, quelltext: str, zaehl=None, melde=None) -> list: stand = ledger.lade(projekt) try: ohne = await feature_im_code(features, quelle, quelltext, stand, zaehl, melde) doppelt = await dubletten(features, stand, melde) finally: # Auch ein abgebrochener Lauf hat schon Urteile gefällt; die sind bezahlt ledger.sichere(projekt, stand) return probleme_feature(features, ohne + doppelt) async def pruefe_code_im_feature(projekt: str, features: list, quelle: dict, zaehl=None, melde=None) -> list: punkte = quellcode.interaktionen(quelle) if not punkte: raise RuntimeError("Keine Bedienstellen im Quelltext gefunden") stand = ledger.lade(projekt) try: fehlend = await code_im_feature(features, punkte, stand, zaehl, melde) finally: ledger.sichere(projekt, stand) return probleme_code(features, fehlend)