update
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal file
@@ -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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
.env
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -7,6 +7,19 @@ Rein manuelle Pflege: Sidebar links = Projekte, Hauptbereich = Feature-Cards.
|
|||||||
- `make build && make web` — Produktion: http://localhost:8200
|
- `make build && make web` — Produktion: http://localhost:8200
|
||||||
- `make test` — Backend-Tests
|
- `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
|
## Datenablage
|
||||||
|
|
||||||
Markdown-Dateien unter `daten/<projekt>/<feature>.md` — Projekt = Ordner, die Datei ist
|
Markdown-Dateien unter `daten/<projekt>/<feature>.md` — Projekt = Ordner, die Datei ist
|
||||||
|
|||||||
54
backend/agents.py
Normal file
54
backend/agents.py
Normal file
@@ -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
|
||||||
@@ -1,4 +1,37 @@
|
|||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
DATEN_DIR = PROJECT_ROOT / "daten"
|
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)
|
||||||
|
|||||||
117
backend/pruefung.py
Normal file
117
backend/pruefung.py
Normal file
@@ -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)
|
||||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, HTTPException
|
|||||||
|
|
||||||
import features
|
import features
|
||||||
import projekte
|
import projekte
|
||||||
|
import pruefung
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
@@ -80,6 +81,27 @@ async def feature_speichern(name: str, id: str, body: dict):
|
|||||||
raise HTTPException(400, str(e))
|
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}")
|
@router.delete("/projekte/{name}/features/{id}")
|
||||||
async def feature_loeschen(name: str, id: str):
|
async def feature_loeschen(name: str, id: str):
|
||||||
try:
|
try:
|
||||||
|
|||||||
68
backend/stil.md
Normal file
68
backend/stil.md
Normal file
@@ -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 `<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)
|
||||||
136
backend/tests/test_pruefung.py
Normal file
136
backend/tests/test_pruefung.py
Normal file
@@ -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", "<!-- titel -->\nEins\n")
|
||||||
|
features.anlegen("Test", "<!-- titel -->\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")
|
||||||
@@ -19,4 +19,4 @@ Darkmode umschalten
|
|||||||
- Zustand bleibt gespeichert
|
- Zustand bleibt gespeichert
|
||||||
<!-- testen -->
|
<!-- testen -->
|
||||||
- LMB auf Icon in der Sidebar oben
|
- LMB auf Icon in der Sidebar oben
|
||||||
- Modus schaltet weiter: Auto, Tag, Nacht
|
- Modus je nach vorherigem Zustand Auto, Tag oder Nacht
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Gruppiert Informationen zu ähnlichen Funktionen
|
|||||||
----------------------------------------
|
----------------------------------------
|
||||||
<!-- regeln -->
|
<!-- regeln -->
|
||||||
- Alphabetisch sortiert
|
- Alphabetisch sortiert
|
||||||
- Unter daten/ als md Datei gespeichert
|
- Unter daten: als md Datei gespeichert
|
||||||
- Verlassen speichert
|
- Verlassen speichert
|
||||||
- Escape verwirft
|
- Escape verwirft
|
||||||
----------------------------------------
|
----------------------------------------
|
||||||
@@ -37,7 +37,7 @@ Unterscheidet die Kern- und optionalen Features
|
|||||||
- Verändert die Randfarbe vom Feature
|
- Verändert die Randfarbe vom Feature
|
||||||
<!-- testen -->
|
<!-- testen -->
|
||||||
- Vollmodus öffnen
|
- Vollmodus öffnen
|
||||||
- Klick auf Badge zum toggeln
|
- Klick auf Badge mit LMB
|
||||||
<!-- funktion -->
|
<!-- funktion -->
|
||||||
Feature entfernen
|
Feature entfernen
|
||||||
<!-- regeln -->
|
<!-- regeln -->
|
||||||
@@ -45,7 +45,7 @@ Feature entfernen
|
|||||||
<!-- testen -->
|
<!-- testen -->
|
||||||
- RMB auf Featurecard
|
- RMB auf Featurecard
|
||||||
- Klick auf "Feature entfernen"
|
- Klick auf "Feature entfernen"
|
||||||
- Bestätigen im Modal
|
- Bestätigen mit LMB im Modal
|
||||||
- Feature entfernt
|
- Feature entfernt
|
||||||
<!-- funktion -->
|
<!-- funktion -->
|
||||||
Feature erstellen
|
Feature erstellen
|
||||||
@@ -56,7 +56,7 @@ Feature erstellen
|
|||||||
- RMB auf Hauptbereich
|
- RMB auf Hauptbereich
|
||||||
- Klick auf "Feature erstellen"
|
- Klick auf "Feature erstellen"
|
||||||
- Im Modal Namen eingeben
|
- Im Modal Namen eingeben
|
||||||
- Bestätigen
|
- Bestätigen mit LMB
|
||||||
<!-- funktion -->
|
<!-- funktion -->
|
||||||
Regeln anpassen
|
Regeln anpassen
|
||||||
<!-- ziel -->
|
<!-- ziel -->
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ Spezifische Hinweise für die Funktion
|
|||||||
<!-- testen -->
|
<!-- testen -->
|
||||||
- Vollmodus öffnen
|
- Vollmodus öffnen
|
||||||
- Klick auf Regeln der Funktion
|
- Klick auf Regeln der Funktion
|
||||||
- Inhalt anpassen
|
- Regeln angepasst
|
||||||
<!-- funktion -->
|
<!-- funktion -->
|
||||||
Testen anpassen
|
Testen anpassen
|
||||||
<!-- ziel -->
|
<!-- ziel -->
|
||||||
@@ -52,7 +52,7 @@ Beschreibt die Schritte zum Prüfen der Funktion
|
|||||||
<!-- testen -->
|
<!-- testen -->
|
||||||
- Vollmodus öffnen
|
- Vollmodus öffnen
|
||||||
- Klick auf Testen der Funktion
|
- Klick auf Testen der Funktion
|
||||||
- Inhalt anpassen
|
- Tests angepasst
|
||||||
<!-- funktion -->
|
<!-- funktion -->
|
||||||
Titel anpassen
|
Titel anpassen
|
||||||
<!-- ziel -->
|
<!-- ziel -->
|
||||||
@@ -60,7 +60,7 @@ Beschreibt die Funktion so kompakt wie möglich
|
|||||||
<!-- testen -->
|
<!-- testen -->
|
||||||
- Vollmodus öffnen
|
- Vollmodus öffnen
|
||||||
- Klick auf Titel der Funktion
|
- Klick auf Titel der Funktion
|
||||||
- Inhalt anpassen
|
- Titel angepasst
|
||||||
<!-- funktion -->
|
<!-- funktion -->
|
||||||
Ziel anpassen
|
Ziel anpassen
|
||||||
<!-- ziel -->
|
<!-- ziel -->
|
||||||
@@ -68,4 +68,4 @@ Beschreibt kurz das Kernziel der Funktion
|
|||||||
<!-- testen -->
|
<!-- testen -->
|
||||||
- Vollmodus öffnen
|
- Vollmodus öffnen
|
||||||
- Klick auf Ziel der Funktion
|
- Klick auf Ziel der Funktion
|
||||||
- Inhalt anpassen
|
- Ziel angepasst
|
||||||
|
|||||||
42
daten/Planer/karten-pruefen.md
Normal file
42
daten/Planer/karten-pruefen.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!-- titel -->
|
||||||
|
Karten prüfen
|
||||||
|
----------------------------------------
|
||||||
|
<!-- art -->
|
||||||
|
optional
|
||||||
|
----------------------------------------
|
||||||
|
<!-- ziel -->
|
||||||
|
Prüft alle Karten auf Stilvorgaben und schlägt Verbesserungen vor
|
||||||
|
----------------------------------------
|
||||||
|
<!-- regeln -->
|
||||||
|
- Verwendet Minimax M2.7-HS per API
|
||||||
|
- Alle Inhalte der Features haben globale Layouts und Stile
|
||||||
|
- API Zugänge stehen in .env
|
||||||
|
----------------------------------------
|
||||||
|
<!-- funktion -->
|
||||||
|
Änderungsvorschläge anzeigen
|
||||||
|
<!-- regeln -->
|
||||||
|
- Annehmen und Ablehnen Buttons oben rechts
|
||||||
|
- Buttons laden nur die Problemstelle neu
|
||||||
|
<!-- testen -->
|
||||||
|
- Prüfung durchlaufen lassen
|
||||||
|
- Unter den fehlerhaften Problemen werden Verbesserungsvorschläge angezeigt
|
||||||
|
- Annehmen und Ablehnen Buttons angezeigt
|
||||||
|
<!-- funktion -->
|
||||||
|
Fehlerhafte Cards markieren
|
||||||
|
<!-- regeln -->
|
||||||
|
- Fehlerhafte Cards werden in Vollmodus angezeigt
|
||||||
|
<!-- testen -->
|
||||||
|
- Prüfung durchlaufen
|
||||||
|
- Fehlerhafte Cards werden rot markiert
|
||||||
|
- In den Cards werden die Probleme rot markiert
|
||||||
|
<!-- funktion -->
|
||||||
|
Prüfung ausführen
|
||||||
|
<!-- regeln -->
|
||||||
|
- Prüf-Icon in Sidebar Navigation
|
||||||
|
-
|
||||||
|
<!-- testen -->
|
||||||
|
- LMB auf Icon
|
||||||
|
- Prüfung wird gestartet
|
||||||
|
- Rechte Sidebar geöffnet
|
||||||
|
- Fortschritt unter Icon angezeigt
|
||||||
|
- Probleme werden live gelistet
|
||||||
@@ -17,14 +17,14 @@ Regel Schreibstil
|
|||||||
Spezifische Hinweise zum Titel
|
Spezifische Hinweise zum Titel
|
||||||
<!-- regeln -->
|
<!-- regeln -->
|
||||||
- Ein kurzer Satz je Regel
|
- Ein kurzer Satz je Regel
|
||||||
- Eine Aussage je Regel
|
- Eine Aussage je Zeile
|
||||||
<!-- funktion -->
|
<!-- funktion -->
|
||||||
Testschritt Schreibstil
|
Testschritt Schreibstil
|
||||||
<!-- ziel -->
|
<!-- ziel -->
|
||||||
Beschreibt das Prüfen als Ablauf
|
Beschreibt das Prüfen als Ablauf
|
||||||
<!-- regeln -->
|
<!-- regeln -->
|
||||||
- Ein Schritt je Zeile
|
- Ein Schritt je Zeile
|
||||||
- Letzter Schritt ist der Endzustand
|
- Letzte Zeile beschreibt Endzustand
|
||||||
<!-- funktion -->
|
<!-- funktion -->
|
||||||
Titel Schreibstil
|
Titel Schreibstil
|
||||||
<!-- ziel -->
|
<!-- ziel -->
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Gruppiert Features für ein Projekt
|
|||||||
- Alphabetisch sortiert
|
- Alphabetisch sortiert
|
||||||
- Aktives Projekt ist hervorgehoben
|
- Aktives Projekt ist hervorgehoben
|
||||||
- Verlassen speichert
|
- Verlassen speichert
|
||||||
- Escape verwirft
|
- Escape bricht ab
|
||||||
----------------------------------------
|
----------------------------------------
|
||||||
<!-- funktion -->
|
<!-- funktion -->
|
||||||
Projekt anpassen
|
Projekt anpassen
|
||||||
@@ -21,7 +21,7 @@ Projekt anpassen
|
|||||||
- RMB auf Projektname
|
- RMB auf Projektname
|
||||||
- Klick auf "Projekt anpassen"
|
- Klick auf "Projekt anpassen"
|
||||||
- Inhalt anpassen
|
- Inhalt anpassen
|
||||||
- Rausklicken
|
- Außerhalb klicken
|
||||||
- Name ist angepasst
|
- Name ist angepasst
|
||||||
<!-- funktion -->
|
<!-- funktion -->
|
||||||
Projekt aufrufen
|
Projekt aufrufen
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { fetchProjekte } from './api.js'
|
|||||||
import SeitenLeiste from './components/SeitenLeiste.vue'
|
import SeitenLeiste from './components/SeitenLeiste.vue'
|
||||||
import FeatureListe from './components/FeatureListe.vue'
|
import FeatureListe from './components/FeatureListe.vue'
|
||||||
import KontextMenue from './components/KontextMenue.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 projekte = ref([])
|
||||||
const aktivesProjekt = ref(null)
|
const aktivesProjekt = ref(null)
|
||||||
@@ -29,6 +31,9 @@ function umbenannt(alt, neu) {
|
|||||||
ladeProjekte()
|
ladeProjekte()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prüfung gehört zum Projekt — beim Wechsel wird sie verworfen
|
||||||
|
watch(aktivesProjekt, () => { if (pruefungOffen.value) schliessePruefung() })
|
||||||
|
|
||||||
// Zuletzt aktives Projekt überlebt das Neuladen
|
// Zuletzt aktives Projekt überlebt das Neuladen
|
||||||
watch(aktivesProjekt, name => {
|
watch(aktivesProjekt, name => {
|
||||||
if (name) localStorage.setItem('aktivesProjekt', name)
|
if (name) localStorage.setItem('aktivesProjekt', name)
|
||||||
@@ -51,7 +56,7 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="raster">
|
<div class="raster" :class="{ pruefung: pruefungOffen }">
|
||||||
<SeitenLeiste
|
<SeitenLeiste
|
||||||
:projekte="projekte"
|
:projekte="projekte"
|
||||||
:aktiv="aktivesProjekt"
|
:aktiv="aktivesProjekt"
|
||||||
@@ -59,6 +64,12 @@ onMounted(async () => {
|
|||||||
@geaendert="ladeProjekte"
|
@geaendert="ladeProjekte"
|
||||||
@umbenannt="umbenannt"
|
@umbenannt="umbenannt"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
v-if="aktivesProjekt"
|
||||||
|
class="icon-knopf"
|
||||||
|
title="Karten prüfen"
|
||||||
|
@click="startePruefung(aktivesProjekt)"
|
||||||
|
>⌾</button>
|
||||||
<button class="icon-knopf" :title="dunkel ? 'Hellmodus' : 'Dunkelmodus'" @click="wechsleDarkMode">
|
<button class="icon-knopf" :title="dunkel ? 'Hellmodus' : 'Dunkelmodus'" @click="wechsleDarkMode">
|
||||||
{{ dunkel ? '☀' : '☾' }}
|
{{ dunkel ? '☀' : '☾' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -70,6 +81,7 @@ onMounted(async () => {
|
|||||||
<p v-else-if="!meldung" class="leer">Projekt wählen oder anlegen.</p>
|
<p v-else-if="!meldung" class="leer">Projekt wählen oder anlegen.</p>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<PruefPanel v-if="pruefungOffen" />
|
||||||
<KontextMenue />
|
<KontextMenue />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -80,6 +92,7 @@ onMounted(async () => {
|
|||||||
grid-template-columns: 220px 1fr;
|
grid-template-columns: 220px 1fr;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
.raster.pruefung { grid-template-columns: 220px 1fr 260px; }
|
||||||
main {
|
main {
|
||||||
padding: 1.2rem 1.5rem;
|
padding: 1.2rem 1.5rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -40,3 +40,9 @@ export const featureSpeichern = (projekt, id, roh) =>
|
|||||||
req(`/projekte/${p(projekt)}/features/${p(id)}`, { method: 'PUT', body: { roh } })
|
req(`/projekte/${p(projekt)}/features/${p(id)}`, { method: 'PUT', body: { roh } })
|
||||||
export const featureLoeschen = (projekt, id) =>
|
export const featureLoeschen = (projekt, id) =>
|
||||||
req(`/projekte/${p(projekt)}/features/${p(id)}`, { method: 'DELETE' })
|
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' })
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import { nextTick, ref, watch } from 'vue'
|
|||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import { featureSpeichern } from '../api.js'
|
import { featureSpeichern } from '../api.js'
|
||||||
import { oeffneMenue } from '../composables/kontextmenue.js'
|
import { oeffneMenue } from '../composables/kontextmenue.js'
|
||||||
|
import { erledige } from '../composables/pruefung.js'
|
||||||
import ModalBox from './ModalBox.vue'
|
import ModalBox from './ModalBox.vue'
|
||||||
|
import ProblemHinweis from './ProblemHinweis.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
projekt: { type: String, required: true },
|
projekt: { type: String, required: true },
|
||||||
feature: { type: Object, required: true },
|
feature: { type: Object, required: true },
|
||||||
vollmodus: { type: Boolean, default: false }, // frisch angelegt: direkt im Vollmodus öffnen
|
vollmodus: { type: Boolean, default: false }, // frisch angelegt: direkt im Vollmodus öffnen
|
||||||
|
probleme: { type: Array, default: () => [] },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['aktualisiert', 'entfernen'])
|
const emit = defineEmits(['aktualisiert', 'entfernen'])
|
||||||
|
|
||||||
@@ -173,12 +176,59 @@ function punkte(text) {
|
|||||||
function wechsleAnsicht() {
|
function wechsleAnsicht() {
|
||||||
gross.value = !gross.value
|
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)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article
|
<article
|
||||||
class="karte"
|
class="karte"
|
||||||
:class="[s.art, { gross }]"
|
:class="[s.art, { gross, fehlerhaft: probleme.length }]"
|
||||||
@mousedown.middle.prevent
|
@mousedown.middle.prevent
|
||||||
@auxclick.middle.prevent="wechsleAnsicht"
|
@auxclick.middle.prevent="wechsleAnsicht"
|
||||||
@contextmenu.prevent.stop="kartenMenue"
|
@contextmenu.prevent.stop="kartenMenue"
|
||||||
@@ -207,7 +257,12 @@ function wechsleAnsicht() {
|
|||||||
:title="gross ? 'Kompaktmodus' : 'Vollmodus'"
|
:title="gross ? 'Kompaktmodus' : 'Vollmodus'"
|
||||||
@click="wechsleAnsicht"
|
@click="wechsleAnsicht"
|
||||||
>👁</button>
|
>👁</button>
|
||||||
|
<span v-if="probleme.length" class="alle">
|
||||||
|
<button class="icon-knopf" title="Alle annehmen" @click="alleAnnehmen">✓</button>
|
||||||
|
<button class="icon-knopf rot" title="Alle ablehnen" @click="alleAblehnen">✕</button>
|
||||||
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
<ProblemHinweis :probleme="problemeAn('titel')" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||||
|
|
||||||
<section :class="{ leer: !s.ziel && !istAktiv('ziel') }">
|
<section :class="{ leer: !s.ziel && !istAktiv('ziel') }">
|
||||||
<p class="gruppe">Ziel</p>
|
<p class="gruppe">Ziel</p>
|
||||||
@@ -221,6 +276,7 @@ function wechsleAnsicht() {
|
|||||||
/>
|
/>
|
||||||
<div v-else-if="s.ziel" class="inhalt klickbar" v-html="md(s.ziel)" @click="starte('ziel')" />
|
<div v-else-if="s.ziel" class="inhalt klickbar" v-html="md(s.ziel)" @click="starte('ziel')" />
|
||||||
<p v-else-if="gross" class="hinweis" @click="starte('ziel')">Ziel ergänzen…</p>
|
<p v-else-if="gross" class="hinweis" @click="starte('ziel')">Ziel ergänzen…</p>
|
||||||
|
<ProblemHinweis :probleme="problemeAn('ziel')" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Karten-Regeln nur in der ausführlichen Ansicht -->
|
<!-- Karten-Regeln nur in der ausführlichen Ansicht -->
|
||||||
@@ -235,7 +291,14 @@ function wechsleAnsicht() {
|
|||||||
@keydown.esc="abbrechen"
|
@keydown.esc="abbrechen"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="s.regeln" class="klickbar" @click="starte('regeln')">
|
<div v-else-if="s.regeln" class="klickbar" @click="starte('regeln')">
|
||||||
<div v-for="(r, k) in punkte(s.regeln)" :key="k" class="regel">{{ r }}</div>
|
<template v-for="(r, k) in punkte(s.regeln)" :key="k">
|
||||||
|
<div class="regel" :class="{ fehler: problemeAn('regeln', '', r).length }">{{ r }}</div>
|
||||||
|
<ProblemHinweis
|
||||||
|
:probleme="problemeAn('regeln', '', r)"
|
||||||
|
@annehmen="annehmen"
|
||||||
|
@ablehnen="ablehnen"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<p v-else-if="gross" class="hinweis" @click="starte('regeln')">Regeln ergänzen…</p>
|
<p v-else-if="gross" class="hinweis" @click="starte('regeln')">Regeln ergänzen…</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -260,8 +323,14 @@ function wechsleAnsicht() {
|
|||||||
@keyup.enter="$event.target.blur()"
|
@keyup.enter="$event.target.blur()"
|
||||||
@keydown.esc="abbrechen"
|
@keydown.esc="abbrechen"
|
||||||
>
|
>
|
||||||
<div v-else class="funktionstitel klickbar" @click="starte('funktion', i)">{{ f.text }}</div>
|
<div
|
||||||
|
v-else
|
||||||
|
class="funktionstitel klickbar"
|
||||||
|
:class="{ fehler: problemeAn('titel', f.text).length }"
|
||||||
|
@click="starte('funktion', i)"
|
||||||
|
>{{ f.text }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ProblemHinweis :probleme="problemeAn('titel', f.text)" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||||
|
|
||||||
<!-- Ziel, Regeln und Testen nur im Vollmodus -->
|
<!-- Ziel, Regeln und Testen nur im Vollmodus -->
|
||||||
<div class="details">
|
<div class="details">
|
||||||
@@ -277,6 +346,7 @@ function wechsleAnsicht() {
|
|||||||
@keydown.esc="abbrechen"
|
@keydown.esc="abbrechen"
|
||||||
/>
|
/>
|
||||||
<div v-else class="inhalt klickbar" v-html="md(f.ziel)" @click="starte('ziel', i)" />
|
<div v-else class="inhalt klickbar" v-html="md(f.ziel)" @click="starte('ziel', i)" />
|
||||||
|
<ProblemHinweis :probleme="problemeAn('ziel', f.text)" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||||
</div>
|
</div>
|
||||||
<button v-if="!f.ziel && !istAktiv('ziel', i)" class="hinzu" @click="starte('ziel', i)">+ Ziel</button>
|
<button v-if="!f.ziel && !istAktiv('ziel', i)" class="hinzu" @click="starte('ziel', i)">+ Ziel</button>
|
||||||
|
|
||||||
@@ -292,7 +362,14 @@ function wechsleAnsicht() {
|
|||||||
@keydown.esc="abbrechen"
|
@keydown.esc="abbrechen"
|
||||||
/>
|
/>
|
||||||
<div v-else class="klickbar" @click="starte('regeln', i)">
|
<div v-else class="klickbar" @click="starte('regeln', i)">
|
||||||
<div v-for="(r, k) in punkte(f.regeln)" :key="k" class="regel">{{ r }}</div>
|
<template v-for="(r, k) in punkte(f.regeln)" :key="k">
|
||||||
|
<div class="regel" :class="{ fehler: problemeAn('regeln', f.text, r).length }">{{ r }}</div>
|
||||||
|
<ProblemHinweis
|
||||||
|
:probleme="problemeAn('regeln', f.text, r)"
|
||||||
|
@annehmen="annehmen"
|
||||||
|
@ablehnen="ablehnen"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="!f.regeln && !istAktiv('regeln', i)" class="hinzu" @click="starte('regeln', i)">+ Regeln</button>
|
<button v-if="!f.regeln && !istAktiv('regeln', i)" class="hinzu" @click="starte('regeln', i)">+ Regeln</button>
|
||||||
@@ -309,9 +386,16 @@ function wechsleAnsicht() {
|
|||||||
@keydown.esc="abbrechen"
|
@keydown.esc="abbrechen"
|
||||||
/>
|
/>
|
||||||
<div v-else class="klickbar" @click="starte('testen', i)">
|
<div v-else class="klickbar" @click="starte('testen', i)">
|
||||||
<div v-for="(schritt, k) in punkte(f.testen)" :key="k" class="schritt">
|
<template v-for="(schritt, k) in punkte(f.testen)" :key="k">
|
||||||
|
<div class="schritt" :class="{ fehler: problemeAn('testen', f.text, schritt).length }">
|
||||||
<span class="nr">{{ k + 1 }}.</span>{{ schritt }}
|
<span class="nr">{{ k + 1 }}.</span>{{ schritt }}
|
||||||
</div>
|
</div>
|
||||||
|
<ProblemHinweis
|
||||||
|
:probleme="problemeAn('testen', f.text, schritt)"
|
||||||
|
@annehmen="annehmen"
|
||||||
|
@ablehnen="ablehnen"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -344,6 +428,10 @@ function wechsleAnsicht() {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.karte.kern { border-color: var(--akzent); }
|
.karte.kern { border-color: var(--akzent); }
|
||||||
.karte.optional { border-color: var(--gruen); }
|
.karte.optional { border-color: var(--gruen); }
|
||||||
|
/* Prüfung: fehlerhafte Karte und Stelle rot */
|
||||||
|
.karte.fehlerhaft { border-color: var(--rot); }
|
||||||
|
.fehler { color: var(--rot); }
|
||||||
|
.alle { white-space: nowrap; }
|
||||||
|
|
||||||
/* Vollmodus: volle Breite, Funktionen als Boxen in responsiven Spalten,
|
/* Vollmodus: volle Breite, Funktionen als Boxen in responsiven Spalten,
|
||||||
alle Details offen */
|
alle Details offen */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { fetchFeatures, featureAnlegen, featureLoeschen } from '../api.js'
|
import { fetchFeatures, featureAnlegen, featureLoeschen } from '../api.js'
|
||||||
import { oeffneMenue } from '../composables/kontextmenue.js'
|
import { oeffneMenue } from '../composables/kontextmenue.js'
|
||||||
|
import { problemeFuer } from '../composables/pruefung.js'
|
||||||
import FeatureKarte from './FeatureKarte.vue'
|
import FeatureKarte from './FeatureKarte.vue'
|
||||||
import ModalBox from './ModalBox.vue'
|
import ModalBox from './ModalBox.vue'
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ watch(() => props.projekt, laden, { immediate: true })
|
|||||||
:projekt="projekt"
|
:projekt="projekt"
|
||||||
:feature="f"
|
:feature="f"
|
||||||
:vollmodus="f.key === neuKey"
|
:vollmodus="f.key === neuKey"
|
||||||
|
:probleme="problemeFuer(f.id)"
|
||||||
@aktualisiert="aktualisiert"
|
@aktualisiert="aktualisiert"
|
||||||
@entfernen="oeffneModal({ art: 'entfernen', feature: f })"
|
@entfernen="oeffneModal({ art: 'entfernen', feature: f })"
|
||||||
/>
|
/>
|
||||||
|
|||||||
52
frontend/src/components/ProblemHinweis.vue
Normal file
52
frontend/src/components/ProblemHinweis.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
probleme: { type: Array, required: true },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['annehmen', 'ablehnen'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-for="(p, i) in probleme" :key="i" class="hinweis-block" @click.stop>
|
||||||
|
<div class="kopf">
|
||||||
|
<p class="problem">{{ p.problem }}</p>
|
||||||
|
<span class="knoepfe">
|
||||||
|
<button class="icon-knopf" title="Annehmen" @click="emit('annehmen', p)">✓</button>
|
||||||
|
<button class="icon-knopf rot" title="Ablehnen" @click="emit('ablehnen', p)">✕</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="vorschlag">{{ p.vorschlag }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hinweis-block {
|
||||||
|
border-left: 2px solid var(--rot);
|
||||||
|
background: var(--rot-bg);
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
padding: 0.25rem 0.5rem 0.35rem;
|
||||||
|
margin: 0.15rem 0 0.3rem;
|
||||||
|
min-width: 0;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.kopf {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.problem {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--rot);
|
||||||
|
}
|
||||||
|
.knoepfe { white-space: nowrap; }
|
||||||
|
.vorschlag {
|
||||||
|
margin: 0.1rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
72
frontend/src/components/PruefPanel.vue
Normal file
72
frontend/src/components/PruefPanel.vue
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<script setup>
|
||||||
|
import { laeuft, prozent, fertig, gesamt, meldung, probleme, schliesse } from '../composables/pruefung.js'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="panel">
|
||||||
|
<header>
|
||||||
|
<p class="gruppe">Prüfung</p>
|
||||||
|
<button class="icon-knopf" title="Schließen" @click="schliesse">✕</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="fortschritt">
|
||||||
|
<template v-if="laeuft">{{ prozent }} % geprüft</template>
|
||||||
|
<template v-else-if="gesamt">{{ gesamt }} Karten geprüft</template>
|
||||||
|
<template v-else>Keine Karten</template>
|
||||||
|
<span v-if="laeuft" class="dezent"> ({{ fertig }}/{{ gesamt }})</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="meldung" class="meldung">{{ meldung }}</p>
|
||||||
|
|
||||||
|
<p v-if="!probleme.length && !laeuft && !meldung" class="dezent">Keine Probleme gefunden.</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li v-for="(p, i) in probleme" :key="i">
|
||||||
|
<span class="stelle">{{ p.funktion || p.feature }} · {{ p.feld }}</span>
|
||||||
|
<span class="problem">{{ p.problem }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border-left: 1px solid var(--rand);
|
||||||
|
padding: 1rem 0.8rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.gruppe {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--dezent);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.fortschritt { margin: 0; font-size: 0.9rem; }
|
||||||
|
.dezent { color: var(--dezent); font-size: 0.85rem; }
|
||||||
|
ul { list-style: none; margin: 0.3rem 0 0; padding: 0; }
|
||||||
|
li {
|
||||||
|
border-top: 1px solid var(--rand);
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
.stelle {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--akzent);
|
||||||
|
}
|
||||||
|
.problem { font-size: 0.85rem; }
|
||||||
|
</style>
|
||||||
89
frontend/src/composables/pruefung.js
Normal file
89
frontend/src/composables/pruefung.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { pruefungStarten, fetchPruefung, pruefungBeenden } from '../api.js'
|
||||||
|
|
||||||
|
// Ein Prüflauf für das aktive Projekt; Zustand global, damit Sidebar,
|
||||||
|
// Panel und Karten dieselben Probleme sehen.
|
||||||
|
export const offen = ref(false)
|
||||||
|
export const laeuft = ref(false)
|
||||||
|
export const fertig = ref(0)
|
||||||
|
export const gesamt = ref(0)
|
||||||
|
export const meldung = ref('')
|
||||||
|
export const probleme = ref([])
|
||||||
|
|
||||||
|
const erledigt = new Set() // angenommene oder abgelehnte Probleme kommen nicht zurück
|
||||||
|
let projektName = null
|
||||||
|
let uhr = null
|
||||||
|
|
||||||
|
export const prozent = computed(() =>
|
||||||
|
gesamt.value ? Math.round((fertig.value / gesamt.value) * 100) : 0)
|
||||||
|
|
||||||
|
export function schluessel(p) {
|
||||||
|
return `${p.feature}|${p.feld}|${p.funktion}|${p.original}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function problemeFuer(featureId) {
|
||||||
|
return probleme.value.filter(p => p.feature === featureId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function uebernehmen(stand) {
|
||||||
|
laeuft.value = stand.laeuft
|
||||||
|
fertig.value = stand.fertig
|
||||||
|
gesamt.value = stand.gesamt
|
||||||
|
meldung.value = stand.meldung
|
||||||
|
probleme.value = stand.probleme.filter(p => !erledigt.has(schluessel(p)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abfragen() {
|
||||||
|
try {
|
||||||
|
uebernehmen(await fetchPruefung(projektName))
|
||||||
|
} catch (e) {
|
||||||
|
meldung.value = e.message
|
||||||
|
laeuft.value = false
|
||||||
|
}
|
||||||
|
if (!laeuft.value) stoppeUhr()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stoppeUhr() {
|
||||||
|
clearInterval(uhr)
|
||||||
|
uhr = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function starte(projekt) {
|
||||||
|
projektName = projekt
|
||||||
|
offen.value = true
|
||||||
|
meldung.value = ''
|
||||||
|
erledigt.clear()
|
||||||
|
probleme.value = []
|
||||||
|
try {
|
||||||
|
uebernehmen(await pruefungStarten(projekt))
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status !== 409) { // 409 = Lauf läuft schon; dann daran anhängen
|
||||||
|
meldung.value = e.message
|
||||||
|
laeuft.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
uebernehmen(await fetchPruefung(projekt))
|
||||||
|
} catch (f) {
|
||||||
|
meldung.value = f.message
|
||||||
|
laeuft.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stoppeUhr()
|
||||||
|
uhr = setInterval(abfragen, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Angenommenes oder abgelehntes Problem verschwindet, die übrigen bleiben stehen
|
||||||
|
export function erledige(problem) {
|
||||||
|
erledigt.add(schluessel(problem))
|
||||||
|
probleme.value = probleme.value.filter(p => schluessel(p) !== schluessel(problem))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function schliesse() {
|
||||||
|
stoppeUhr()
|
||||||
|
offen.value = false
|
||||||
|
probleme.value = []
|
||||||
|
laeuft.value = false
|
||||||
|
if (projektName) await pruefungBeenden(projektName).catch(() => {})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user