update
This commit is contained in:
@@ -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]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user