Files
planer2/backend/pruefung.py
2026-07-27 07:54:09 +02:00

118 lines
3.8 KiB
Python

"""Prüft die Karten eines Projekts gegen den Stil-Katalog.
Ein Lauf je Projekt, Zustand nur im Speicher — er überlebt keinen Serverneustart.
Je Karte eine Anfrage, höchstens PRUEFUNG_PARALLEL gleichzeitig; Probleme erscheinen,
sobald die jeweilige Karte geprüft ist.
"""
import asyncio
import json
import re
import agents
import config
import features
# projektname -> {"laeuft": bool, "fertig": int, "gesamt": int,
# "probleme": [...], "meldung": str}
_laeufe = {}
SYSTEM = """Du prüfst Karten einer Feature-Verwaltung gegen einen Stil-Katalog.
{stil}
Du bekommst eine Karte als Markdown. Melde jede Abweichung vom Katalog.
Antworte ausschließlich mit JSON:
{{"probleme": [{{"feld": "...", "funktion": "...", "original": "...",
"problem": "...", "vorschlag": "..."}}]}}
- feld: titel, ziel, regeln oder testen
- funktion: Titel der Funktion, zu der die Stelle gehört; "" für die Karte selbst
- original: der fehlerhafte Text wortwörtlich; bei regeln und testen genau eine Zeile ohne führendes "- "
- problem: die verletzte Vorgabe in höchstens 8 Wörtern
- vorschlag: der korrigierte Text, sonst nichts
Keine Probleme gefunden: {{"probleme": []}}
Erfinde nichts und melde nur, was der Katalog verlangt."""
def status(projekt: str) -> dict:
lauf = _laeufe.get(projekt)
if not lauf:
return {"laeuft": False, "fertig": 0, "gesamt": 0, "probleme": [], "meldung": ""}
return lauf
def _ohne_strich(text: str) -> str:
return re.sub(r"^[-*]\s*", "", (text or "").strip())
def _antwort_lesen(text: str) -> 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")
daten = json.loads(treffer.group(0))
probleme = []
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"))
p["funktion"] = (p.get("funktion") or "").strip()
probleme.append(p)
return probleme
async def _pruefe_karte(stil: str, feature: dict) -> list[dict]:
text = await agents.frage(SYSTEM.format(stil=stil), feature["roh"])
probleme = _antwort_lesen(text)
for p in probleme:
p["feature"] = feature["id"]
return probleme
async def _lauf(projekt: str, liste: list[dict]):
lauf = _laeufe[projekt]
stil = config.STIL_DATEI.read_text()
schranke = asyncio.Semaphore(config.PRUEFUNG_PARALLEL)
async def eine(feature):
async with schranke:
if _laeufe.get(projekt) is not lauf:
return # verworfen (Panel geschlossen) — keine weitere Anfrage
try:
lauf["probleme"].extend(await _pruefe_karte(stil, feature))
except Exception as e:
lauf["meldung"] = str(e)
lauf["fertig"] += 1
try:
await asyncio.gather(*(eine(f) for f 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
liste = features.liste(projekt)
_laeufe[projekt] = {
"laeuft": True,
"fertig": 0,
"gesamt": len(liste),
"probleme": [],
"meldung": "",
}
asyncio.create_task(_lauf(projekt, liste))
return _laeufe[projekt]
def beende(projekt: str):
"""Ergebnis verwerfen (Panel schließen)."""
_laeufe.pop(projekt, None)