From 7912eae71518308dd406b2fee30d2bb222031764 Mon Sep 17 00:00:00 2001 From: team3 Date: Mon, 27 Jul 2026 16:43:11 +0200 Subject: [PATCH] update --- README.md | 35 ++++ backend/agents.py | 25 ++- backend/inhalt.py | 243 +++++++++++++++++++++++ backend/pruefung.py | 50 ++++- backend/quellcode.py | 160 +++++++++++++++ backend/routes.py | 5 +- backend/struktur.py | 4 +- backend/tests/test_inhalt.py | 58 ++++++ backend/tests/test_pruefung.py | 2 +- frontend/src/App.vue | 8 +- frontend/src/api.js | 4 +- frontend/src/components/FeatureKarte.vue | 16 +- frontend/src/components/PruefPanel.vue | 6 +- frontend/src/composables/pruefung.js | 7 +- 14 files changed, 596 insertions(+), 27 deletions(-) create mode 100644 backend/inhalt.py create mode 100644 backend/quellcode.py create mode 100644 backend/tests/test_inhalt.py diff --git a/README.md b/README.md index 1eea0cc..187fe71 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,41 @@ einziger Vorschlag für das Feld — einzelne Zeilen daraus ergäben Dubletten o Jede Anfrage bekommt nur ihren Ausschnitt der Karte und nur den Katalogteil, der für sie gilt. Die Herkunft eines Problems stammt aus dem Code, nicht aus der Modellantwort. +## Mit dem Code abgleichen + +Das ⌗-Icon prüft die andere Frage: bilden alle Karten zusammen ab, was das Programm +kann? Gesucht werden Lücken — Fähigkeiten ohne Funktion auf einer Karte. + +``` +1 Inventar quellcode.py Bedienstellen aus dem Code, deterministisch +2 Abdecken 6 Stimmen deckt eine bestehende Funktion die Stelle ab? +3 Ergänzen Code 5 von 6 sagen nein -> Vorschlag mit Fundort +``` + +**Inventar.** `interaktionen()` liest die Stellen aus, an denen ein Mensch etwas +auslöst: Ereignisse im Markup und Einträge der Kontextmenüs. Zusammengefasst nach +dem aufgerufenen Namen — `starte('ziel')` steht zwölfmal da und ist eine Fähigkeit. +Ein Modell hatte die Liste anfangs frei aufgezählt: jede Runde eine andere. + +Nicht im Inventar: Backend-Routen (die Verdrahtung hinter den Knöpfen), Zuweisungen, +Speichern beim Verlassen, Abbrechen mit Escape. + +**Abdecken.** Je Stelle eine Ja/Nein-Frage, mit dem Code-Umfeld, dem Rumpf der +Funktion dahinter und dem vollen Text aller Karten. Nur Titel reichten nicht — der +Richter musste raten und urteilte jede Runde anders. + +**Schwelle.** Fünf von sechs Stimmen. Einstimmigkeit war zu scharf: dieselbe Frage +kippt auch bei Temperatur 0 gelegentlich, und ein Ausreißer löschte klare Funde. +Eindeutige Stellen antworten 6:0 oder 0:6, Grenzfälle etwa 3:3. + +Jeder Vorschlag nennt seinen Fundort als `datei:zeile`. Die Gegenrichtung — „diese +Funktion hat keinen Code, weg damit" — ist bewusst nicht eingebaut: ein +Nichtvorhandensein lässt sich nicht belegen, und die Vorschläge zielten wiederholt +auf gültige Inhalte. Karten dürfen Dinge festhalten, die kein Knopf sind. + +Gemessen an diesem Projekt: auf den unsynchronisierten Karten findet der Abgleich +die offenen Stellen, nach dem Übernehmen dreimal hintereinander nichts mehr. + Der Katalog liegt in `backend/stil/` — eine Datei je Feld, alphabetisch zusammengesetzt (`allgemein` landet dadurch vorn). Das Präfix entscheidet, wer sie zu sehen bekommt: diff --git a/backend/agents.py b/backend/agents.py index 02f1616..86a3696 100644 --- a/backend/agents.py +++ b/backend/agents.py @@ -2,6 +2,8 @@ Konfiguration oder antwortet der Provider nicht, bricht der Aufruf mit sichtbarer Meldung ab.""" +import asyncio + import httpx import config @@ -10,6 +12,11 @@ ZEITLIMIT = 180.0 MAX_TOKENS = 8000 API_VERSION = "2023-06-01" +# Der Provider drosselt bei vielen gleichzeitigen Anfragen +WIEDERHOLBAR = (429, 500, 502, 503, 504) +WIEDERHOLUNGEN = 4 +PAUSE = 2.0 + class NichtKonfiguriert(ValueError): """Zugangsdaten fehlen. Eigener Typ, damit die Route das nicht mit @@ -44,11 +51,19 @@ async def frage(system: str, inhalt: str, temperatur: float = 0.0) -> str: "thinking": {"type": "disabled"}, } async with httpx.AsyncClient(timeout=httpx.Timeout(ZEITLIMIT, connect=30)) as client: - res = await client.post( - config.MINIMAX_URL, - headers={"x-api-key": config.MINIMAX_API_KEY, "anthropic-version": API_VERSION}, - json=daten, - ) + for versuch in range(WIEDERHOLUNGEN): + res = await client.post( + config.MINIMAX_URL, + headers={"x-api-key": config.MINIMAX_API_KEY, + "anthropic-version": API_VERSION}, + json=daten, + ) + if res.status_code not in WIEDERHOLBAR or versuch == WIEDERHOLUNGEN - 1: + break + # Zu viele Anfragen oder Serverfehler: warten und noch einmal. + # Ohne das fielen bei großen Läufen ganze Stimmen aus — und ein + # fehlendes Urteil sah aus wie „nichts gefunden". + await asyncio.sleep(PAUSE * (2 ** versuch)) if res.status_code != 200: raise RuntimeError(f"Provider antwortet mit HTTP {res.status_code}") antwort = res.json() diff --git a/backend/inhalt.py b/backend/inhalt.py new file mode 100644 index 0000000..c0a5004 --- /dev/null +++ b/backend/inhalt.py @@ -0,0 +1,243 @@ +"""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 diff --git a/backend/pruefung.py b/backend/pruefung.py index d83b885..001d1c4 100644 --- a/backend/pruefung.py +++ b/backend/pruefung.py @@ -18,6 +18,8 @@ from collections import Counter import agents import config import features +import inhalt as inhaltspruefung +import quellcode import struktur # projektname -> {"laeuft": bool, "fertig": int, "gesamt": int, "fehler": int, @@ -70,8 +72,9 @@ NACHBESSERN = """Dein Vorschlag verstößt weiter gegen messbare Vorgaben: Schreibe die betroffenen Felder erneut. Gleiches Format wie zuvor.""" -# Nur hier ist der Text eine Liste — mehrzeilige Vorschläge ergeben nur dort Sinn -ZEILENFELDER = ("regeln", "testen") +# Eine Quelle für „was ist eine Liste" — sonst zerlegt der eine Teil, was der +# andere als Block behandelt, und aus einer Ergänzung wird ein Komplettersatz. +ZEILENFELDER = struktur.ZEILENFELDER # Felder, die leer bleiben dürfen; ein leerer Vorschlag streicht die Stelle LEERBAR = ZEILENFELDER + ("ziel",) # Alles, was es überhaupt gibt; je Bereich gilt eine engere Auswahl @@ -178,7 +181,7 @@ def status(projekt: str) -> dict: lauf = _laeufe.get(projekt) if not lauf: return { - "laeuft": False, "fertig": 0, "gesamt": 0, "fehler": 0, + "laeuft": False, "art": "", "fertig": 0, "gesamt": 0, "fehler": 0, "probleme": [], "meldung": "", } return lauf @@ -450,7 +453,33 @@ async def _pruefe(stil: dict, aufgabe: dict) -> list[dict]: return probleme -async def _lauf(projekt: str, liste: list[dict], stil: 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)] + return liste, lambda a: _pruefe(stil, a) + + +async def _inhalt_aufgaben(projekt: str): + """Ein Abgleich über alle Karten — Vollständigkeit ist eine Eigenschaft der + Menge, nicht der einzelnen Karte.""" + alle = features.liste(projekt) + quelle = quellcode.dateien() + if not quelle: + raise ValueError("Kein Quelltext gefunden") + text = quellcode.als_text(quelle) + + async def abgleich(_): + probleme, _inv = await inhaltspruefung.pruefe(alle, quelle, text) + return probleme + + return [{"feature": "", "funktion": ""}], abgleich + + +ARTEN = {"stil": _stil_aufgaben, "inhalt": _inhalt_aufgaben} + + +async def _lauf(projekt: str, liste: list[dict], arbeite): lauf = _laeufe[projekt] schranke = asyncio.Semaphore(config.PRUEFUNG_PARALLEL) @@ -459,7 +488,7 @@ async def _lauf(projekt: str, liste: list[dict], stil: dict): if _laeufe.get(projekt) is not lauf: return # verworfen (Panel geschlossen) — keine weitere Anfrage try: - lauf["probleme"].extend(await _pruefe(stil, aufgabe)) + lauf["probleme"].extend(await arbeite(aufgabe)) except Exception as e: # Zählt trotzdem als erledigt, sonst hängt der Fortschritt. # Die Meldung nennt die Zahl, damit „100 %" nicht vollständig vortäuscht. @@ -475,22 +504,25 @@ async def _lauf(projekt: str, liste: list[dict], stil: dict): lauf["laeuft"] = False -async def starte(projekt: str) -> dict: +async def starte(projekt: str, art: str = "stil") -> dict: """Startet einen Lauf im Hintergrund; ein laufender wird nicht verdoppelt.""" + if art not in ARTEN: + raise ValueError(f"Unbekannte Prüfart „{art}“") if status(projekt)["laeuft"]: raise ValueError("Prüfung läuft bereits") agents.pruefe_konfiguration() # früher Stopp statt halbem Lauf - stil = {b: _stil_lesen(b) for b in BEREICHE} # ebenso: Fehler im Task wäre unsichtbar - liste = [a for f in features.liste(projekt) for a in aufgaben(f)] + # Vorbereiten vor dem Task: ein Fehler darin wäre sonst unsichtbar + liste, arbeite = await ARTEN[art](projekt) _laeufe[projekt] = { "laeuft": True, + "art": art, "fertig": 0, "gesamt": len(liste), "fehler": 0, "probleme": [], "meldung": "", } - aufgabe = asyncio.create_task(_lauf(projekt, liste, stil)) + aufgabe = asyncio.create_task(_lauf(projekt, liste, arbeite)) _aufgaben.add(aufgabe) aufgabe.add_done_callback(_aufgaben.discard) return _laeufe[projekt] diff --git a/backend/quellcode.py b/backend/quellcode.py new file mode 100644 index 0000000..cc878c3 --- /dev/null +++ b/backend/quellcode.py @@ -0,0 +1,160 @@ +"""Der Quelltext als Prüfgrundlage. + +Die Inhaltsprüfung vergleicht Karten mit dem, was das Programm wirklich kann. +Damit das nicht auf Behauptungen hinausläuft, muss jede Aussage über den Code +eine wörtlich zitierte Zeile mitbringen — `belegt()` prüft sie nach. +""" + +import re + +import config + +# Was zum lauffähigen Programm gehört. Tests beschreiben keine Nutzerfunktionen. +MUSTER = ( + "backend/*.py", + "frontend/index.html", + "frontend/src/*.js", + "frontend/src/**/*.js", + "frontend/src/**/*.vue", +) +AUSGENOMMEN = ("tests/", "/tests/", "node_modules/", "dist/") + + +def _passt(pfad: str) -> bool: + return not any(a in pfad for a in AUSGENOMMEN) + + +def dateien() -> dict: + """Pfad relativ zur Projektwurzel -> Inhalt, in fester Reihenfolge.""" + raus = {} + for muster in MUSTER: + for datei in sorted(config.PROJECT_ROOT.glob(muster)): + pfad = str(datei.relative_to(config.PROJECT_ROOT)) + if datei.is_file() and _passt(pfad): + raus[pfad] = datei.read_text() + return dict(sorted(raus.items())) + + +def als_text(quelle: dict = None) -> str: + """Alle Dateien am Stück, jede mit Überschrift — so kann das Modell zitieren.""" + quelle = dateien() if quelle is None else quelle + return "\n\n".join(f"### {pfad}\n{text.rstrip()}" for pfad, text in quelle.items()) + + +def _normal(zeile: str) -> str: + return re.sub(r"\s+", " ", zeile).strip() + + +def belegt(quelle: dict, pfad: str, schnipsel: str) -> bool: + """Steht der Schnipsel wörtlich in der Datei? + + Verglichen wird ohne Rücksicht auf Einrückung — Modelle geben sie ungenau + wieder. Alles andere muss stimmen, sonst ist der Beleg wertlos. + """ + text = quelle.get(pfad) + if text is None or not schnipsel.strip(): + return False + gesucht = [_normal(z) for z in schnipsel.splitlines() if z.strip()] + if not gesucht: + return False + vorhanden = [_normal(z) for z in text.splitlines()] + # Zusammenhängend: eine mehrzeilige Angabe muss auch mehrzeilig dastehen + for i in range(len(vorhanden) - len(gesucht) + 1): + if vorhanden[i:i + len(gesucht)] == gesucht: + return True + return False + + +# Die Stellen, an denen ein Mensch etwas auslöst. Vorher hat ein Modell die +# Fähigkeiten frei aufgezählt — jede Runde andere, jede Runde anders benannt. +# Hier steht die Liste fest, weil sie aus dem Code fällt. +_EREIGNIS = re.compile(r'@(click|auxclick|contextmenu|keyup|keydown|blur|submit|change)' + r'[\w.]*\s*=\s*"([^"]*)"') +_MENUEPUNKT = re.compile(r"""\{\s*text:\s*['"](.+?)['"]\s*,\s*aktion:""") +_NAME = re.compile(r"[A-Za-zÄÖÜäöü_$][\w$]*") + +# Rein technische Aufhänger: Speichern beim Verlassen, Abbrechen mit Escape. +# Sie tragen keine eigene Fähigkeit, sondern gehören zur Bedienung eines Feldes. +_UNINTERESSANT = {"fertig", "abbrechen", "blur", "waehle"} +# emit('auswaehlen') ist eine Fähigkeit, emit('abbrechen') nicht — es zählt das +# Ereignis, nicht der Aufruf. Vorher fiel mit „emit" auch das Auswählen weg. +_EMIT = re.compile(r"""emit\(\s*['"](\w+)['"]""") +_ZUWEISUNG = re.compile(r"^\s*[\w.]+\s*=[^=]") + + +def _umfeld(zeilen: list, nr: int, rand: int = 5) -> str: + von, bis = max(0, nr - rand - 1), min(len(zeilen), nr + rand) + return "\n".join(zeilen[von:bis]).strip() + + +_ENDE = re.compile(r"^(function |const |async function |\S)") + + +def _rumpf(zeilen: list, name: str, hoechstens: int = 14) -> str: + """Die Umsetzung hinter einem Namen. + + Das Markup allein sagt nur, dass ein Knopf etwas aufruft. Was er tut, steht im + Rumpf — ohne den musste der Prüfer raten und urteilte jede Runde anders. + """ + anfang = re.compile(rf"^\s*(async\s+)?function\s+{re.escape(name)}\s*\(|" + rf"^\s*const\s+{re.escape(name)}\s*=") + for i, zeile in enumerate(zeilen): + if not anfang.match(zeile): + continue + block = [zeile] + for weiter in zeilen[i + 1:i + hoechstens]: + block.append(weiter) + if weiter.startswith("}"): + break + return "\n".join(block) + return "" + + +def interaktionen(quelle: dict = None) -> list[dict]: + """Auslösbare Stellen im Code, eine je Bedienhandlung. + + Zusammengefasst nach dem aufgerufenen Namen: `@click="starte('ziel')"` steht + zwölfmal im Markup, ist aber eine Fähigkeit. + + Backend-Routen stehen bewusst nicht drin. Sie sind die Verdrahtung hinter den + Knöpfen, nicht die Bedienung — als eigene Einträge erzeugten sie nur Meldungen + wie „Prüfungsstatus abrufen", die niemand auf einer Karte sehen will. + """ + quelle = dateien() if quelle is None else quelle + raus, gesehen = [], set() + + def merke(schluessel, art, pfad, nr, zeilen, beleg): + if schluessel in gesehen: + return None + gesehen.add(schluessel) + punkt = {"schluessel": schluessel, "art": art, "datei": pfad, "zeile": nr, + "beleg": beleg.strip(), "umfeld": _umfeld(zeilen, nr), "rumpf": ""} + raus.append(punkt) + return punkt + + for pfad, text in quelle.items(): + zeilen = text.splitlines() + for nr, zeile in enumerate(zeilen, 1): + for m in _MENUEPUNKT.finditer(zeile): + merke(f"menue:{m.group(1)}", "Menüpunkt", pfad, nr, zeilen, zeile) + if not pfad.endswith(".vue"): + continue + for m in _EREIGNIS.finditer(zeile): + ausdruck = m.group(2) + if _ZUWEISUNG.match(ausdruck) or ausdruck.startswith("$"): + continue # Zuweisung oder Ereignis-Objekt, keine Fähigkeit + treffer = _EMIT.search(ausdruck) or _NAME.search(ausdruck) + if not treffer or treffer.group(1 if _EMIT.search(ausdruck) else 0) \ + in _UNINTERESSANT: + continue + name = treffer.group(1) if _EMIT.search(ausdruck) else treffer.group(0) + punkt = merke(f"bedienen:{name}", "Bedienung", pfad, nr, zeilen, zeile) + if punkt is not None: + punkt["rumpf"] = _rumpf(zeilen, name) + return raus + + +def uebersicht(quelle: dict = None) -> str: + """Kurze Dateiliste mit Zeilenzahl — für Prompts, die den Code nicht brauchen.""" + quelle = dateien() if quelle is None else quelle + return "\n".join(f"- {p} ({len(t.splitlines())} Zeilen)" for p, t in quelle.items()) diff --git a/backend/routes.py b/backend/routes.py index 323198e..d56f20c 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -83,9 +83,10 @@ async def feature_speichern(name: str, id: str, body: dict): @router.post("/projekte/{name}/pruefung") -async def pruefung_starten(name: str): +async def pruefung_starten(name: str, body: dict | None = None): + art = (body or {}).get("art") or "stil" try: - return await pruefung.starte(name) + return await pruefung.starte(name, art) except FileNotFoundError as e: raise HTTPException(404, str(e)) except agents.NichtKonfiguriert as e: diff --git a/backend/struktur.py b/backend/struktur.py index 32935bb..c6e41eb 100644 --- a/backend/struktur.py +++ b/backend/struktur.py @@ -25,7 +25,9 @@ ZIEL_WOERTER = 12 # Abschließend. Genau diese Wörter, keine sinnverwandten. FUELLWOERTER = ("auch", "noch", "einfach", "jeweils", "ggf") BADGE = ("kern", "optional") -ZEILENFELDER = ("regeln", "testen") +# Felder, die als Liste geführt werden — je Zeile ein Eintrag. +# „funktionen" gehört der Inhaltsprüfung, hat aber dieselbe Gestalt. +ZEILENFELDER = ("regeln", "testen", "funktionen") SATZENDE = ".;" _KLAMMER = re.compile(r"[()/]") diff --git a/backend/tests/test_inhalt.py b/backend/tests/test_inhalt.py new file mode 100644 index 0000000..1338552 --- /dev/null +++ b/backend/tests/test_inhalt.py @@ -0,0 +1,58 @@ +import inhalt +import quellcode + + +def test_unformatierte_prompts_ohne_doppelklammern(): + """Was nicht durch .format() geht, darf keine Escape-Klammern tragen — + das Modell schrieb sie sonst wörtlich in die Antwort und das JSON war kaputt.""" + for name in ("ABDECKUNG_SYSTEM",): + text = getattr(inhalt, name) + assert "{{" not in text and "}}" not in text, name + + +def test_formatierte_prompts_ueberstehen_format(): + punkt = {"art": "Bedienung", "datei": "a.vue", "zeile": 1, + "umfeld": "x", "rumpf": ""} + fertig = inhalt.ABDECKUNG.format(funktionen="—", **punkt) + assert '{"abgedeckt": true' in fertig + assert "{{" not in fertig + + +def test_beleg_muss_woertlich_dastehen(): + quelle = {"a.py": "def eins():\n return 1\n"} + assert quellcode.belegt(quelle, "a.py", "def eins():") + assert quellcode.belegt(quelle, "a.py", " def eins(): ") # Einrückung egal + assert quellcode.belegt(quelle, "a.py", "def eins():\nreturn 1") + assert not quellcode.belegt(quelle, "a.py", "def zwei():") + assert not quellcode.belegt(quelle, "b.py", "def eins():") + assert not quellcode.belegt(quelle, "a.py", "") + + +def test_interaktionen_sind_deterministisch(): + quelle = { + "frontend/src/X.vue": ( + '\n' + '\n' + '
c
\n' + '\n' + 'zu\n' + ), + "backend/routes.py": '@router.get("/projekte")\n@router.post("/projekte")\n', + "frontend/src/m.js": " { text: '+ Projekt erstellen', aktion: () => x() },\n", + } + schluessel = [p["schluessel"] for p in quellcode.interaktionen(quelle)] + # 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("route:") for s in schluessel) # Verdrahtung, keine Bedienung + 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) + assert not any(s.startswith("bedienen:abbrechen") for s in schluessel) + assert not any(s.startswith("bedienen:offen") for s in schluessel) + # Zweiter Aufruf liefert exakt dasselbe + assert schluessel == [p["schluessel"] for p in quellcode.interaktionen(quelle)] + + +def test_tests_sind_kein_quelltext(): + assert not any("tests/" in p for p in quellcode.dateien()) diff --git a/backend/tests/test_pruefung.py b/backend/tests/test_pruefung.py index 06469e3..761a5f6 100644 --- a/backend/tests/test_pruefung.py +++ b/backend/tests/test_pruefung.py @@ -123,7 +123,7 @@ async def test_parallel_begrenzt(projekt, monkeypatch): async def test_status_ohne_lauf(): assert pruefung.status("Fremd") == { - "laeuft": False, "fertig": 0, "gesamt": 0, "fehler": 0, + "laeuft": False, "art": "", "fertig": 0, "gesamt": 0, "fehler": 0, "probleme": [], "meldung": "", } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c7b62c8..789c5fc 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -87,7 +87,10 @@ onMounted(async () => { @umbenannt="umbenannt" > - + + + + {{ pruefungProzent }} %