"""Prüft die Karten eines Projekts gegen den Stil-Katalog. Ein Lauf je Projekt, Zustand nur im Speicher — er überlebt keinen Serverneustart. Eine Anfrage je Prüfstelle: einmal die Kartenebene (titel, badge, ziel, regeln) und dazu je Funktion einmal (titel, ziel, regeln, testen). Jede Anfrage bekommt nur den Katalogteil, der für sie gilt, und nur ihren Ausschnitt der Karte — das hält den Prompt kurz und die Zuordnung eindeutig. Höchstens PRUEFUNG_PARALLEL gleichzeitig; Probleme erscheinen, sobald ihre Stelle geprüft ist. """ import asyncio import json import re import agents import config import features import struktur # projektname -> {"laeuft": bool, "fertig": int, "gesamt": int, "fehler": int, # "probleme": [...], "meldung": str} _laeufe = {} # create_task gibt die Aufgabe nur schwach an die Schleife weiter — ohne eigene # Referenz kann der Lauf mitten drin weggesammelt werden. _aufgaben = set() SYSTEM = """Du prüfst {gegenstand} gegen einen Stil-Katalog. {stil} Melde jede Abweichung vom Katalog. Antworte ausschließlich mit JSON: {{"probleme": [{{"feld": "...", "original": "...", "problem": "...", "vorschlag": "..."}}]}} - feld: {felder} - original: die betroffene Stelle wortwörtlich; bei regeln und testen genau eine Zeile ohne führendes "- " - problem: die verletzte Vorgabe in höchstens 8 Wörtern - vorschlag: der vollständige neue Text für diese Stelle vorschlag ersetzt original eins zu eins: - eine Zeile schreibt die Stelle um - mehrere Zeilen setzen alle davon an die Stelle - leer streicht die Stelle Mehrere Zeilen gibt es nur bei regeln und testen. Leer ist nur bei regeln, testen und ziel erlaubt. Jedes Problem braucht einen Vorschlag. Weißt du keinen konkreten Ersatz, melde nichts. Wortzahl, Satzzeichen am Zeilenende und Füllwörter misst das System selbst und meldet sie schon. Melde sie nicht noch einmal. Steht unten ein Abschnitt „Gemessen", schreib genau diese Stellen neu. Der Nutzer nimmt jeden Vorschlag einzeln an oder ab, in beliebiger Reihenfolge. Darum: - Jeder Vorschlag muss für sich allein anwendbar sein, ohne einen anderen - Begründe nie mit dem, was ein anderer Vorschlag ändern würde - Der Vorschlag selbst muss den Katalog erfüllen — sonst meldest du ihn beim nächsten Lauf wieder Nicht mitgeschickte Felder sind leer und werden nicht gemeldet. {hinweis} Keine Probleme gefunden: {{"probleme": []}} Erfinde nichts und melde nur, was der Katalog verlangt. Der Katalog ist abschließend — leite keine weiteren Vorgaben daraus ab.""" # Nur hier ist der Text eine Liste — mehrzeilige Vorschläge ergeben nur dort Sinn ZEILENFELDER = ("regeln", "testen") # 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 ALLE_FELDER = ("titel", "badge", "ziel", "regeln", "testen") # Was der jeweilige Prüfer sieht und melden darf. Der Bereichsname ist zugleich das # Dateipräfix im Stil-Ordner — „feature" liest allgemein-* und feature-*. BEREICHE = { "feature": { "gegenstand": "die Kartenebene einer Feature-Karte", "felder": ("titel", "badge", "ziel", "regeln"), # Ohne die Funktionstitel kann niemand beurteilen, ob die Karte kern ist "hinweis": ( "Der Block funktionen ist nur Kontext für die Badge-Einstufung." " Melde nichts daraus." ), }, "funktion": { "gegenstand": "eine einzelne Funktion einer Feature-Karte", "felder": ("titel", "ziel", "regeln", "testen"), "hinweis": "", }, } def _felder_text(felder: tuple) -> str: return f"{', '.join(felder[:-1])} oder {felder[-1]}" def _stil_lesen(bereich: str) -> str: """Katalog für einen Bereich: die allgemein-Dateien plus die des Bereichs. Leerer Ordner heißt Fehlbedienung — dann lieber abbrechen als blind prüfen.""" def gehoert(datei, gruppe): return datei.stem == gruppe or datei.stem.startswith(f"{gruppe}-") dateien = sorted( d for d in config.STIL_DIR.glob("*.md") if gehoert(d, "allgemein") or gehoert(d, bereich) ) if not dateien: raise ValueError(f"Kein Stil-Katalog für „{bereich}“ in {config.STIL_DIR}") return "\n\n".join(d.read_text().strip() for d in dateien) def _block(marker: str, text: str) -> str: """Leere Felder gar nicht erst mitschicken — sonst meldet der Prüfer sie als fehlend.""" return f"\n{text}\n" if text.strip() else "" def aufgaben(feature: dict) -> list[dict]: """Eine Prüfstelle für die Kartenebene, dazu je Funktion eine. `felder` ist das, was geprüft wird; `kontext` geht nur zur Einordnung mit.""" s = feature["sektionen"] liste = [{ "feature": feature["id"], "funktion": "", "bereich": "feature", "felder": { "titel": s["titel"], "badge": s["art"], "ziel": s["ziel"], "regeln": s["regeln"], }, "kontext": {"funktionen": "\n".join(f"- {f['text']}" for f in s["funktionen"])}, }] for f in s["funktionen"]: liste.append({ "feature": feature["id"], "funktion": f["text"], "bereich": "funktion", "felder": { "titel": f["text"], "ziel": f["ziel"], "regeln": f["regeln"], "testen": f["testen"], }, "kontext": {}, }) return liste def inhalt(aufgabe: dict, offen: list[dict] = ()) -> str: """Der Ausschnitt für das Modell. `offen` sind gemessene Verstöße, für die der Code keinen Ersatz kennt — sie hängen als Auftrag hinten dran.""" text = "".join( _block(k, v) for k, v in {**aufgabe["felder"], **aufgabe["kontext"]}.items() ) if offen: text += "\nGemessen, bitte neu schreiben:\n" text += "\n".join(struktur.auftrag(b) for b in offen) + "\n" return text def status(projekt: str) -> dict: lauf = _laeufe.get(projekt) if not lauf: return { "laeuft": False, "fertig": 0, "gesamt": 0, "fehler": 0, "probleme": [], "meldung": "", } return lauf def _ohne_strich(text: str) -> str: """Listenzeilen kommen mal mit, mal ohne „- " zurück — je Zeile vereinheitlicht, damit die Anzeige die Stelle wiederfindet.""" zeilen = (text or "").strip().splitlines() return "\n".join(re.sub(r"^[-*]\s*", "", z.strip()) for z in zeilen) _STRUKTUR = set(',:}]') def _quotes_maskieren(roh: str) -> str: """Karteninhalt enthält Anführungszeichen (`Klick auf "Feature entfernen"`). Das Modell gibt ihn wortwörtlich zurück und maskiert sie oft nicht — dann ist das JSON kaputt. Ein Zeichen schließt den String nur, wenn danach JSON-Struktur folgt.""" aus = [] im_string = False i = 0 while i < len(roh): z = roh[i] if im_string and z == "\\" and i + 1 < len(roh): aus.append(roh[i:i + 2]) # bestehende Escape-Sequenz unverändert i += 2 continue if z == '"': if not im_string: im_string = True elif roh[i + 1:].lstrip()[:1] in _STRUKTUR or not roh[i + 1:].strip(): im_string = False else: aus.append("\\") aus.append(z) i += 1 return "".join(aus) def _antwort_lesen(text: str, felder: tuple = ALLE_FELDER) -> list[dict]: """Holt das JSON aus der Antwort, auch wenn es in ```-Zäunen steckt. Listenzeilen kommen mal mit, mal ohne „- " zurück — hier vereinheitlicht, damit die Anzeige die Stelle wiederfindet.""" treffer = re.search(r"\{.*\}", text, re.S) if not treffer: raise ValueError("Antwort enthält kein JSON") roh = treffer.group(0) try: # strict=False lässt Zeilenumbrüche in Strings durch daten = json.loads(roh, strict=False) except json.JSONDecodeError: daten = json.loads(_quotes_maskieren(roh), strict=False) probleme = [] gesehen = set() # dieselbe Stelle nur einmal melden for p in daten.get("probleme") or []: if not isinstance(p, dict) or not p.get("original"): continue p["original"] = _ohne_strich(p["original"]) p["vorschlag"] = _ohne_strich(p.get("vorschlag")) if not _anwendbar(p, felder): continue schluessel = (p.get("feld"), p["original"]) if schluessel in gesehen: continue gesehen.add(schluessel) probleme.append(p) return probleme def _anwendbar(p: dict, felder: tuple) -> bool: """Ein Problem ohne umsetzbaren Vorschlag ist für den Nutzer wertlos — lieber verschweigen als einen leeren Hinweis anzeigen.""" feld = p.get("feld") if feld not in felder: return False # erfundenes Feld wie „struktur" — die Karte kennt es nicht if not p["vorschlag"]: return feld in LEERBAR # leerer Vorschlag streicht die Stelle if "\n" in p["vorschlag"] or "\n" in p["original"]: return feld in ZEILENFELDER # nur Listen haben Zeilen return True async def _pruefe(stil: dict, aufgabe: dict) -> list[dict]: """Erst messen, dann fragen. Was der Code selbst reparieren kann, geht ohne Anfrage durch; der Rest hängt als Auftrag am Ausschnitt.""" bereich = BEREICHE[aufgabe["bereich"]] befunde = struktur.befunde(aufgabe["bereich"], aufgabe["felder"]) fertig = [b for b in befunde if b["vorschlag"]] offen = [b for b in befunde if not b["vorschlag"]] system = SYSTEM.format( stil=stil[aufgabe["bereich"]], gegenstand=bereich["gegenstand"], felder=_felder_text(bereich["felder"]), hinweis=bereich["hinweis"], ) antwort = await agents.frage(system, inhalt(aufgabe, offen)) probleme = list(fertig) belegt = {(b["feld"], b["original"]) for b in fertig} for p in _antwort_lesen(antwort, bereich["felder"]): if (p["feld"], p["original"]) in belegt: continue # der gemessene Befund geht vor, er ist exakt probleme.append(p) # Herkunft stempelt der Code, nicht das Modell — es kennt nur seinen Ausschnitt for p in probleme: p["feature"] = aufgabe["feature"] p["funktion"] = aufgabe["funktion"] return probleme async def _lauf(projekt: str, liste: list[dict], stil: dict): lauf = _laeufe[projekt] schranke = asyncio.Semaphore(config.PRUEFUNG_PARALLEL) async def eine(aufgabe): async with schranke: if _laeufe.get(projekt) is not lauf: return # verworfen (Panel geschlossen) — keine weitere Anfrage try: lauf["probleme"].extend(await _pruefe(stil, 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. lauf["fehler"] += 1 lauf["meldung"] = ( f"{lauf['fehler']} von {lauf['gesamt']} Prüfungen fehlgeschlagen: {e}" ) lauf["fertig"] += 1 try: await asyncio.gather(*(eine(a) for a in liste)) finally: lauf["laeuft"] = False async def starte(projekt: str) -> dict: """Startet einen Lauf im Hintergrund; ein laufender wird nicht verdoppelt.""" 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)] _laeufe[projekt] = { "laeuft": True, "fertig": 0, "gesamt": len(liste), "fehler": 0, "probleme": [], "meldung": "", } aufgabe = asyncio.create_task(_lauf(projekt, liste, stil)) _aufgaben.add(aufgabe) aufgabe.add_done_callback(_aufgaben.discard) return _laeufe[projekt] def beende(projekt: str): """Ergebnis verwerfen (Panel schließen).""" _laeufe.pop(projekt, None)