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

@@ -11,6 +11,11 @@ MAX_TOKENS = 8000
API_VERSION = "2023-06-01"
class NichtKonfiguriert(ValueError):
"""Zugangsdaten fehlen. Eigener Typ, damit die Route das nicht mit
„Prüfung läuft bereits" verwechselt — beides wäre sonst ein ValueError."""
def pruefe_konfiguration():
fehlend = [
name
@@ -22,7 +27,7 @@ def pruefe_konfiguration():
if not wert
]
if fehlend:
raise ValueError(f"Nicht konfiguriert: {', '.join(fehlend)} in .env fehlt")
raise NichtKonfiguriert(f"Nicht konfiguriert: {', '.join(fehlend)} in .env fehlt")
async def frage(system: str, inhalt: str) -> str:

View File

@@ -3,7 +3,9 @@ from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DATEN_DIR = PROJECT_ROOT / "daten"
STIL_DATEI = Path(__file__).resolve().parent / "stil.md"
# Ein Abschnitt je Datei; alphabetisch gelesen, „allgemein" landet dadurch vorn.
# Neue Datei anlegen genügt — der Katalog nimmt sie ohne Codeänderung auf.
STIL_DIR = Path(__file__).resolve().parent / "stil"
def _lade_env():

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]

View File

@@ -3,6 +3,7 @@ hier wird auf 400/404/409 gemappt."""
from fastapi import APIRouter, HTTPException
import agents
import features
import projekte
import pruefung
@@ -87,6 +88,8 @@ async def pruefung_starten(name: str):
return await pruefung.starte(name)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except agents.NichtKonfiguriert as e:
raise HTTPException(400, str(e)) # 409 heißt „läuft bereits" — hier fehlt die .env
except ValueError as e:
raise HTTPException(409, str(e))

View File

@@ -1,68 +0,0 @@
# Stil-Katalog für Karten
Verbindliche Vorgaben für alle Inhalte einer Feature-Karte. Der Prüfer meldet jede
Abweichung als Problem und schlägt einen korrigierten Text vor.
## Allgemein
- Sprache: Deutsch, Präsens, keine Anrede
- Kein Satzzeichen am Zeilenende (kein Punkt, kein Semikolon)
- Keine Füllwörter (auch, noch, einfach, jeweils, ggf.)
- Keine Klammern und keine Schrägstriche außer in Icon-Angaben
- Icons heißen `<Name>-Icon`, etwa `x-Icon` oder `Stift-Icon`; ein Doppelpunkt mit dem
tatsächlichen Zeichen dahinter ist erlaubt (`Nacht-Icon: ☾`)
- Maustasten als `LMB`, `RMB`, `MMB`
## Titel der Karte
- Höchstens 3 Wörter
- Substantiv, kein Verb
- Benennt die Sache, nicht die Tätigkeit
Richtig: `Features` · `Karten prüfen` · `Darkmode`
Falsch: `Features verwalten und anzeigen` (zu lang, Tätigkeit)
## Titel einer Funktion
- Höchstens 5 Wörter
- Kurzsatz aus Objekt und Verb im Infinitiv
- Genau eine Tätigkeit
Richtig: `Feature erstellen` · `Anzeigemodus umschalten` · `Feature als Datei speichern`
Falsch: `Erstellung von Features` (Substantivierung) · `Feature erstellen und öffnen` (zwei Tätigkeiten)
## Ziel
- Höchstens 2 Sätze, je höchstens 12 Wörter
- Beschreibt den Zweck, nicht die Bedienung
- Entfällt, wenn der Titel selbsterklärend ist — dann bleibt das Feld leer
Richtig: `Gruppiert Informationen zu ähnlichen Funktionen`
Falsch: `Klick auf die Karte öffnet den Vollmodus` (Bedienung gehört zu Testen)
## Regeln
- Eine Zeile je Regel, höchstens 10 Wörter
- Genau eine Aussage je Zeile; zwei Aussagen werden zu zwei Regeln
- Beschreibt eine Eigenschaft oder Bedingung, keinen Ablaufschritt
- Keine Aufzählung mit Komma oder „und" zweier gleichrangiger Aussagen
Richtig: `Alphabetisch sortiert` · `Kern blau umrandet` · `x-Icon`
Falsch: `Kern blau umrandet, Optional grün umrandet` (zwei Aussagen)
Falsch: `Klick auf Badge` (Ablaufschritt, gehört zu Testen)
## Testen
- Eine Zeile je Schritt, höchstens 8 Wörter
- Schritte in Reihenfolge der Bedienung
- Letzte Zeile beschreibt den Endzustand, keine Handlung
- Handlungen als Nominalstil mit Maustaste oder Verb im Infinitiv
Richtig:
```
- Vollmodus öffnen
- RMB auf Funktion
- Klick auf "Funktion entfernen"
- Funktion entfernt
```
Falsch: letzte Zeile `Klick zum entfernen` (Handlung statt Endzustand)

17
backend/stil/allgemein.md Normal file
View File

@@ -0,0 +1,17 @@
# Stil-Katalog für Karten
Verbindliche Vorgaben für alle Inhalte einer Feature-Karte. Der Prüfer meldet jede
Abweichung als Problem und schlägt einen korrigierten Text vor.
## Allgemein
Gilt für jedes Feld.
- Sprache: Deutsch, Präsens, keine Anrede
- Kein Satzzeichen am Zeilenende (kein Punkt, kein Semikolon)
- Keine Füllwörter. Die Liste ist abschließend: auch, noch, einfach, jeweils, ggf.
- Keine Klammern und keine Schrägstriche außer in Icon-Angaben
- Icons heißen `<Name>-Icon`. Der Name darf ein Zeichen sein: `x-Icon`, `+-Icon`,
`Stift-Icon`. Ein Doppelpunkt mit dem tatsächlichen Zeichen dahinter ist erlaubt
(`Nacht-Icon: ☾`)
- Maustasten als `LMB`, `RMB`, `MMB`

View File

@@ -0,0 +1,11 @@
## Badge der Karte
Feld `badge`. Genau zwei Werte erlaubt: `kern` oder `optional`.
- `kern`: ohne die Karte ist das Projekt nicht benutzbar
- `optional`: nützlich, aber verzichtbar
- Die Einstufung muss zum Ziel der Karte passen
- Im Zweifel `kern`
Richtig: `Projekte` ist kern · `Darkmode` ist optional
Falsch: `Darkmode` als kern (das Projekt läuft ohne)

View File

@@ -0,0 +1,13 @@
## Regeln der Karte
Feld `regeln`, wenn `funktion` leer ist. Gilt für die ganze Karte.
- Eine Zeile je Regel, höchstens 10 Wörter
- Genau eine Aussage je Zeile; zwei Aussagen werden zu zwei Regeln
- Beschreibt eine Eigenschaft oder Bedingung, keinen Ablaufschritt
- Keine Aufzählung mit Komma oder „und" zweier gleichrangiger Aussagen
- Ein Icon-Name allein ist eine gültige Regel
Richtig: `Alphabetisch sortiert` · `Verlassen speichert` · `Escape verwirft`
Falsch: `Kern blau umrandet, Optional grün umrandet` (zwei Aussagen)
Falsch: `Klick auf Badge` (Ablaufschritt, gehört zu Testen)

View File

@@ -0,0 +1,10 @@
## Titel der Karte
Feld `titel`, wenn `funktion` leer ist.
- Höchstens 3 Wörter
- Substantiv, oder Objekt mit Verb im Infinitiv
- Benennt die Sache, kein ganzer Satz
Richtig: `Features` · `Karten prüfen` · `Darkmode`
Falsch: `Features verwalten und anzeigen` (zu lang, Tätigkeit)

View File

@@ -0,0 +1,10 @@
## Ziel der Karte
Feld `ziel`, wenn `funktion` leer ist.
- Höchstens 2 Sätze, je höchstens 12 Wörter
- Beschreibt den Zweck der Karte, nicht die Bedienung
- Entfällt, wenn der Titel selbsterklärend ist — dann bleibt das Feld leer
Richtig: `Gruppiert Informationen zu ähnlichen Funktionen`
Falsch: `Klick auf die Karte öffnet den Vollmodus` (Bedienung gehört zu Testen)

View File

@@ -0,0 +1,13 @@
## Regeln einer Funktion
Feld `regeln`, wenn `funktion` gesetzt ist. Gilt nur für diese Funktion.
- Eine Zeile je Regel, höchstens 10 Wörter
- Genau eine Aussage je Zeile; zwei Aussagen werden zu zwei Regeln
- Beschreibt eine Eigenschaft oder Bedingung, keinen Ablaufschritt
- Keine Aufzählung mit Komma oder „und" zweier gleichrangiger Aussagen
- Ein Icon-Name allein ist eine gültige Regel
Richtig: `x-Icon` · `Kern blau umrandet` · `Aktualisiert den Dateinamen`
Falsch: `Kern blau umrandet, Optional grün umrandet` (zwei Aussagen)
Falsch: `Klick auf Badge` (Ablaufschritt, gehört zu Testen)

View File

@@ -0,0 +1,18 @@
## Testen einer Funktion
Feld `testen`. Gibt es nur bei Funktionen, nie auf Kartenebene.
- Eine Zeile je Schritt, höchstens 8 Wörter
- Schritte in Reihenfolge der Bedienung
- Kein Schritt der Bedienung fehlt
- Letzte Zeile beschreibt den Endzustand, keine Handlung
- Handlungen als Nominalstil mit Maustaste oder Verb im Infinitiv
Richtig:
```
- Vollmodus öffnen
- RMB auf Funktion
- Klick auf "Funktion entfernen"
- Funktion entfernt
```
Falsch: letzte Zeile `Klick zum entfernen` (Handlung statt Endzustand)

View File

@@ -0,0 +1,10 @@
## Titel einer Funktion
Feld `titel`, wenn `funktion` gesetzt ist.
- Höchstens 5 Wörter
- Kurzsatz aus Objekt und Verb im Infinitiv
- Genau eine Tätigkeit
Richtig: `Feature erstellen` · `Anzeigemodus umschalten` · `Feature als Datei speichern`
Falsch: `Erstellung von Features` (Substantivierung) · `Feature erstellen und öffnen` (zwei Tätigkeiten)

View File

@@ -0,0 +1,10 @@
## Ziel einer Funktion
Feld `ziel`, wenn `funktion` gesetzt ist.
- Höchstens 2 Sätze, je höchstens 12 Wörter
- Beschreibt den Zweck der Funktion, nicht die Bedienung
- Entfällt, wenn der Funktionstitel selbsterklärend ist — dann bleibt das Feld leer
Richtig: `Beschreibt kurz das Kernziel der Funktion`
Falsch: `Klick auf Regeln der Funktion` (Bedienung gehört zu Testen)

107
backend/struktur.py Normal file
View File

@@ -0,0 +1,107 @@
"""Stufe 1 der Prüfung: alles, was sich zählen lässt, zählt der Code.
Das Modell erfand hier Regeln, die im Katalog nicht stehen — „Im" als Füllwort,
Satzzeichen, wo keins ist. Diese Klasse ist jetzt abschließend hier erledigt, und
der Prompt verbietet dem Modell, sie noch einmal zu melden.
Ein Befund mit Vorschlag geht ohne Anfrage durch. Ein Befund ohne Vorschlag heißt:
der Code weiß, dass die Stelle falsch ist, aber nicht, was richtig wäre — den
Auftrag bekommt das Modell.
"""
import re
# Höchstzahl Wörter je Zeile, nach (bereich, feld)
WORTGRENZEN = {
("feature", "titel"): 3,
("funktion", "titel"): 5,
("feature", "regeln"): 10,
("funktion", "regeln"): 10,
("funktion", "testen"): 8,
}
ZIEL_SAETZE = 2
ZIEL_WOERTER = 12
# Abschließend. Genau diese Wörter, keine sinnverwandten.
FUELLWOERTER = ("auch", "noch", "einfach", "jeweils", "ggf")
BADGE = ("kern", "optional")
ZEILENFELDER = ("regeln", "testen")
SATZENDE = ".;"
_KLAMMER = re.compile(r"[()/]")
_STRICH = re.compile(r"^[-*]\s*")
def _zeilen(feld: str, text: str) -> list[str]:
"""regeln und testen sind Listen, alles andere ist ein Block."""
if feld in ZEILENFELDER:
return [_STRICH.sub("", z.strip()) for z in text.splitlines() if z.strip()]
return [text.strip()] if text.strip() else []
def _problem(feld: str, original: str, problem: str, vorschlag: str = "") -> dict:
return {"feld": feld, "original": original, "problem": problem, "vorschlag": vorschlag}
def _ohne_wort(zeile: str, wort: str) -> str:
neu = re.sub(rf"\s*\b{re.escape(wort)}\b\.?", "", zeile, flags=re.I).strip()
return re.sub(r"\s{2,}", " ", neu)
def _reparierbar(zeile: str) -> tuple[list[str], str]:
"""Verstöße, die sich wegstreichen lassen, samt der bereinigten Zeile."""
gruende, neu = [], zeile
if neu[-1] in SATZENDE:
gruende.append("Satzzeichen am Zeilenende")
neu = neu[:-1].rstrip()
for wort in FUELLWOERTER:
if re.search(rf"\b{wort}\b", neu, re.I):
gruende.append(f"Füllwort „{wort}“")
neu = _ohne_wort(neu, wort)
return gruende, neu
def _umschreiben(bereich: str, feld: str, zeile: str) -> list[str]:
"""Verstöße, die der Code sieht, aber nicht beheben kann."""
gruende = []
if _KLAMMER.search(zeile):
gruende.append("Klammer oder Schrägstrich")
grenze = WORTGRENZEN.get((bereich, feld))
if grenze and len(zeile.split()) > grenze:
gruende.append(f"Mehr als {grenze} Wörter")
if feld == "ziel":
saetze = [s.strip() for s in re.split(r"[.!?]+", zeile) if s.strip()]
if len(saetze) > ZIEL_SAETZE:
gruende.append(f"Mehr als {ZIEL_SAETZE} Sätze")
if any(len(s.split()) > ZIEL_WOERTER for s in saetze):
gruende.append(f"Satz mit mehr als {ZIEL_WOERTER} Wörtern")
return gruende
def _zeile(bereich: str, feld: str, zeile: str) -> list[dict]:
"""Höchstens ein Befund je Zeile. Zwei Vorschläge für dieselbe Zeile wären nicht
unabhängig — der zweite fände seine Stelle nach dem ersten nicht mehr."""
gruende, neu = _reparierbar(zeile)
offen = _umschreiben(bereich, feld, zeile)
if offen:
# Muss ohnehin neu geschrieben werden — dann alles in einem Zug, vom Modell
return [_problem(feld, zeile, ", ".join(gruende + offen))]
return [_problem(feld, zeile, ", ".join(gruende), neu)] if gruende else []
def befunde(bereich: str, felder: dict) -> list[dict]:
"""Alle messbaren Verstöße einer Prüfstelle, in Reihenfolge der Felder."""
raus = []
for feld, text in felder.items():
if feld == "badge":
if text and text not in BADGE:
raus.append(_problem(feld, text, "Weder kern noch optional", "kern"))
continue
for zeile in _zeilen(feld, text or ""):
raus += _zeile(bereich, feld, zeile)
return raus
def auftrag(befund: dict) -> str:
"""Zeile für das Modell: der Code hat gemessen, den Ersatz schreibt das Modell."""
return f'- {befund["feld"]} „{befund["original"]}“: {befund["problem"]}'

View File

@@ -1,4 +1,5 @@
import asyncio
import json
import pytest
@@ -8,7 +9,7 @@ import projekte
import pruefung
ANTWORT = """```json
{"probleme": [{"feld": "regeln", "funktion": "Etwas tun", "original": "Zwei Aussagen, und noch eine",
{"probleme": [{"feld": "regeln", "original": "Zwei Aussagen, und noch eine",
"problem": "Zwei Aussagen je Regel", "vorschlag": "Zwei Aussagen"}]}
```"""
@@ -71,7 +72,11 @@ async def test_providerfehler_landet_in_meldung(projekt, monkeypatch):
monkeypatch.setattr(agents, "frage", kaputt)
await pruefung.starte(projekt)
await warte_bis_fertig(projekt)
assert pruefung.status(projekt)["meldung"] == "Provider antwortet mit HTTP 500"
s = pruefung.status(projekt)
assert s["fehler"] == 2
assert s["meldung"] == (
"2 von 2 Prüfungen fehlgeschlagen: Provider antwortet mit HTTP 500"
)
async def test_verworfener_lauf_stoppt(projekt, monkeypatch):
@@ -114,23 +119,214 @@ async def test_parallel_begrenzt(projekt, monkeypatch):
async def test_status_ohne_lauf():
assert pruefung.status("Fremd") == {
"laeuft": False, "fertig": 0, "gesamt": 0, "probleme": [], "meldung": ""
"laeuft": False, "fertig": 0, "gesamt": 0, "fehler": 0,
"probleme": [], "meldung": "",
}
def test_antwort_lesen_ohne_zaun():
probleme = pruefung._antwort_lesen('{"probleme": [{"original": "x", "vorschlag": "y"}]}')
assert probleme == [{"original": "x", "vorschlag": "y", "funktion": ""}]
probleme = pruefung._antwort_lesen('{"probleme": [{"feld": "titel", "original": "x", "vorschlag": "y"}]}')
assert probleme == [{"feld": "titel", "original": "x", "vorschlag": "y"}]
def test_listenstrich_wird_entfernt():
probleme = pruefung._antwort_lesen(
'{"probleme": [{"original": "- alt", "vorschlag": "- neu", "funktion": " F "}]}')
'{"probleme": [{"feld": "regeln", "original": "- alt", "vorschlag": "- neu"}]}')
assert probleme[0]["original"] == "alt"
assert probleme[0]["vorschlag"] == "neu"
assert probleme[0]["funktion"] == "F"
def test_antwort_ohne_json():
with pytest.raises(ValueError):
pruefung._antwort_lesen("keine Ahnung")
def test_unmaskierte_quotes_im_original():
# Karteninhalt enthält Anführungszeichen; das Modell maskiert sie nicht
probleme = pruefung._antwort_lesen(
'{"probleme": [{"feld": "testen", "original": "Klick auf "Feature entfernen"",'
' "problem": "x", "vorschlag": "Klick auf "Feature entfernen" mit LMB"}]}')
assert probleme[0]["original"] == 'Klick auf "Feature entfernen"'
assert probleme[0]["vorschlag"] == 'Klick auf "Feature entfernen" mit LMB'
def test_gueltiges_json_bleibt_unberuehrt():
probleme = pruefung._antwort_lesen(
r'{"probleme": [{"feld": "testen", "original": "Klick auf \"X\"", "vorschlag": "y"}]}')
assert probleme[0]["original"] == 'Klick auf "X"'
def test_zeilenumbruch_im_string():
probleme = pruefung._antwort_lesen(
'{"probleme": [{"feld": "testen", "original": "erste\nzweite", "vorschlag": "y"}]}')
assert probleme[0]["original"] == "erste\nzweite"
def _eins(felder=pruefung.ALLE_FELDER, **werte):
p = {"feld": "testen", "original": "Bestätigen", "problem": "x", "vorschlag": "y"}
p.update(werte)
return pruefung._antwort_lesen(json.dumps({"probleme": [p]}), felder)
def test_mehrzeiliger_vorschlag_bleibt_erhalten():
# Die alte Zeile bleibt stehen, der Endzustand kommt dahinter — ohne Aktionsfeld
p = _eins(vorschlag="- Bestätigen mit LMB\n- Feature erstellt")[0]
assert p["vorschlag"] == "Bestätigen mit LMB\nFeature erstellt"
def test_leerer_vorschlag_nur_wo_das_feld_leer_sein_darf():
for feld in ("regeln", "testen", "ziel"):
assert len(_eins(feld=feld, vorschlag="")) == 1 # streicht die Stelle
for feld in ("titel", "badge"):
assert _eins(feld=feld, vorschlag="") == [] # wären danach kaputt
def test_mehrere_zeilen_nur_bei_listenfeldern():
for feld in ("regeln", "testen"):
assert len(_eins(feld=feld, vorschlag="a\nb")) == 1
for feld in ("titel", "badge", "ziel"):
assert _eins(feld=feld, vorschlag="a\nb") == []
def test_erfundenes_feld_wird_verworfen():
# Das Modell meldete „struktur" — kein Feld der Karte, also nie anwendbar
assert _eins(feld="struktur") == []
assert _eins(feld="") == []
def test_feld_muss_zum_bereich_passen():
feature = pruefung.BEREICHE["feature"]["felder"]
funktion = pruefung.BEREICHE["funktion"]["felder"]
assert _eins(feld="testen", felder=funktion) != []
assert _eins(feld="testen", felder=feature) == [] # Karte hat kein testen
assert _eins(feld="badge", vorschlag="kern", felder=feature) != []
assert _eins(feld="badge", vorschlag="kern", felder=funktion) == []
def test_dieselbe_stelle_nur_einmal():
probleme = pruefung._antwort_lesen(
'{"probleme": ['
'{"feld": "regeln", "original": "a", "problem": "eins", "vorschlag": "x"},'
'{"feld": "regeln", "original": "a", "problem": "zwei", "vorschlag": "y"},'
'{"feld": "testen", "original": "a", "problem": "drei", "vorschlag": "z"}]}')
assert [p["problem"] for p in probleme] == ["eins", "drei"] # feld trennt
KARTE = (
"<!-- titel -->\nLogin\n<!-- art -->\noptional\n<!-- ziel -->\nAnmelden\n"
"<!-- regeln -->\n- Kartenregel\n"
"<!-- funktion -->\nAnmelden\n<!-- testen -->\n- Formular öffnen\n"
"<!-- funktion -->\nAbmelden\n"
)
def test_aufgaben_eine_je_ebene(datenordner):
projekte.anlegen("Test")
f = features.anlegen("Test", KARTE)
liste = pruefung.aufgaben(f)
# Dateireihenfolge, nicht alphabetisch — sortiert wird erst in der Anzeige
assert [a["funktion"] for a in liste] == ["", "Anmelden", "Abmelden"]
assert [a["bereich"] for a in liste] == ["feature", "funktion", "funktion"]
assert all(a["feature"] == "login" for a in liste)
def test_aufgabe_bekommt_nur_ihren_ausschnitt(datenordner):
projekte.anlegen("Test")
f = features.anlegen("Test", KARTE)
karte, anmelden, abmelden = pruefung.aufgaben(f)
inhalt = {a["funktion"]: pruefung.inhalt(a) for a in (karte, anmelden, abmelden)}
# Kartenebene: badge statt art, Funktionstitel nur als Kontext für den Badge
assert "<!-- badge -->\noptional" in inhalt[""]
assert "<!-- regeln -->\n- Kartenregel" in inhalt[""]
assert "<!-- funktionen -->\n- Anmelden\n- Abmelden" in inhalt[""]
assert "Formular öffnen" not in inhalt[""] # keine Details der Funktionen
# Funktion: eigener Titel, eigenes testen, nichts von der Karte
assert "<!-- titel -->\nAnmelden" in inhalt["Anmelden"]
assert "<!-- testen -->\n- Formular öffnen" in inhalt["Anmelden"]
assert "Kartenregel" not in inhalt["Anmelden"]
# Leere Felder werden gar nicht erst mitgeschickt
assert inhalt["Abmelden"] == "<!-- titel -->\nAbmelden\n"
async def test_lauf_zaehlt_alle_stellen(datenordner, monkeypatch):
projekte.anlegen("Test")
features.anlegen("Test", KARTE)
monkeypatch.setattr(agents, "pruefe_konfiguration", lambda: None)
gesehen = []
async def merke(system, inhalt):
gesehen.append((system, inhalt))
return '{"probleme": []}'
monkeypatch.setattr(agents, "frage", merke)
await pruefung.starte("Test")
await warte_bis_fertig("Test")
assert pruefung.status("Test")["gesamt"] == 3 # 1 Karte + 2 Funktionen
assert len(gesehen) == 3
# Der Kartenprüfer kennt die Badge-Regeln, der Funktionsprüfer nicht
karten_system = next(s for s, i in gesehen if "badge" in i)
assert "## Badge der Karte" in karten_system
assert "## Testen einer Funktion" not in karten_system
async def test_herkunft_kommt_vom_code_nicht_vom_modell(datenordner, monkeypatch):
projekte.anlegen("Test")
features.anlegen("Test", KARTE)
monkeypatch.setattr(agents, "pruefe_konfiguration", lambda: None)
async def luegt(system, inhalt):
# Das Modell behauptet eine fremde Funktion — das muss ignoriert werden
return ('{"probleme": [{"feld": "titel", "funktion": "Erfunden",'
' "original": "x", "vorschlag": "y"}]}')
monkeypatch.setattr(agents, "frage", luegt)
await pruefung.starte("Test")
await warte_bis_fertig("Test")
zuordnung = sorted(p["funktion"] for p in pruefung.status("Test")["probleme"])
assert zuordnung == ["", "Abmelden", "Anmelden"]
def test_stil_katalog_je_bereich():
feature = pruefung._stil_lesen("feature")
funktion = pruefung._stil_lesen("funktion")
# allgemein steckt in beiden und steht vorn
for stil in (feature, funktion):
assert stil.startswith("# Stil-Katalog")
assert "## Allgemein" in stil
for ueberschrift in (
"## Titel der Karte", "## Badge der Karte",
"## Ziel der Karte", "## Regeln der Karte",
):
assert ueberschrift in feature
assert ueberschrift not in funktion # der Funktionsprüfer sieht das nicht
for ueberschrift in (
"## Titel einer Funktion", "## Ziel einer Funktion",
"## Regeln einer Funktion", "## Testen einer Funktion",
):
assert ueberschrift in funktion
assert ueberschrift not in feature
def test_neue_stil_datei_kommt_ohne_code_dazu(tmp_path, monkeypatch):
(tmp_path / "allgemein.md").write_text("## Allgemein\n")
(tmp_path / "feature-neu.md").write_text("## Neu\n")
monkeypatch.setattr(pruefung.config, "STIL_DIR", tmp_path)
stil = pruefung._stil_lesen("feature")
assert "## Neu" in stil
assert stil.index("## Allgemein") < stil.index("## Neu") # alphabetisch
assert "## Neu" not in pruefung._stil_lesen("funktion")
def test_stil_ordner_leer_bricht_ab(tmp_path, monkeypatch):
monkeypatch.setattr(pruefung.config, "STIL_DIR", tmp_path)
with pytest.raises(ValueError):
pruefung._stil_lesen("feature")

View File

@@ -58,6 +58,17 @@ async def test_feature_crud(client):
assert (await client.delete("/api/projekte/Alpha/features/login")).status_code == 404
async def test_pruefung_ohne_konfiguration(client, monkeypatch):
import agents
monkeypatch.setattr(agents.config, "MINIMAX_API_KEY", "")
await client.post("/api/projekte", json={"name": "Alpha"})
res = await client.post("/api/projekte/Alpha/pruefung")
# 400, nicht 409 — 409 heißt „läuft bereits" und würde die Meldung verschlucken
assert res.status_code == 400
assert "MINIMAX_API_KEY" in res.json()["detail"]
async def test_unbekanntes_projekt(client):
assert (await client.get("/api/projekte/Fremd/features")).status_code == 404
assert (await client.post("/api/projekte/Fremd/features", json={"roh": ROH})).status_code == 404

View File

@@ -0,0 +1,146 @@
import pruefung
import struktur
def _pruefe(bereich, feld, text):
return struktur.befunde(bereich, {feld: text})
def _probleme(befunde):
return [b["problem"] for b in befunde]
def test_satzzeichen_am_zeilenende():
b = _pruefe("funktion", "regeln", "- Passwort nie loggen.")[0]
assert b["problem"] == "Satzzeichen am Zeilenende"
assert b["vorschlag"] == "Passwort nie loggen"
def test_punkt_im_wort_ist_kein_satzende():
# Der Prüfer meldete hier „Punkt am Zeilenende" — die Zeile endet auf „v"
assert _pruefe("funktion", "regeln", "- API Zugänge stehen in .env") == []
assert _pruefe("funktion", "regeln", "- Nur kern und optional erlaubt") == []
def test_fuellwort_wird_entfernt():
b = _pruefe("funktion", "regeln", "- Auch Features entfernen")[0]
assert b["problem"] == "Füllwort „auch“"
assert b["vorschlag"] == "Features entfernen"
def test_liste_der_fuellwoerter_ist_abschliessend():
# Das Modell hielt „Im", „Nur" und „direkt" für Füllwörter — sind sie nicht
for zeile in ("- Im Modal Namen eingeben", "- Nur wenige Wörter",
"- Feature direkt in Vollmodus geöffnet"):
assert _pruefe("funktion", "regeln", zeile) == []
def test_wortgrenze_je_bereich_und_feld():
assert _probleme(_pruefe("feature", "titel", "Karten der Features prüfen")) \
== ["Mehr als 3 Wörter"]
assert _pruefe("funktion", "titel", "Karten der Features prüfen") == []
assert _probleme(_pruefe("funktion", "testen", "- Eins zwei drei vier fünf sechs sieben acht neun")) \
== ["Mehr als 8 Wörter"]
def test_ohne_vorschlag_wenn_der_code_nicht_kuerzen_kann():
b = _pruefe("funktion", "testen", "- Eins zwei drei vier fünf sechs sieben acht neun")[0]
assert b["vorschlag"] == "" # den Ersatz schreibt das Modell
def test_klammer_und_schraegstrich():
assert _probleme(_pruefe("funktion", "regeln", "- Rand blau (kern)")) \
== ["Klammer oder Schrägstrich"]
assert _probleme(_pruefe("funktion", "regeln", "- Kern/Optional getrennt")) \
== ["Klammer oder Schrägstrich"]
def test_ziel_saetze_und_laenge():
lang = "Dieses Ziel hat deutlich mehr als zwölf Wörter in einem einzigen langen Satz"
assert "Satz mit mehr als 12 Wörtern" in _probleme(_pruefe("feature", "ziel", lang))
assert _probleme(_pruefe("feature", "ziel", "Eins. Zwei. Drei")) == ["Mehr als 2 Sätze"]
def test_badge_nur_kern_oder_optional():
assert _pruefe("feature", "badge", "optional") == []
b = _pruefe("feature", "badge", "wichtig")[0]
assert b["vorschlag"] == "kern"
def test_jede_zeile_einzeln():
befunde = _pruefe("funktion", "regeln", "- Erste Regel.\n- Auch zweite Regel")
assert _probleme(befunde) == ["Satzzeichen am Zeilenende", "Füllwort „auch“"]
def test_ein_befund_je_zeile():
# Zwei Vorschläge für dieselbe Zeile wären nicht unabhängig anwendbar
befunde = _pruefe("funktion", "regeln", "- Auch Passwort prüfen.")
assert len(befunde) == 1
assert befunde[0]["problem"] == "Satzzeichen am Zeilenende, Füllwort „auch“"
assert befunde[0]["vorschlag"] == "Passwort prüfen"
def test_unreparierbares_zieht_den_ganzen_befund_zum_modell():
# Zu lang und Füllwort: der Code streicht nicht halb, das Modell schreibt neu
b = _pruefe("funktion", "testen", "- Auch eins zwei drei vier fünf sechs sieben acht")[0]
assert b["problem"] == "Füllwort „auch“, Mehr als 8 Wörter"
assert b["vorschlag"] == ""
def test_auftrag_nennt_feld_und_stelle():
b = _pruefe("feature", "titel", "Karten der Features prüfen")[0]
assert struktur.auftrag(b) == '- titel „Karten der Features prüfen“: Mehr als 3 Wörter'
async def test_pruefe_verbindet_messung_und_modell(datenordner, monkeypatch):
import agents
import features
import projekte
projekte.anlegen("Test")
f = features.anlegen(
"Test",
"<!-- titel -->\nLogin\n<!-- funktion -->\nAnmelden\n"
"<!-- regeln -->\n- Auch Passwort prüfen.\n",
)
gesehen = []
async def modell(system, inhalt):
gesehen.append(inhalt)
# Das Modell meldet ein erfundenes Feld und dieselbe Zeile noch einmal
return ('{"probleme": ['
'{"feld": "struktur", "original": "x", "vorschlag": "y"},'
'{"feld": "regeln", "original": "Auch Passwort prüfen.",'
' "problem": "Füllwort", "vorschlag": "Passwort prüfen"},'
'{"feld": "regeln", "original": "Auch Passwort prüfen.",'
' "problem": "zwei Aussagen", "vorschlag": "Passwort prüfen"}]}')
monkeypatch.setattr(agents, "frage", modell)
aufgabe = pruefung.aufgaben(f)[1]
probleme = await pruefung._pruefe({"funktion": "<KATALOG>"}, aufgabe)
# Ein Befund für die Zeile, beide Verstöße auf einmal behoben — ohne Modell
assert [p["problem"] for p in probleme] == [
"Satzzeichen am Zeilenende, Füllwort „auch“",
]
assert probleme[0]["vorschlag"] == "Passwort prüfen"
# Erfundenes Feld fällt raus, Doppelmeldung der gemessenen Zeile ebenso
assert probleme[0]["feld"] == "regeln"
assert probleme[0]["funktion"] == "Anmelden"
# Nichts zu kürzen -> kein Auftrag im Ausschnitt
assert "Gemessen" not in gesehen[0]
def test_gemessene_befunde_haengen_am_ausschnitt(datenordner):
import features
import projekte
projekte.anlegen("Test")
f = features.anlegen("Test", "<!-- titel -->\nEin viel zu langer Kartentitel\n")
karte = pruefung.aufgaben(f)[0]
befunde = struktur.befunde("feature", karte["felder"])
assert "Gemessen" not in pruefung.inhalt(karte)
text = pruefung.inhalt(karte, befunde)
assert "Gemessen, bitte neu schreiben:" in text
assert "Mehr als 3 Wörter" in text