This commit is contained in:
Team3
2026-07-27 07:54:09 +02:00
parent 37b9078b34
commit 37100f1b81
22 changed files with 837 additions and 21 deletions

54
backend/agents.py Normal file
View 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

View File

@@ -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)

117
backend/pruefung.py Normal file
View 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)

View File

@@ -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:

68
backend/stil.md Normal file
View 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)

View 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")