This commit is contained in:
team3
2026-07-27 11:29:57 +02:00
parent 15ad6dfe8a
commit 1c8a4fd4fd
30 changed files with 1133 additions and 173 deletions

View File

@@ -1,8 +1,12 @@
"""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.
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
@@ -12,85 +16,291 @@ import re
import agents
import config
import features
import struktur
# projektname -> {"laeuft": bool, "fertig": int, "gesamt": int,
# projektname -> {"laeuft": bool, "fertig": int, "gesamt": int, "fehler": int,
# "probleme": [...], "meldung": str}
_laeufe = {}
SYSTEM = """Du prüfst Karten einer Feature-Verwaltung gegen einen Stil-Katalog.
# 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}
Du bekommst eine Karte als Markdown. Melde jede Abweichung vom Katalog.
Antworte ausschließlich mit JSON:
{{"probleme": [{{"feld": "...", "funktion": "...", "original": "...",
Melde jede Abweichung vom Katalog. Antworte ausschließlich mit JSON:
{{"probleme": [{{"feld": "...", "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 "- "
- 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 korrigierte Text, sonst nichts
- 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."""
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"<!-- {marker} -->\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, "probleme": [], "meldung": ""}
return {
"laeuft": False, "fertig": 0, "gesamt": 0, "fehler": 0,
"probleme": [], "meldung": "",
}
return lauf
def _ohne_strich(text: str) -> str:
return re.sub(r"^[-*]\s*", "", (text or "").strip())
"""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)
def _antwort_lesen(text: str) -> list[dict]:
_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")
daten = json.loads(treffer.group(0))
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"))
p["funktion"] = (p.get("funktion") or "").strip()
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
async def _pruefe_karte(stil: str, feature: dict) -> list[dict]:
text = await agents.frage(SYSTEM.format(stil=stil), feature["roh"])
probleme = _antwort_lesen(text)
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"] = feature["id"]
p["feature"] = aufgabe["feature"]
p["funktion"] = aufgabe["funktion"]
return probleme
async def _lauf(projekt: str, liste: list[dict]):
async def _lauf(projekt: str, liste: list[dict], stil: dict):
lauf = _laeufe[projekt]
stil = config.STIL_DATEI.read_text()
schranke = asyncio.Semaphore(config.PRUEFUNG_PARALLEL)
async def eine(feature):
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_karte(stil, feature))
lauf["probleme"].extend(await _pruefe(stil, aufgabe))
except Exception as e:
lauf["meldung"] = str(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(f) for f in liste))
await asyncio.gather(*(eine(a) for a in liste))
finally:
lauf["laeuft"] = False
@@ -100,15 +310,19 @@ async def starte(projekt: str) -> dict:
if status(projekt)["laeuft"]:
raise ValueError("Prüfung läuft bereits")
agents.pruefe_konfiguration() # früher Stopp statt halbem Lauf
liste = features.liste(projekt)
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": "",
}
asyncio.create_task(_lauf(projekt, liste))
aufgabe = asyncio.create_task(_lauf(projekt, liste, stil))
_aufgaben.add(aufgabe)
aufgabe.add_done_callback(_aufgaben.discard)
return _laeufe[projekt]