update
This commit is contained in:
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
|
||||
|
||||
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
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 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
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")
|
||||
Reference in New Issue
Block a user