{{ TITEL }}
+ +Wie soll es werden?
+Noch nichts festgehalten.
+ +{{ meldung }}
+diff --git a/backend/features.py b/backend/features.py index 064a6ee..c6f38a9 100644 --- a/backend/features.py +++ b/backend/features.py @@ -32,6 +32,12 @@ def liste(projekt: str) -> list[dict]: return sorted(eintraege, key=lambda e: e["sektionen"]["titel"].lower()) +def pruefbar(projekt: str) -> list[dict]: + """Alles außer der Vision. Die hält fest, wie es gedacht ist — sie ist die + Vorlage, nicht der Prüfling, und keine Prüfung schlägt Änderungen daran vor.""" + return [f for f in liste(projekt) if f["sektionen"]["art"] != parser.VISION] + + def anlegen(projekt: str, roh: str) -> dict: ordner = projekte.wurzel(projekt) titel = parser.zerlege(roh)["titel"] diff --git a/backend/inhalt.py b/backend/inhalt.py index a83a65d..88777ec 100644 --- a/backend/inhalt.py +++ b/backend/inhalt.py @@ -1,6 +1,7 @@ """Zwei Abgleiche zwischen Karten und Quelltext, in beide Richtungen. -**Feature im Code** — steht hinter jeder Funktion einer Karte auch Code? +**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. @@ -17,14 +18,22 @@ 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 @@ -86,7 +95,7 @@ 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: +ABDECKUNG = """Diese Stelle im Programm lässt sich auslösen: {art} in {datei}, Zeile {zeile} @@ -100,7 +109,9 @@ Die Karten des Projekts beschreiben bisher das hier: 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. +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": "..."}} @@ -114,8 +125,40 @@ oder 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. @@ -126,8 +169,34 @@ ANKLAGE_AB = 4 # 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: @@ -144,11 +213,34 @@ def _json(antwort: str) -> dict: 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.""" - zeilen = [f"Karte: {karte}", f'Funktion: {f["text"]}'] + """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"): @@ -158,16 +250,47 @@ def _funktionstext(karte: str, f: dict) -> str: 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.""" +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 - nachricht = SUCHE_NACHRICHT.format( - quelle=quelltext, - funktion=_funktionstext(feature["sektionen"]["titel"], x)) + 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) @@ -175,7 +298,8 @@ async def feature_im_code(features: list, quelle: dict, quelltext: str, if zaehl: zaehl() gruende = [] - brauchbar = belegt = behauptet = 0 + brauchbar = behauptet = 0 + beleg = None for u in proben: if not isinstance(u, dict): continue @@ -185,26 +309,196 @@ async def feature_im_code(features: list, quelle: dict, quelltext: str, # 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 + 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()) - if belegt: + 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 + 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"], - "grund": gruende[0] if gruende else "Kein Beleg im Code gefunden"} + "art": "ohne_code", "grund": grund} - return [x for x in await asyncio.gather(*(eine(a) for a in auftraege)) if x] + 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: @@ -214,13 +508,16 @@ def probleme_feature(features: list, ohne_code: list) -> list: 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"]] + 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 = (f"{len(weg)} Funktion ohne Code" if len(weg) == 1 - else f"{len(weg)} Funktionen ohne Code") + 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): @@ -237,6 +534,10 @@ def _funktionsliste(features: list) -> str: 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: @@ -244,6 +545,10 @@ def _funktionsliste(features: list) -> str: 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"]: @@ -268,43 +573,118 @@ async def _abdeckung(punkt: dict, liste: str) -> list: return [p for p in proben if isinstance(p, dict)] -def _naechste_karte(name: str, karten: dict): - if not name: +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) > 0.6 else None + return karten[treffer] if _aehnlich(name, treffer) >= schwelle else None -async def code_im_feature(features: list, punkte: list, zaehl=None) -> list: - """Bedienstellen, die von keiner Funktion abgedeckt sind.""" +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() - 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. + 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") - if sum(u.get("abgedeckt") is False for u in urteile) < FEHLT_AB: + 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 - 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) + + 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 - 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 = {"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 = [x for x in await asyncio.gather(*(eine(p) for p in punkte)) if x] + 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: @@ -313,7 +693,7 @@ async def code_im_feature(features: list, punkte: list, zaehl=None) -> list: if gleich: gleich["beleg"] += f', {x["beleg"]}' else: - raus.append(x) + raus.append(dict(x)) return raus @@ -343,14 +723,26 @@ def probleme_code(features: list, fehlend: list) -> list: # ---------------------------------------------------------------- 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_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(features: list, quelle: dict, zaehl=None) -> list: +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") - return probleme_code(features, await code_im_feature(features, punkte, zaehl)) + 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) diff --git a/backend/ledger.py b/backend/ledger.py new file mode 100644 index 0000000..ccd11e6 --- /dev/null +++ b/backend/ledger.py @@ -0,0 +1,97 @@ +"""Gedächtnis der Inhaltsprüfungen: was einmal beurteilt wurde, bleibt beurteilt. + +Ein Urteil über eine Codestelle kostet sechs Anfragen und fällt bei Grenzfällen mal +so, mal so. Genau daran scheiterte die Wiederholbarkeit: derselbe Stand lieferte in +Runde 1 einen Befund, in Runde 2 keinen, in Runde 3 wieder einen. + +Gespeichert wird deshalb das Urteil, nicht nur das Ergebnis. Der nächste Lauf fragt +nur noch, was er nicht schon weiß. Ein Eintrag gilt weiter, solange seine +Voraussetzung nachweisbar dasteht: + +- ein Beleg, solange die zitierte Zeile noch in der Datei steht +- eine Abdeckung, solange die nennende Funktion noch auf einer Karte steht +- eine Fehlmeldung, solange keine passende Funktion dazugekommen ist + +Fällt die Voraussetzung weg, fällt der Eintrag weg und wird neu gefragt. Damit +bleibt gleicher Stand gleiches Ergebnis, und geänderter Stand wird wirklich neu +beurteilt. + +Die Datei liegt im Projektordner. Sie wandert beim Umbenennen mit und verschwindet +beim Löschen — ohne Zutun, weil beides den Ordner bewegt. Wer von vorn anfangen +will, löscht sie; der nächste Lauf fragt dann wieder alles. +""" + +import hashlib +import json + +import projekte + +DATEI = ".pruefung.json" + +# Ein Fach je Frage. Getrennt, weil sie unterschiedlich ungültig werden. +FAECHER = ( + "belege", # Funktion -> Codestelle, die sie umsetzt (Prüfung 2) + "ohne_code", # Funktion -> kein Code gefunden (Prüfung 2) + "paare", # Funktionspaar -> Dublette oder verschieden (Prüfung 2) + "abdeckung", # Codestelle -> Funktion, die sie beschreibt (Prüfung 3) + "ohne_karte", # Codestelle -> keine Funktion beschreibt sie (Prüfung 3) +) + + +def _pfad(projekt: str): + return projekte.wurzel(projekt) / DATEI + + +def lade(projekt: str) -> dict: + """Der gespeicherte Stand. Kaputte oder fehlende Datei heißt: nichts gemerkt.""" + try: + daten = json.loads(_pfad(projekt).read_text()) + except (OSError, ValueError): + daten = {} + return {fach: dict(daten.get(fach) or {}) for fach in FAECHER} + + +def sichere(projekt: str, stand: dict): + """Sortiert und eingerückt — die Datei soll sich lesen und vergleichen lassen. + + Erst daneben schreiben, dann umbenennen. Ein Absturz mitten im Schreiben ließe + sonst eine halbe Datei zurück, und das Gedächtnis eines ganzen Laufs wäre weg — + teuer bezahlt und nicht wiederherstellbar. + """ + text = json.dumps({fach: stand.get(fach) or {} for fach in FAECHER}, + indent=1, ensure_ascii=False, sort_keys=True) + ziel = _pfad(projekt) + neben = ziel.with_suffix(".json.neu") + neben.write_text(text + "\n") + neben.replace(ziel) + + +def abdruck(*teile: str) -> str: + """Kurzer Fingerabdruck über den Stand, der ein Urteil trägt.""" + roh = json.dumps([t or "" for t in teile], ensure_ascii=False).encode() + return hashlib.sha1(roh).hexdigest()[:16] + + +# Schlüssel sind JSON-Listen. Ein Trennzeichen wäre einfacher, aber Kartennamen und +# Funktionstitel dürfen alles enthalten — und ein Schlüssel, der sich nicht wieder +# zerlegen lässt, ist keiner. + +def schluessel(*teile: str) -> str: + return json.dumps(list(teile), ensure_ascii=False) + + +def paarschluessel(a: str, b: str) -> str: + return json.dumps(sorted((a, b)), ensure_ascii=False) + + +def teile(wert: str) -> list: + try: + gelesen = json.loads(wert) + except ValueError: + return [] + return gelesen if isinstance(gelesen, list) else [] + + +def behalte(fach: dict, gueltig: set) -> dict: + """Einträge zu verschwundenen Stellen wegräumen — sonst wächst die Datei ewig.""" + return {k: v for k, v in fach.items() if k in gueltig} diff --git a/backend/parser.py b/backend/parser.py index 296fc81..e7add51 100644 --- a/backend/parser.py +++ b/backend/parser.py @@ -14,6 +14,16 @@ import re MARKER = re.compile(r"^\s*$") TRENNER = re.compile(r"^-{3,}\s*$") +# „vision" ist keine Einstufung, sondern eine eigene Kartenart: sie hält fest, wie +# es gedacht ist, und wird von keiner Prüfung angefasst. Unbekanntes ist „kern". +VISION = "vision" +ARTEN = ("kern", "optional", VISION) + + +def _art(bloecke: list) -> str: + wert = bloecke[0].strip().lower() if bloecke else "" + return wert if wert in ARTEN else "kern" + def zerlege(roh: str) -> dict: # Erst in Blöcke (schluessel, text) in Dateireihenfolge schneiden @@ -70,7 +80,7 @@ def zerlege(roh: str) -> dict: return { "titel": titel_zeilen[0] if titel_zeilen else "", - "art": "optional" if art and art[0].strip().lower() == "optional" else "kern", + "art": _art(art), "ziel": "\n\n".join(ziel), "regeln": "\n".join(regeln), "funktionen": funktionen, diff --git a/backend/pruefung.py b/backend/pruefung.py index 0a9c262..09776e0 100644 --- a/backend/pruefung.py +++ b/backend/pruefung.py @@ -472,12 +472,12 @@ async def _pruefe(stil: dict, aufgabe: dict) -> list[dict]: async def _stil_aufgaben(projekt: str): """Prüfstellen der Stilprüfung samt der Kataloge, die sie brauchen.""" stil = {b: _stil_lesen(b) for b in BEREICHE} - liste = [a for f in features.liste(projekt) for a in aufgaben(f)] + liste = [a for f in features.pruefbar(projekt) for a in aufgaben(f)] return liste, lambda a, _zaehl: _pruefe(stil, a) def _quelle(projekt: str): - alle = features.liste(projekt) + alle = features.pruefbar(projekt) quelle = quellcode.dateien() if not quelle: raise ValueError("Kein Quelltext gefunden") @@ -485,7 +485,8 @@ def _quelle(projekt: str): async def _feature_aufgaben(projekt: str): - """Ein Lauf über alle Funktionen: steht hinter jeder auch Code? + """Ein Lauf über alle Funktionen: steht hinter jeder auch Code? Und beschreibt + keine zwei Funktionen dieselbe Sache? Ein Aufruf statt vieler, weil das Ergebnis je Karte gebündelt wird — zwei Vorschläge für dieselbe Funktionsliste wären nicht unabhängig anwendbar. @@ -496,7 +497,8 @@ async def _feature_aufgaben(projekt: str): anzahl = sum(len(f["sektionen"]["funktionen"]) for f in alle) async def abgleich(_, zaehl): - return await inhaltspruefung.pruefe_feature_im_code(alle, quelle, text, zaehl) + return await inhaltspruefung.pruefe_feature_im_code( + projekt, alle, quelle, text, zaehl, _melder(projekt)) return _platzhalter(anzahl), abgleich @@ -508,11 +510,26 @@ async def _code_aufgaben(projekt: str): punkte = quellcode.interaktionen(quelle) async def abgleich(_, zaehl): - return await inhaltspruefung.pruefe_code_im_feature(alle, quelle, zaehl) + return await inhaltspruefung.pruefe_code_im_feature( + projekt, alle, quelle, zaehl, _melder(projekt)) return _platzhalter(len(punkte)), abgleich +def _melder(projekt: str): + """Sagt dem Lauf, was schiefging, ohne ihn abzubrechen. + + Ein Sammellauf ist eine einzige Aufgabe. Flöge er wegen einer Stelle, wären + alle anderen Befunde weg — und der Balken stünde trotzdem auf 100 %. So bleibt + das Ergebnis erhalten und der Fehlstand steht daneben. + """ + def melde(text: str, fehler: int = 0): + if lauf := _laeufe.get(projekt): + lauf["meldung"] = text + lauf["fehler"] += fehler + return melde + + def _platzhalter(anzahl: int) -> list: """Eine echte Aufgabe, der Rest nur für den Fortschrittsbalken.""" return [True] + [None] * max(0, anzahl - 1) diff --git a/backend/quellcode.py b/backend/quellcode.py index c7a1d52..6842eb8 100644 --- a/backend/quellcode.py +++ b/backend/quellcode.py @@ -89,7 +89,16 @@ def _umfeld(zeilen: list, nr: int, rand: int = 5) -> str: return "\n".join(zeilen[von:bis]).strip() -_ENDE = re.compile(r"^(function |const |async function |\S)") +def _pyrumpf(zeilen: list, nr: int, hoechstens: int = 14) -> str: + """Der eingerückte Block unter einer Zeile — für Python statt geschweifter Klammern.""" + if nr > len(zeilen): + return "" # Dekorator ohne Rumpf, etwa am Dateiende + block = [zeilen[nr - 1]] + for weiter in zeilen[nr:nr + hoechstens]: + if weiter.strip() and not weiter[:1].isspace() and not weiter.startswith("@"): + break + block.append(weiter) + return "\n".join(block).rstrip() def _rumpf(zeilen: list, name: str, hoechstens: int = 14) -> str: @@ -120,9 +129,17 @@ def interaktionen(quelle: dict = None) -> list[dict]: Dazu Fenster-Lauscher: ein Tastaturgriff ist eine Fähigkeit, auch ohne Knopf. - Backend-Routen stehen bewusst nicht drin. Jede nutzbare Route hängt an einem - Knopf, der schon in der Liste steht — als eigene Einträge wurden sie doppelt - gemeldet und dabei der falschen Karte zugeordnet. + Dazu die Backend-Routen. Sie standen zwischenzeitlich nicht drin, weil sie + doppelt gemeldet und dabei der falschen Karte zugeordnet wurden. Beides ist + jetzt aufgefangen: das Gedächtnis hält die Zuordnung fest, und der + Dublettenabgleich fängt zwei Funktionen für dieselbe Sache. Was hinter einem + Knopf liegt, ist der einzige Weg an Parser, Dateiverwaltung und Prüflogik — + ohne die Routen bliebe der ganze Rücken des Programms ungeprüft. + + Reine Hilfsfunktionen bleiben draußen. Ein Kunde bestellt keine Funktion namens + `_slug`; sie wird über die Route erreicht, die ihn interessiert. Jedes + öffentliche Symbol aufzunehmen ergäbe wieder ein Code-Inventar statt einer + Auftragsliste. """ quelle = dateien() if quelle is None else quelle raus, gesehen = [], set() @@ -144,6 +161,11 @@ def interaktionen(quelle: dict = None) -> list[dict]: for m in _LAUSCHER.finditer(zeile): merke(f"lauscher:{m.group(1)}.{m.group(2)}", "Tastatur oder Maus", pfad, nr, zeilen, zeile) + for m in _ROUTE.finditer(zeile): + punkt = merke(f"route:{m.group(1)} {m.group(2)}", "Schnittstelle", + pfad, nr, zeilen, zeile) + if punkt is not None: + punkt["rumpf"] = _pyrumpf(zeilen, nr + 1) if not pfad.endswith(".vue"): continue for m in _EREIGNIS.finditer(zeile): diff --git a/backend/tests/test_inhalt.py b/backend/tests/test_inhalt.py index 1a35e3d..decf102 100644 --- a/backend/tests/test_inhalt.py +++ b/backend/tests/test_inhalt.py @@ -44,7 +44,9 @@ def test_interaktionen_sind_deterministisch(): # Zwölf Aufrufe von starte() sind eine Fähigkeit, nicht zwölf assert schluessel.count("bedienen:starte") == 1 assert "bedienen:menue" in schluessel - assert not any(s.startswith("schnittstelle:") for s in schluessel) # Verdrahtung + # Routen: der einzige Weg an Parser, Dateiverwaltung und Prüflogik + assert "route:get /projekte" in schluessel + assert "route:post /projekte" in schluessel assert "menue:+ Projekt erstellen" in schluessel # Technische Aufhänger und Zuweisungen sind keine Fähigkeiten assert not any(s.startswith("bedienen:fertig") for s in schluessel) diff --git a/backend/tests/test_ledger.py b/backend/tests/test_ledger.py new file mode 100644 index 0000000..cbed1a9 --- /dev/null +++ b/backend/tests/test_ledger.py @@ -0,0 +1,532 @@ +"""Das Gedächtnis der Inhaltsprüfungen: was gilt weiter, was wird neu gefragt. + +Die Prüfungen 2 und 3 fragen ein Modell. Dieselbe Frage kippt bei Grenzfällen auch +bei Temperatur 0 — vorher meldete Runde 1 einen Befund, Runde 2 keinen, Runde 3 +wieder einen. Hier steht, unter welchen Bedingungen ein Urteil stehen bleibt. +""" + +import json + +import pytest + +import agents +import features +import inhalt +import ledger +import projekte + + +@pytest.fixture +def projekt(datenordner): + projekte.anlegen("Test") + return "Test" + + +def karte(projekt, titel, *funktionen): + """funktionen sind (name, regel) oder (name, regel, [testschritte]).""" + text = f"\n{titel}\n" + for eintrag in funktionen: + name, regel, schritte = (*eintrag, [])[:3] + text += f"\n{name}\n\n- {regel}\n" + if schritte: + text += "\n" + "".join(f"- {s}\n" for s in schritte) + return features.anlegen(projekt, text) + + +QUELLE = {"frontend/src/App.vue": '\n'} + + +class Zaehler: + """Ersetzt das Modell und zählt mit, wie oft überhaupt gefragt wurde.""" + + def __init__(self, antwort): + self.antwort = antwort + self.rufe = 0 + + async def __call__(self, system, nachricht, temperatur=0.0): + self.rufe += 1 + wert = self.antwort + return wert(nachricht, self.rufe) if callable(wert) else wert + + +# ---------------------------------------------------------------- Datei + +def test_leeres_gedaechtnis_hat_alle_faecher(projekt): + stand = ledger.lade(projekt) + assert set(stand) == set(ledger.FAECHER) + assert all(v == {} for v in stand.values()) + + +def test_kaputte_datei_wird_wie_leer_behandelt(projekt): + (projekte.wurzel(projekt) / ledger.DATEI).write_text("{kein json") + assert ledger.lade(projekt)["belege"] == {} + + +def test_gemerktes_ueberlebt_den_lauf(projekt): + stand = ledger.lade(projekt) + stand["belege"][ledger.schluessel("a", "Tun")] = {"abdruck": "x", "datei": "a.py", + "beleg": "def tun():"} + ledger.sichere(projekt, stand) + assert ledger.lade(projekt)["belege"] == stand["belege"] + + +def test_schluessel_bleibt_zerlegbar(projekt): + """Titel dürfen alles enthalten — auch das, was als Trennzeichen taugen würde.""" + fies = 'Klick auf "x", dann | und \\ und ["a","b"]' + assert ledger.teile(ledger.schluessel("karte", fies)) == ["karte", fies] + + +def test_gedaechtnis_ist_kein_feature(projekt): + karte(projekt, "Eins") + ledger.sichere(projekt, ledger.lade(projekt)) + assert [f["sektionen"]["titel"] for f in features.liste(projekt)] == ["Eins"] + + +# ---------------------------------------------------------------- Prüfung 2 + +GEFUNDEN = json.dumps({"gefunden": True, "datei": "frontend/src/App.vue", + "beleg": ''}) + + +async def test_beleg_wird_gemerkt_und_beim_zweiten_lauf_nicht_gefragt( + projekt, monkeypatch): + karte(projekt, "Eins", ("Etwas starten", "Knopf")) + modell = Zaehler(GEFUNDEN) + monkeypatch.setattr(agents, "frage", modell) + + alle = features.liste(projekt) + text = "### frontend/src/App.vue\n" + QUELLE["frontend/src/App.vue"] + assert await inhalt.pruefe_feature_im_code(projekt, alle, QUELLE, text) == [] + erste_runde = modell.rufe + assert erste_runde >= len(inhalt.SUCHE_TEMPERATUREN) + + assert await inhalt.pruefe_feature_im_code(projekt, alle, QUELLE, text) == [] + assert modell.rufe == erste_runde # keine einzige neue Anfrage + + +async def test_beleg_verfaellt_wenn_die_zeile_verschwindet(projekt, monkeypatch): + karte(projekt, "Eins", ("Etwas starten", "Knopf")) + modell = Zaehler(GEFUNDEN) + monkeypatch.setattr(agents, "frage", modell) + + alle = features.liste(projekt) + text = "### frontend/src/App.vue\n" + QUELLE["frontend/src/App.vue"] + await inhalt.pruefe_feature_im_code(projekt, alle, QUELLE, text) + vorher = modell.rufe + + anders = {"frontend/src/App.vue": "