diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cf09704 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Kopie als .env anlegen und ausfüllen. Fehlt ein Wert, bricht die Prüfung +# mit sichtbarer Meldung ab — es wird nie still auf ein anderes Modell gewechselt. +MINIMAX_API_KEY= +MINIMAX_MODELL=MiniMax-M2.7-highspeed +MINIMAX_URL=https://api.minimax.io/anthropic/v1/messages + +# Karten, die gleichzeitig geprüft werden +PRUEFUNG_PARALLEL=10 diff --git a/.gitignore b/.gitignore index 098ee61..a89b70f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.env __pycache__/ .pytest_cache/ node_modules/ diff --git a/README.md b/README.md index 912e26a..65a11ca 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,19 @@ Rein manuelle Pflege: Sidebar links = Projekte, Hauptbereich = Feature-Cards. - `make build && make web` — Produktion: http://localhost:8200 - `make test` — Backend-Tests +## 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. + +Zugang über `.env` (Vorlage: `.env.example`) — `MINIMAX_API_KEY`, `MINIMAX_MODELL`, +`MINIMAX_URL`, dazu `PRUEFUNG_PARALLEL` (gleichzeitig geprüfte Karten, 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. + ## Datenablage Markdown-Dateien unter `daten//.md` — Projekt = Ordner, die Datei ist diff --git a/backend/agents.py b/backend/agents.py new file mode 100644 index 0000000..220ca12 --- /dev/null +++ b/backend/agents.py @@ -0,0 +1,54 @@ +"""Anbindung an MiniMax über das Anthropic-Messages-Format. Kein Fallback: fehlt die +Konfiguration oder antwortet der Provider nicht, bricht der Aufruf mit sichtbarer +Meldung ab.""" + +import httpx + +import config + +ZEITLIMIT = 180.0 +MAX_TOKENS = 8000 +API_VERSION = "2023-06-01" + + +def pruefe_konfiguration(): + fehlend = [ + name + for name, wert in ( + ("MINIMAX_API_KEY", config.MINIMAX_API_KEY), + ("MINIMAX_MODELL", config.MINIMAX_MODELL), + ("MINIMAX_URL", config.MINIMAX_URL), + ) + if not wert + ] + if fehlend: + raise ValueError(f"Nicht konfiguriert: {', '.join(fehlend)} in .env fehlt") + + +async def frage(system: str, inhalt: str) -> str: + """Eine Anfrage, eine Antwort — der Text der Modellantwort. + Kalt geroutet: niedrige Temperatur, kein Thinking; die Prüfung soll nicht raten.""" + pruefe_konfiguration() + daten = { + "model": config.MINIMAX_MODELL, + "max_tokens": MAX_TOKENS, + "system": system, + "messages": [{"role": "user", "content": inhalt}], + "temperature": 0.2, + "thinking": {"type": "disabled"}, + } + async with httpx.AsyncClient(timeout=httpx.Timeout(ZEITLIMIT, connect=30)) as client: + res = await client.post( + config.MINIMAX_URL, + headers={"x-api-key": config.MINIMAX_API_KEY, "anthropic-version": API_VERSION}, + json=daten, + ) + if res.status_code != 200: + raise RuntimeError(f"Provider antwortet mit HTTP {res.status_code}") + antwort = res.json() + text = "".join( + b.get("text", "") for b in antwort.get("content", []) if b.get("type") == "text" + ) + if not text.strip(): + raise RuntimeError(f"Leere Antwort (stop_reason={antwort.get('stop_reason')})") + return text diff --git a/backend/config.py b/backend/config.py index fbd78b7..521ced0 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,4 +1,37 @@ +import os from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parent.parent DATEN_DIR = PROJECT_ROOT / "daten" +STIL_DATEI = Path(__file__).resolve().parent / "stil.md" + + +def _lade_env(): + """Liest .env im Projektwurzel-Verzeichnis; echte Umgebungsvariablen haben Vorrang.""" + datei = PROJECT_ROOT / ".env" + if not datei.is_file(): + return + for zeile in datei.read_text().splitlines(): + zeile = zeile.strip() + if not zeile or zeile.startswith("#") or "=" not in zeile: + continue + schluessel, wert = zeile.split("=", 1) + os.environ.setdefault(schluessel.strip(), wert.strip()) + + +_lade_env() + +MINIMAX_API_KEY = os.environ.get("MINIMAX_API_KEY", "") +MINIMAX_MODELL = os.environ.get("MINIMAX_MODELL", "") +MINIMAX_URL = os.environ.get("MINIMAX_URL", "") + + +def _zahl(name: str, standard: int) -> int: + try: + wert = int(os.environ.get(name, standard)) + except ValueError: + return standard + return max(1, wert) + + +PRUEFUNG_PARALLEL = _zahl("PRUEFUNG_PARALLEL", 10) diff --git a/backend/pruefung.py b/backend/pruefung.py new file mode 100644 index 0000000..646651e --- /dev/null +++ b/backend/pruefung.py @@ -0,0 +1,117 @@ +"""Prüft die Karten eines Projekts gegen den Stil-Katalog. + +Ein Lauf je Projekt, Zustand nur im Speicher — er überlebt keinen Serverneustart. +Je Karte eine Anfrage, höchstens PRUEFUNG_PARALLEL gleichzeitig; Probleme erscheinen, +sobald die jeweilige Karte geprüft ist. +""" + +import asyncio +import json +import re + +import agents +import config +import features + +# projektname -> {"laeuft": bool, "fertig": int, "gesamt": int, +# "probleme": [...], "meldung": str} +_laeufe = {} + +SYSTEM = """Du prüfst Karten einer Feature-Verwaltung gegen einen Stil-Katalog. + +{stil} + +Du bekommst eine Karte als Markdown. Melde jede Abweichung vom Katalog. +Antworte ausschließlich mit JSON: +{{"probleme": [{{"feld": "...", "funktion": "...", "original": "...", + "problem": "...", "vorschlag": "..."}}]}} + +- feld: titel, ziel, regeln oder testen +- funktion: Titel der Funktion, zu der die Stelle gehört; "" für die Karte selbst +- original: der fehlerhafte Text wortwörtlich; bei regeln und testen genau eine Zeile ohne führendes "- " +- problem: die verletzte Vorgabe in höchstens 8 Wörtern +- vorschlag: der korrigierte Text, sonst nichts + +Keine Probleme gefunden: {{"probleme": []}} +Erfinde nichts und melde nur, was der Katalog verlangt.""" + + +def status(projekt: str) -> dict: + lauf = _laeufe.get(projekt) + if not lauf: + return {"laeuft": False, "fertig": 0, "gesamt": 0, "probleme": [], "meldung": ""} + return lauf + + +def _ohne_strich(text: str) -> str: + return re.sub(r"^[-*]\s*", "", (text or "").strip()) + + +def _antwort_lesen(text: str) -> list[dict]: + """Holt das JSON aus der Antwort, auch wenn es in ```-Zäunen steckt. + Listenzeilen kommen mal mit, mal ohne „- " zurück — hier vereinheitlicht, + damit die Anzeige die Stelle wiederfindet.""" + treffer = re.search(r"\{.*\}", text, re.S) + if not treffer: + raise ValueError("Antwort enthält kein JSON") + daten = json.loads(treffer.group(0)) + probleme = [] + for p in daten.get("probleme") or []: + if not isinstance(p, dict) or not p.get("original"): + continue + p["original"] = _ohne_strich(p["original"]) + p["vorschlag"] = _ohne_strich(p.get("vorschlag")) + p["funktion"] = (p.get("funktion") or "").strip() + probleme.append(p) + return probleme + + +async def _pruefe_karte(stil: str, feature: dict) -> list[dict]: + text = await agents.frage(SYSTEM.format(stil=stil), feature["roh"]) + probleme = _antwort_lesen(text) + for p in probleme: + p["feature"] = feature["id"] + return probleme + + +async def _lauf(projekt: str, liste: list[dict]): + lauf = _laeufe[projekt] + stil = config.STIL_DATEI.read_text() + schranke = asyncio.Semaphore(config.PRUEFUNG_PARALLEL) + + async def eine(feature): + async with schranke: + if _laeufe.get(projekt) is not lauf: + return # verworfen (Panel geschlossen) — keine weitere Anfrage + try: + lauf["probleme"].extend(await _pruefe_karte(stil, feature)) + except Exception as e: + lauf["meldung"] = str(e) + lauf["fertig"] += 1 + + try: + await asyncio.gather(*(eine(f) for f in liste)) + finally: + lauf["laeuft"] = False + + +async def starte(projekt: str) -> dict: + """Startet einen Lauf im Hintergrund; ein laufender wird nicht verdoppelt.""" + if status(projekt)["laeuft"]: + raise ValueError("Prüfung läuft bereits") + agents.pruefe_konfiguration() # früher Stopp statt halbem Lauf + liste = features.liste(projekt) + _laeufe[projekt] = { + "laeuft": True, + "fertig": 0, + "gesamt": len(liste), + "probleme": [], + "meldung": "", + } + asyncio.create_task(_lauf(projekt, liste)) + return _laeufe[projekt] + + +def beende(projekt: str): + """Ergebnis verwerfen (Panel schließen).""" + _laeufe.pop(projekt, None) diff --git a/backend/routes.py b/backend/routes.py index e5b47f8..5d8fc14 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, HTTPException import features import projekte +import pruefung router = APIRouter(prefix="/api") @@ -80,6 +81,27 @@ async def feature_speichern(name: str, id: str, body: dict): raise HTTPException(400, str(e)) +@router.post("/projekte/{name}/pruefung") +async def pruefung_starten(name: str): + try: + return await pruefung.starte(name) + except FileNotFoundError as e: + raise HTTPException(404, str(e)) + except ValueError as e: + raise HTTPException(409, str(e)) + + +@router.get("/projekte/{name}/pruefung") +async def pruefung_status(name: str): + return pruefung.status(name) + + +@router.delete("/projekte/{name}/pruefung") +async def pruefung_beenden(name: str): + pruefung.beende(name) + return {"ok": True} + + @router.delete("/projekte/{name}/features/{id}") async def feature_loeschen(name: str, id: str): try: diff --git a/backend/stil.md b/backend/stil.md new file mode 100644 index 0000000..127ea4b --- /dev/null +++ b/backend/stil.md @@ -0,0 +1,68 @@ +# 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/tests/test_pruefung.py b/backend/tests/test_pruefung.py new file mode 100644 index 0000000..6d5757e --- /dev/null +++ b/backend/tests/test_pruefung.py @@ -0,0 +1,136 @@ +import asyncio + +import pytest + +import agents +import features +import projekte +import pruefung + +ANTWORT = """```json +{"probleme": [{"feld": "regeln", "funktion": "Etwas tun", "original": "Zwei Aussagen, und noch eine", + "problem": "Zwei Aussagen je Regel", "vorschlag": "Zwei Aussagen"}]} +```""" + + +@pytest.fixture +def projekt(datenordner): + projekte.anlegen("Test") + features.anlegen("Test", "\nEins\n") + features.anlegen("Test", "\nZwei\n") + return "Test" + + +@pytest.fixture(autouse=True) +def kein_lauf(): + pruefung._laeufe.clear() + yield + pruefung._laeufe.clear() + + +async def warte_bis_fertig(projekt): + for _ in range(50): + if not pruefung.status(projekt)["laeuft"]: + return + await asyncio.sleep(0.01) + raise AssertionError("Prüfung wurde nicht fertig") + + +async def test_lauf_sammelt_probleme(projekt, monkeypatch): + monkeypatch.setattr(agents, "pruefe_konfiguration", lambda: None) + monkeypatch.setattr(agents, "frage", lambda system, inhalt: _antwort()) + await pruefung.starte(projekt) + await warte_bis_fertig(projekt) + + s = pruefung.status(projekt) + assert s["gesamt"] == 2 + assert s["fertig"] == 2 + assert s["meldung"] == "" + assert len(s["probleme"]) == 2 + assert s["probleme"][0]["feature"] in ("eins", "zwei") + assert s["probleme"][0]["vorschlag"] == "Zwei Aussagen" + + +async def _antwort(): + return ANTWORT + + +async def test_ohne_konfiguration_kein_lauf(projekt, monkeypatch): + monkeypatch.setattr(agents.config, "MINIMAX_API_KEY", "") + with pytest.raises(ValueError): + await pruefung.starte(projekt) + assert pruefung.status(projekt)["laeuft"] is False + + +async def test_providerfehler_landet_in_meldung(projekt, monkeypatch): + monkeypatch.setattr(agents, "pruefe_konfiguration", lambda: None) + + async def kaputt(system, inhalt): + raise RuntimeError("Provider antwortet mit HTTP 500") + + monkeypatch.setattr(agents, "frage", kaputt) + await pruefung.starte(projekt) + await warte_bis_fertig(projekt) + assert pruefung.status(projekt)["meldung"] == "Provider antwortet mit HTTP 500" + + +async def test_verworfener_lauf_stoppt(projekt, monkeypatch): + monkeypatch.setattr(agents, "pruefe_konfiguration", lambda: None) + monkeypatch.setattr(agents.config, "PRUEFUNG_PARALLEL", 1) + aufrufe = [] + + async def zaehle(system, inhalt): + aufrufe.append(inhalt) + pruefung.beende("Test") # Panel wird nach der ersten Karte geschlossen + return '{"probleme": []}' + + monkeypatch.setattr(agents, "frage", zaehle) + await pruefung.starte(projekt) + for _ in range(50): + await asyncio.sleep(0.01) + assert len(aufrufe) == 1 # zweite Karte wird nicht mehr angefragt + + +async def test_parallel_begrenzt(projekt, monkeypatch): + monkeypatch.setattr(agents, "pruefe_konfiguration", lambda: None) + monkeypatch.setattr(agents.config, "PRUEFUNG_PARALLEL", 1) + gleichzeitig = 0 + hoechststand = 0 + + async def langsam(system, inhalt): + nonlocal gleichzeitig, hoechststand + gleichzeitig += 1 + hoechststand = max(hoechststand, gleichzeitig) + await asyncio.sleep(0.02) + gleichzeitig -= 1 + return '{"probleme": []}' + + monkeypatch.setattr(agents, "frage", langsam) + await pruefung.starte(projekt) + await warte_bis_fertig(projekt) + assert hoechststand == 1 + assert pruefung.status(projekt)["fertig"] == 2 + + +async def test_status_ohne_lauf(): + assert pruefung.status("Fremd") == { + "laeuft": False, "fertig": 0, "gesamt": 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": ""}] + + +def test_listenstrich_wird_entfernt(): + probleme = pruefung._antwort_lesen( + '{"probleme": [{"original": "- alt", "vorschlag": "- neu", "funktion": " F "}]}') + 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") diff --git a/daten/Planer/darkmode.md b/daten/Planer/darkmode.md index 3e4d4ae..5b412a4 100644 --- a/daten/Planer/darkmode.md +++ b/daten/Planer/darkmode.md @@ -19,4 +19,4 @@ Darkmode umschalten - Zustand bleibt gespeichert - LMB auf Icon in der Sidebar oben -- Modus schaltet weiter: Auto, Tag, Nacht +- Modus je nach vorherigem Zustand Auto, Tag oder Nacht diff --git a/daten/Planer/features.md b/daten/Planer/features.md index 30a410e..1281cb5 100644 --- a/daten/Planer/features.md +++ b/daten/Planer/features.md @@ -9,7 +9,7 @@ Gruppiert Informationen zu ähnlichen Funktionen ---------------------------------------- - Alphabetisch sortiert -- Unter daten/ als md Datei gespeichert +- Unter daten: als md Datei gespeichert - Verlassen speichert - Escape verwirft ---------------------------------------- @@ -37,7 +37,7 @@ Unterscheidet die Kern- und optionalen Features - Verändert die Randfarbe vom Feature - Vollmodus öffnen -- Klick auf Badge zum toggeln +- Klick auf Badge mit LMB Feature entfernen @@ -45,7 +45,7 @@ Feature entfernen - RMB auf Featurecard - Klick auf "Feature entfernen" -- Bestätigen im Modal +- Bestätigen mit LMB im Modal - Feature entfernt Feature erstellen @@ -56,7 +56,7 @@ Feature erstellen - RMB auf Hauptbereich - Klick auf "Feature erstellen" - Im Modal Namen eingeben -- Bestätigen +- Bestätigen mit LMB Regeln anpassen diff --git a/daten/Planer/funktionen.md b/daten/Planer/funktionen.md index aa1785d..8e43b2a 100644 --- a/daten/Planer/funktionen.md +++ b/daten/Planer/funktionen.md @@ -42,7 +42,7 @@ Spezifische Hinweise für die Funktion - Vollmodus öffnen - Klick auf Regeln der Funktion -- Inhalt anpassen +- Regeln angepasst Testen anpassen @@ -52,7 +52,7 @@ Beschreibt die Schritte zum Prüfen der Funktion - Vollmodus öffnen - Klick auf Testen der Funktion -- Inhalt anpassen +- Tests angepasst Titel anpassen @@ -60,7 +60,7 @@ Beschreibt die Funktion so kompakt wie möglich - Vollmodus öffnen - Klick auf Titel der Funktion -- Inhalt anpassen +- Titel angepasst Ziel anpassen @@ -68,4 +68,4 @@ Beschreibt kurz das Kernziel der Funktion - Vollmodus öffnen - Klick auf Ziel der Funktion -- Inhalt anpassen +- Ziel angepasst diff --git a/daten/Planer/karten-pruefen.md b/daten/Planer/karten-pruefen.md new file mode 100644 index 0000000..bb9b3a3 --- /dev/null +++ b/daten/Planer/karten-pruefen.md @@ -0,0 +1,42 @@ + +Karten prüfen +---------------------------------------- + +optional +---------------------------------------- + +Prüft alle Karten auf Stilvorgaben und schlägt Verbesserungen vor +---------------------------------------- + +- Verwendet Minimax M2.7-HS per API +- Alle Inhalte der Features haben globale Layouts und Stile +- API Zugänge stehen in .env +---------------------------------------- + +Änderungsvorschläge anzeigen + +- Annehmen und Ablehnen Buttons oben rechts +- Buttons laden nur die Problemstelle neu + +- Prüfung durchlaufen lassen +- Unter den fehlerhaften Problemen werden Verbesserungsvorschläge angezeigt +- Annehmen und Ablehnen Buttons angezeigt + +Fehlerhafte Cards markieren + +- Fehlerhafte Cards werden in Vollmodus angezeigt + +- Prüfung durchlaufen +- Fehlerhafte Cards werden rot markiert +- In den Cards werden die Probleme rot markiert + +Prüfung ausführen + +- Prüf-Icon in Sidebar Navigation +- + +- LMB auf Icon +- Prüfung wird gestartet +- Rechte Sidebar geöffnet +- Fortschritt unter Icon angezeigt +- Probleme werden live gelistet diff --git a/daten/Planer/karten-schreibstil.md b/daten/Planer/karten-schreibstil.md index 627939d..b1ebe97 100644 --- a/daten/Planer/karten-schreibstil.md +++ b/daten/Planer/karten-schreibstil.md @@ -17,14 +17,14 @@ Regel Schreibstil Spezifische Hinweise zum Titel - Ein kurzer Satz je Regel -- Eine Aussage je Regel +- Eine Aussage je Zeile Testschritt Schreibstil Beschreibt das Prüfen als Ablauf - Ein Schritt je Zeile -- Letzter Schritt ist der Endzustand +- Letzte Zeile beschreibt Endzustand Titel Schreibstil diff --git a/daten/Planer/projekte.md b/daten/Planer/projekte.md index b4cd972..2683fec 100644 --- a/daten/Planer/projekte.md +++ b/daten/Planer/projekte.md @@ -11,7 +11,7 @@ Gruppiert Features für ein Projekt - Alphabetisch sortiert - Aktives Projekt ist hervorgehoben - Verlassen speichert -- Escape verwirft +- Escape bricht ab ---------------------------------------- Projekt anpassen @@ -21,7 +21,7 @@ Projekt anpassen - RMB auf Projektname - Klick auf "Projekt anpassen" - Inhalt anpassen -- Rausklicken +- Außerhalb klicken - Name ist angepasst Projekt aufrufen diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9412423..e772ce0 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,6 +4,8 @@ import { fetchProjekte } from './api.js' 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' const projekte = ref([]) const aktivesProjekt = ref(null) @@ -29,6 +31,9 @@ function umbenannt(alt, neu) { ladeProjekte() } +// Prüfung gehört zum Projekt — beim Wechsel wird sie verworfen +watch(aktivesProjekt, () => { if (pruefungOffen.value) schliessePruefung() }) + // Zuletzt aktives Projekt überlebt das Neuladen watch(aktivesProjekt, name => { if (name) localStorage.setItem('aktivesProjekt', name) @@ -51,7 +56,7 @@ onMounted(async () => { @@ -80,6 +92,7 @@ onMounted(async () => { grid-template-columns: 220px 1fr; min-height: 100vh; } +.raster.pruefung { grid-template-columns: 220px 1fr 260px; } main { padding: 1.2rem 1.5rem; min-width: 0; diff --git a/frontend/src/api.js b/frontend/src/api.js index a936447..d2b7a33 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -40,3 +40,9 @@ export const featureSpeichern = (projekt, id, roh) => req(`/projekte/${p(projekt)}/features/${p(id)}`, { method: 'PUT', body: { roh } }) export const featureLoeschen = (projekt, id) => req(`/projekte/${p(projekt)}/features/${p(id)}`, { method: 'DELETE' }) + +export const pruefungStarten = (projekt) => + req(`/projekte/${p(projekt)}/pruefung`, { method: 'POST' }) +export const fetchPruefung = (projekt) => req(`/projekte/${p(projekt)}/pruefung`) +export const pruefungBeenden = (projekt) => + req(`/projekte/${p(projekt)}/pruefung`, { method: 'DELETE' }) diff --git a/frontend/src/components/FeatureKarte.vue b/frontend/src/components/FeatureKarte.vue index 8caf3c0..2a85be1 100644 --- a/frontend/src/components/FeatureKarte.vue +++ b/frontend/src/components/FeatureKarte.vue @@ -3,12 +3,15 @@ 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 ModalBox from './ModalBox.vue' +import ProblemHinweis from './ProblemHinweis.vue' const props = defineProps({ projekt: { type: String, required: true }, feature: { type: Object, required: true }, vollmodus: { type: Boolean, default: false }, // frisch angelegt: direkt im Vollmodus öffnen + probleme: { type: Array, default: () => [] }, }) const emit = defineEmits(['aktualisiert', 'entfernen']) @@ -173,12 +176,59 @@ function punkte(text) { function wechsleAnsicht() { gross.value = !gross.value } + +// Prüfung: fehlerhafte Karten öffnen sich, damit die Stellen sichtbar sind +watch(() => props.probleme.length, n => { if (n) gross.value = true }, { immediate: true }) + +function problemeAn(feld, funktion = '', original = null) { + return props.probleme.filter(p => + p.feld === feld && + (p.funktion || '') === funktion && + (original === null || p.original === original)) +} + +// Vorschlag übernehmen: Titel und Ziel ganz ersetzen, bei Regeln und Testen die Zeile +function anwenden(p) { + const ziel = p.funktion ? s.value.funktionen.find(f => f.text === p.funktion) : s.value + if (!ziel) return + if (p.feld === 'titel') { + if (p.funktion) ziel.text = p.vorschlag + else ziel.titel = p.vorschlag + } else if (p.feld === 'ziel') { + ziel.ziel = p.vorschlag + } 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') + } +} + +async function annehmen(p) { + anwenden(p) + erledige(p) + await speichern() +} + +function ablehnen(p) { + erledige(p) +} + +async function alleAnnehmen() { + props.probleme.forEach(p => { anwenden(p); erledige(p) }) + await speichern() +} + +function alleAblehnen() { + props.probleme.forEach(erledige) +}