diff --git a/README.md b/README.md index 65a11ca..dbf9417 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,61 @@ Rein manuelle Pflege: Sidebar links = Projekte, Hauptbereich = Feature-Cards. ## Karten prüfen -Das ⌾-Icon in der Sidebar prüft alle Karten des aktiven Projekts gegen -`backend/stil.md` (eine Anfrage je Karte). Fehlerhafte Karten öffnen sich rot markiert -und zeigen unter jeder Stelle einen Vorschlag mit ✓/✕; ✓ schreibt ihn in die Datei, die -übrigen Vorschläge bleiben stehen. Rechts läuft ein Panel mit Fortschritt und Problemliste. +Das ⌾-Icon in der Sidebar prüft alle Karten des aktiven Projekts gegen den Stil-Katalog. +Fehlerhafte Karten öffnen sich rot markiert und zeigen unter jeder Stelle einen Vorschlag +mit ✓/✕; ✓ schreibt ihn in die Datei, die übrigen Vorschläge bleiben stehen. Unter dem +Icon steht der Fortschritt, rechts läuft ein Panel mit Problemliste. + +Zwei Stufen je Prüfstelle. Was sich zählen lässt, zählt `struktur.py` — Wortzahl, +Satzzeichen am Zeilenende, Füllwörter, Klammern, Badge-Wert. Was es wegstreichen kann, +geht als fertiger Vorschlag durch, ganz ohne Anfrage. Der Rest hängt als Auftrag +„Gemessen, bitte neu schreiben" am Ausschnitt für das Modell. Das Modell darf diese +Klasse nicht selbst melden — sonst erfindet es Regeln, die im Katalog nicht stehen. + +Höchstens ein gemessener Befund je Zeile: zwei Vorschläge für dieselbe Zeile wären +nicht unabhängig anwendbar. + +Eine Anfrage je Prüfstelle, nicht je Karte: + +``` +Karte ──┬── Kartenebene titel · badge · ziel · regeln 1 Anfrage + ├── Funktion 1 titel · ziel · regeln · testen 1 Anfrage + └── Funktion 2 … 1 Anfrage +``` + +Ein Vorschlag ersetzt seine Stelle eins zu eins. Was das bewirkt, sagt der Prüfer +nicht — es ergibt sich aus dem Vergleich: + +``` +eine Zeile schreibt die Stelle um +mehrere Zeilen ersetzen die Stelle durch alle davon; steht die alte + wieder vorn, kommt nur etwas dazu +leer streicht die Stelle +``` + +So muss das Modell keine Aktion auswählen. Es schreibt nur hin, was dort stehen soll. +Ohne umsetzbaren Vorschlag wird ein Problem verworfen statt leer angezeigt. + +Vorschläge werden einzeln und in beliebiger Reihenfolge angenommen. Jeder muss +darum für sich allein anwendbar sein und selbst dem Katalog genügen. + +Jede Anfrage bekommt nur ihren Ausschnitt der Karte und nur den Katalogteil, der für sie +gilt. Die Herkunft eines Problems stammt aus dem Code, nicht aus der Modellantwort. + +Der Katalog liegt in `backend/stil/` — eine Datei je Feld, alphabetisch zusammengesetzt +(`allgemein` landet dadurch vorn). Das Präfix entscheidet, wer sie zu sehen bekommt: + +``` +allgemein.md beide Prüfer +feature-*.md nur die Kartenebene: titel, badge, ziel, regeln +funktion-*.md nur die Funktionen: titel, ziel, regeln, testen +``` + +Eine neue Datei wird ohne Codeänderung mitgelesen. Ist der Ordner leer, bricht die +Prüfung ab statt ohne Vorgaben zu prüfen. Zugang über `.env` (Vorlage: `.env.example`) — `MINIMAX_API_KEY`, `MINIMAX_MODELL`, -`MINIMAX_URL`, dazu `PRUEFUNG_PARALLEL` (gleichzeitig geprüfte Karten, Standard 10). +`MINIMAX_URL`, dazu `PRUEFUNG_PARALLEL` (gleichzeitig laufende Anfragen, Standard 10). Fehlt einer der Zugangswerte, bricht die Prüfung mit sichtbarer Meldung ab; es wird nie still auf ein anderes Modell ausgewichen. Ergebnisse liegen nur im Serverspeicher und überleben keinen Neustart. diff --git a/backend/agents.py b/backend/agents.py index 220ca12..2c96d19 100644 --- a/backend/agents.py +++ b/backend/agents.py @@ -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: diff --git a/backend/config.py b/backend/config.py index 521ced0..4df1df9 100644 --- a/backend/config.py +++ b/backend/config.py @@ -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(): diff --git a/backend/pruefung.py b/backend/pruefung.py index 646651e..7fc662f 100644 --- a/backend/pruefung.py +++ b/backend/pruefung.py @@ -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"\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] diff --git a/backend/routes.py b/backend/routes.py index 5d8fc14..323198e 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -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)) diff --git a/backend/stil.md b/backend/stil.md deleted file mode 100644 index 127ea4b..0000000 --- a/backend/stil.md +++ /dev/null @@ -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 `-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) diff --git a/backend/stil/allgemein.md b/backend/stil/allgemein.md new file mode 100644 index 0000000..fdea5b5 --- /dev/null +++ b/backend/stil/allgemein.md @@ -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 `-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` diff --git a/backend/stil/feature-badge.md b/backend/stil/feature-badge.md new file mode 100644 index 0000000..630d6af --- /dev/null +++ b/backend/stil/feature-badge.md @@ -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) diff --git a/backend/stil/feature-regeln.md b/backend/stil/feature-regeln.md new file mode 100644 index 0000000..f132c92 --- /dev/null +++ b/backend/stil/feature-regeln.md @@ -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) diff --git a/backend/stil/feature-titel.md b/backend/stil/feature-titel.md new file mode 100644 index 0000000..98ca343 --- /dev/null +++ b/backend/stil/feature-titel.md @@ -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) diff --git a/backend/stil/feature-ziel.md b/backend/stil/feature-ziel.md new file mode 100644 index 0000000..0d99a42 --- /dev/null +++ b/backend/stil/feature-ziel.md @@ -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) diff --git a/backend/stil/funktion-regeln.md b/backend/stil/funktion-regeln.md new file mode 100644 index 0000000..ded34be --- /dev/null +++ b/backend/stil/funktion-regeln.md @@ -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) diff --git a/backend/stil/funktion-testen.md b/backend/stil/funktion-testen.md new file mode 100644 index 0000000..82a33b3 --- /dev/null +++ b/backend/stil/funktion-testen.md @@ -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) diff --git a/backend/stil/funktion-titel.md b/backend/stil/funktion-titel.md new file mode 100644 index 0000000..62f72a6 --- /dev/null +++ b/backend/stil/funktion-titel.md @@ -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) diff --git a/backend/stil/funktion-ziel.md b/backend/stil/funktion-ziel.md new file mode 100644 index 0000000..709ff41 --- /dev/null +++ b/backend/stil/funktion-ziel.md @@ -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) diff --git a/backend/struktur.py b/backend/struktur.py new file mode 100644 index 0000000..32935bb --- /dev/null +++ b/backend/struktur.py @@ -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"]}' diff --git a/backend/tests/test_pruefung.py b/backend/tests/test_pruefung.py index 6d5757e..0a40c62 100644 --- a/backend/tests/test_pruefung.py +++ b/backend/tests/test_pruefung.py @@ -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 = ( + "\nLogin\n\noptional\n\nAnmelden\n" + "\n- Kartenregel\n" + "\nAnmelden\n\n- Formular öffnen\n" + "\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 "\noptional" in inhalt[""] + assert "\n- Kartenregel" in inhalt[""] + assert "\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 "\nAnmelden" in inhalt["Anmelden"] + assert "\n- Formular öffnen" in inhalt["Anmelden"] + assert "Kartenregel" not in inhalt["Anmelden"] + + # Leere Felder werden gar nicht erst mitgeschickt + assert inhalt["Abmelden"] == "\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") diff --git a/backend/tests/test_routes.py b/backend/tests/test_routes.py index 5f8f303..17e9b28 100644 --- a/backend/tests/test_routes.py +++ b/backend/tests/test_routes.py @@ -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 diff --git a/backend/tests/test_struktur.py b/backend/tests/test_struktur.py new file mode 100644 index 0000000..e9edd34 --- /dev/null +++ b/backend/tests/test_struktur.py @@ -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", + "\nLogin\n\nAnmelden\n" + "\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": ""}, 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", "\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 diff --git a/daten/Planer/darkmode.md b/daten/Planer/darkmode.md index 5b412a4..2d0b51f 100644 --- a/daten/Planer/darkmode.md +++ b/daten/Planer/darkmode.md @@ -18,5 +18,9 @@ Darkmode umschalten - Auto-Icon: A - Zustand bleibt gespeichert -- LMB auf Icon in der Sidebar oben -- Modus je nach vorherigem Zustand Auto, Tag oder Nacht +- LMB auf Sidebar-Icon +- Erster Modus ist Auto +- LMB auf Icon +- Zweiter Modus ist Tag +- LMB auf Icon +- Dritter Modus ist Nacht diff --git a/daten/Planer/features.md b/daten/Planer/features.md index 1281cb5..0d91fd9 100644 --- a/daten/Planer/features.md +++ b/daten/Planer/features.md @@ -9,18 +9,18 @@ Gruppiert Informationen zu ähnlichen Funktionen ---------------------------------------- - Alphabetisch sortiert -- Unter daten: als md Datei gespeichert +- Unter daten als md gespeichert - Verlassen speichert - Escape verwirft ---------------------------------------- Anzeigemodus umschalten -Kompaktmodus zum Lesen und Vollmodus zum Vertiefen und Anpassen +Erlaubt schnelles Lesen oder detaillierte Bearbeitung - Standard ist Kompaktmodus - Vollmodus geht über responsive Spalten -- Vollmodus zeigt alles +- Vollmodus zeigt alle Felder - Kompaktmodus zeigt Ziel und Funktionstitel als Liste - Augen-Icon rechts oben @@ -35,6 +35,7 @@ Unterscheidet die Kern- und optionalen Features - Kernfarbe blau - Optionalfarbe grün - Verändert die Randfarbe vom Feature +- Prüfung meldet falsche Einstufung - Vollmodus öffnen - Klick auf Badge mit LMB @@ -51,7 +52,7 @@ Feature entfernen Feature erstellen - +-Icon -- Feature direkt in Vollmodus geöffnet +- Feature in Vollmodus geöffnet - RMB auf Hauptbereich - Klick auf "Feature erstellen" diff --git a/daten/Planer/karten-pruefen.md b/daten/Planer/karten-pruefen.md index bb9b3a3..471ce61 100644 --- a/daten/Planer/karten-pruefen.md +++ b/daten/Planer/karten-pruefen.md @@ -15,12 +15,27 @@ Prüft alle Karten auf Stilvorgaben und schlägt Verbesserungen vor Änderungsvorschläge anzeigen -- Annehmen und Ablehnen Buttons oben rechts -- Buttons laden nur die Problemstelle neu +- Annehmen als Haken-Icon: ✓ +- Ablehnen als x-Icon: ✕ +- Icons stehen oben rechts +- Angenommener Vorschlag landet in der Datei +- Übrige Vorschläge bleiben stehen - Prüfung durchlaufen lassen - Unter den fehlerhaften Problemen werden Verbesserungsvorschläge angezeigt -- Annehmen und Ablehnen Buttons angezeigt +- Beide Icons angezeigt + +Badge prüfen + +Prüft die Einstufung als Kern oder Optional + +- Nur kern und optional erlaubt +- Einstufung muss zum Ziel passen +- Falscher Badge wird rot markiert + +- Prüfung durchlaufen lassen +- Badge der Karte rot markiert +- Vorschlag unter dem Titel angezeigt Fehlerhafte Cards markieren @@ -33,10 +48,39 @@ Fehlerhafte Cards markieren Prüfung ausführen - Prüf-Icon in Sidebar Navigation -- - LMB auf Icon -- Prüfung wird gestartet +- Prüfung wird gestartet - Rechte Sidebar geöffnet - Fortschritt unter Icon angezeigt - Probleme werden live gelistet + +Stellen einzeln prüfen + +Jede Stelle bekommt eigene Vorgaben und einen eigenen Ausschnitt + +- Eine Anfrage je Kartenebene +- Eine Anfrage je Funktion +- Kartenebene prüft Titel, Badge, Ziel, Regeln +- Funktion prüft Titel, Ziel, Regeln, Testen +- Prüfer sieht nur seinen Ausschnitt +- Herkunft eines Problems stammt aus dem Code + +- Prüfung starten +- Fortschritt beobachten +- Probleme mit Herkunft gelistet + +Stil-Katalog ablegen + +Trennt die Vorgaben je Feld voneinander + +- Eine Datei je Feld im Ordner stil +- allgemein gilt für beide Prüfer +- feature-Präfix gilt für die Kartenebene +- funktion-Präfix gilt für die Funktionen +- Neue Datei wirkt ohne Codeänderung +- Leerer Ordner bricht die Prüfung ab + +- Datei im Ordner stil ergänzen +- Prüfung starten +- Neue Vorgabe wirkt diff --git a/daten/Planer/karten-schreibstil.md b/daten/Planer/karten-schreibstil.md index b1ebe97..0a3a928 100644 --- a/daten/Planer/karten-schreibstil.md +++ b/daten/Planer/karten-schreibstil.md @@ -12,28 +12,36 @@ kern - Feld leer lassen statt füllen ---------------------------------------- -Regel Schreibstil +Badge einstufen + +Stuft die Karte als Kern oder Optional ein + +- Nur kern oder optional +- kern wenn das Projekt es braucht +- Im Zweifel kern + +Regel schreiben Spezifische Hinweise zum Titel - Ein kurzer Satz je Regel - Eine Aussage je Zeile -Testschritt Schreibstil +Testschritt schreiben Beschreibt das Prüfen als Ablauf - Ein Schritt je Zeile - Letzte Zeile beschreibt Endzustand -Titel Schreibstil +Titel schreiben Benennt die Sache so kompakt wie möglich - Nur wenige Wörter - Kurzsatz bei Funktionen -Ziel Schreibstil +Ziel schreiben Beschreibt kurz den Kern diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 73483cc..2fa40c3 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,7 +5,13 @@ import SeitenLeiste from './components/SeitenLeiste.vue' import FeatureListe from './components/FeatureListe.vue' import KontextMenue from './components/KontextMenue.vue' import PruefPanel from './components/PruefPanel.vue' -import { offen as pruefungOffen, starte as startePruefung, schliesse as schliessePruefung } from './composables/pruefung.js' +import { + offen as pruefungOffen, + laeuft as pruefungLaeuft, + prozent as pruefungProzent, + starte as startePruefung, + schliesse as schliessePruefung, +} from './composables/pruefung.js' const projekte = ref([]) const aktivesProjekt = ref(null) @@ -80,12 +86,10 @@ onMounted(async () => { @geaendert="ladeProjekte" @umbenannt="umbenannt" > - + + + {{ pruefungProzent }} % + @@ -118,4 +122,15 @@ main { margin-top: 3rem; text-align: center; } +/* Fortschritt sitzt unter dem Prüf-Icon */ +.pruefen { + display: inline-flex; + flex-direction: column; + align-items: center; + line-height: 1.1; +} +.fortschritt { + font-size: 0.65rem; + color: var(--dezent); +} diff --git a/frontend/src/components/FeatureKarte.vue b/frontend/src/components/FeatureKarte.vue index 2a85be1..577cbae 100644 --- a/frontend/src/components/FeatureKarte.vue +++ b/frontend/src/components/FeatureKarte.vue @@ -3,7 +3,7 @@ import { nextTick, ref, watch } from 'vue' import { marked } from 'marked' import { featureSpeichern } from '../api.js' import { oeffneMenue } from '../composables/kontextmenue.js' -import { erledige } from '../composables/pruefung.js' +import { benenneUm, erledige, vorschlagsArt, vorschlagsZeilen } from '../composables/pruefung.js' import ModalBox from './ModalBox.vue' import ProblemHinweis from './ProblemHinweis.vue' @@ -154,9 +154,13 @@ function baueRoh() { async function speichern() { meldung.value = '' + const alt = props.feature.id try { - const f = await featureSpeichern(props.projekt, props.feature.id, baueRoh()) - emit('aktualisiert', props.feature.id, f) // id kann sich durch Titeländerung ändern + const f = await featureSpeichern(props.projekt, alt, baueRoh()) + // Ein neuer Titel benennt die Datei um; die offenen Probleme müssen mitwandern, + // sonst findet problemeFuer() sie unter der neuen id nicht mehr. + if (f.id !== alt) benenneUm(alt, 'feature', alt, f.id) + emit('aktualisiert', alt, f) } catch (e) { meldung.value = e.message } @@ -187,27 +191,53 @@ function problemeAn(feld, funktion = '', original = null) { (original === null || p.original === original)) } -// Vorschlag übernehmen: Titel und Ziel ganz ersetzen, bei Regeln und Testen die Zeile +// Bleibt die alte Zeile im Vorschlag stehen, kommt nur etwas dazu — sie ist in Ordnung +function fehlerAn(feld, funktion = '', original = null) { + return problemeAn(feld, funktion, original).some(p => vorschlagsArt(p) !== 'ergaenzen') +} + +// Der Vorschlag ersetzt die gefundene Zeile durch seine eigenen — keine, eine oder +// mehrere. Gesucht wird über den Text, nicht über den Index: bei mehreren Vorschlägen +// hintereinander hat sich die Liste schon verschoben. +function zeileAendern(text, p) { + const zeilen = text.split('\n') + const i = zeilen.findIndex(z => z.replace(/^[-*]\s*/, '').trim() === p.original) + if (i < 0) return null + zeilen.splice(i, 1, ...vorschlagsZeilen(p).map(z => `- ${z}`)) + return zeilen.join('\n') +} + +// Vorschlag übernehmen. Rückgabe false = nicht anwendbar, dann bleibt er stehen function anwenden(p) { const ziel = p.funktion ? s.value.funktionen.find(f => f.text === p.funktion) : s.value - if (!ziel) return + if (!ziel) return false if (p.feld === 'titel') { - if (p.funktion) ziel.text = p.vorschlag - else ziel.titel = p.vorschlag + if (!p.vorschlag) return false + if (p.funktion) { + ziel.text = p.vorschlag + // Die übrigen Probleme dieser Funktion zeigen noch auf den alten Titel + benenneUm(props.feature.id, 'funktion', p.funktion, p.vorschlag) + } else { + ziel.titel = p.vorschlag + } } else if (p.feld === 'ziel') { - ziel.ziel = p.vorschlag + ziel.ziel = p.vorschlag // leerer Vorschlag leert das Feld — der Katalog erlaubt das + } else if (p.feld === 'badge') { + if (p.vorschlag !== 'kern' && p.vorschlag !== 'optional') return false + ziel.art = p.vorschlag + } else if (p.feld === 'regeln' || p.feld === 'testen') { + if (typeof ziel[p.feld] !== 'string') return false // z. B. testen auf Kartenebene + const neu = zeileAendern(ziel[p.feld], p) + if (neu === null) return false // Zeile schon weg oder umgeschrieben + ziel[p.feld] = neu } else { - const feld = p.feld === 'testen' ? 'testen' : 'regeln' - ziel[feld] = ziel[feld] - .split('\n') - .map(z => (z.replace(/^[-*]\s*/, '').trim() === p.original ? `- ${p.vorschlag}` : z)) - .join('\n') + return false // unbekanntes Feld — lieber nichts anfassen } + return true } async function annehmen(p) { - anwenden(p) - erledige(p) + if (anwenden(p)) erledige(p) await speichern() } @@ -216,7 +246,7 @@ function ablehnen(p) { } async function alleAnnehmen() { - props.probleme.forEach(p => { anwenden(p); erledige(p) }) + props.probleme.forEach(p => { if (anwenden(p)) erledige(p) }) await speichern() } @@ -247,11 +277,15 @@ function alleAblehnen() { - {{ s.art }} + {{ s.art }}