This commit is contained in:
team3
2026-07-27 12:47:32 +02:00
parent 1c8a4fd4fd
commit 41185eaccc
16 changed files with 582 additions and 230 deletions

View File

@@ -30,16 +30,17 @@ def pruefe_konfiguration():
raise NichtKonfiguriert(f"Nicht konfiguriert: {', '.join(fehlend)} in .env fehlt")
async def frage(system: str, inhalt: str) -> str:
async def frage(system: str, inhalt: str, temperatur: float = 0.0) -> str:
"""Eine Anfrage, eine Antwort — der Text der Modellantwort.
Kalt geroutet: niedrige Temperatur, kein Thinking; die Prüfung soll nicht raten."""
Kalt geroutet: Temperatur 0, kein Thinking; die Prüfung soll nicht raten.
Für Mehrheitsentscheide wird sie bewusst angehoben, sonst wären die Stimmen gleich."""
pruefe_konfiguration()
daten = {
"model": config.MINIMAX_MODELL,
"max_tokens": MAX_TOKENS,
"system": system,
"messages": [{"role": "user", "content": inhalt}],
"temperature": 0.2,
"temperature": temperatur,
"thinking": {"type": "disabled"},
}
async with httpx.AsyncClient(timeout=httpx.Timeout(ZEITLIMIT, connect=30)) as client:

View File

@@ -10,8 +10,10 @@ geprüft ist.
"""
import asyncio
import difflib
import json
import re
from collections import Counter
import agents
import config
@@ -26,53 +28,62 @@ _laeufe = {}
# Referenz kann der Lauf mitten drin weggesammelt werden.
_aufgaben = set()
# Das Modell zeigt nicht mehr auf einzelne Zeilen, sondern schreibt ganze Felder neu.
# Adressierung war die Fehlerquelle: verschmolzene Zeilen, erfundene Zeilen, Zeilen aus
# dem Kontextblock. Welche Zeile sich geändert hat, rechnet der Code aus (_stellen).
SYSTEM = """Du prüfst {gegenstand} gegen einen Stil-Katalog.
{stil}
Melde jede Abweichung vom Katalog. Antworte ausschließlich mit JSON:
{{"probleme": [{{"feld": "...", "original": "...",
"problem": "...", "vorschlag": "..."}}]}}
Du bekommst die Felder dieser Stelle. Schreibe jedes Feld neu, das dem Katalog nicht
genügt. Felder, die schon passen, lässt du weg.
- feld: {felder}
- original: die betroffene Stelle wortwörtlich; bei regeln und testen genau eine
Zeile ohne führendes "- "
- problem: die verletzte Vorgabe in höchstens 8 Wörtern
- vorschlag: der vollständige neue Text für diese Stelle
Antworte ausschließlich mit JSON:
{{"felder": {{"<feld>": {{"regel": "...", "neu": "...", "grund": "..."}}}}}}
vorschlag ersetzt original eins zu eins:
- eine Zeile schreibt die Stelle um
- mehrere Zeilen setzen alle davon an die Stelle
- leer streicht die Stelle
- <feld>: {felder}
- regel: die verletzte Katalogzeile, wörtlich abgeschrieben
- neu: der vollständige Inhalt des Feldes, so wie er dastehen soll
- grund: was am alten Inhalt falsch war, höchstens 8 Wörter
Mehrere Zeilen gibt es nur bei regeln und testen. Leer ist nur bei regeln, testen
und ziel erlaubt.
regel muss Zeichen für Zeichen im Katalog oben stehen. Findest du dort keine Zeile,
die der Inhalt verletzt, ändere nichts. Dein Geschmack ist keine Regel.
Jedes Problem braucht einen Vorschlag. Weißt du keinen konkreten Ersatz, melde nichts.
Bei regeln und testen ist neu eine Liste: eine Zeile je Eintrag, jede mit "- " davor.
Behalte dort jede Zeile, die für sich in Ordnung ist, wortgleich bei. Fehlt ein
Eintrag, ergänze ihn an der richtigen Stelle. Steht einer zu Unrecht da, lass ihn weg.
Ein Feld darf leer werden, wenn der Katalog das erlaubt.
Wortzahl, Satzzeichen am Zeilenende und Füllwörter misst das System selbst und
meldet sie schon. Melde sie nicht noch einmal. Steht unten ein Abschnitt „Gemessen",
schreib genau diese Stellen neu.
Ändere so wenig wie möglich. Ein leeres oder fehlendes Feld ist kein Verstoß, solange
keine Katalogzeile es verlangt. Denk dir keine Inhalte aus, die es nicht gibt.
Der Nutzer nimmt jeden Vorschlag einzeln an oder ab, in beliebiger Reihenfolge.
Darum:
- Jeder Vorschlag muss für sich allein anwendbar sein, ohne einen anderen
- Begründe nie mit dem, was ein anderer Vorschlag ändern würde
- Der Vorschlag selbst muss den Katalog erfüllen — sonst meldest du ihn beim
nächsten Lauf wieder
Nicht mitgeschickte Felder sind leer und werden nicht gemeldet.
Was du schreibst, muss den Katalog erfüllen — es wird gemessen und sonst abgelehnt.
Steht unten „Gemessen", sind das Verstöße, die feststehen. Behebe sie; dort brauchst
du keine regel zu nennen.
{hinweis}
Keine Probleme gefunden: {{"probleme": []}}
Erfinde nichts und melde nur, was der Katalog verlangt.
Nichts zu ändern: {{"felder": {{}}}}
Erfinde nichts und ändere nur, was der Katalog verlangt.
Der Katalog ist abschließend — leite keine weiteren Vorgaben daraus ab."""
NACHBESSERN = """Dein Vorschlag verstößt weiter gegen messbare Vorgaben:
{maengel}
Schreibe die betroffenen Felder erneut. Gleiches Format wie zuvor."""
# Nur hier ist der Text eine Liste — mehrzeilige Vorschläge ergeben nur dort Sinn
ZEILENFELDER = ("regeln", "testen")
# Felder, die leer bleiben dürfen; ein leerer Vorschlag streicht die Stelle
LEERBAR = ZEILENFELDER + ("ziel",)
# Alles, was es überhaupt gibt; je Bereich gilt eine engere Auswahl
ALLE_FELDER = ("titel", "badge", "ziel", "regeln", "testen")
# Anfragen je Stimme, bis der Vorschlag die messbaren Vorgaben erfüllt
VERSUCHE = 2
# Unabhängige Stimmen je Prüfstelle. Verschiedene Temperaturen, damit sie sich
# unterscheiden — bei identischen Anfragen wäre die Mehrheit wertlos.
TEMPERATUREN = (0.0, 0.3, 0.6, 0.9)
# Einstimmig. Ein Verstoß, den nicht jede Stimme sieht, ist ein Grenzfall — und
# Grenzfälle sind genau das, was den Lauf sonst nie zur Ruhe kommen lässt.
MEHRHEIT = len(TEMPERATUREN)
# Was der jeweilige Prüfer sieht und melden darf. Der Bereichsname ist zugleich das
# Dateipräfix im Stil-Ordner — „feature" liest allgemein-* und feature-*.
@@ -208,71 +219,230 @@ def _quotes_maskieren(roh: str) -> str:
return "".join(aus)
def _antwort_lesen(text: str, felder: tuple = ALLE_FELDER) -> list[dict]:
"""Holt das JSON aus der Antwort, auch wenn es in ```-Zäunen steckt.
Listenzeilen kommen mal mit, mal ohne „- " zurück — hier vereinheitlicht,
damit die Anzeige die Stelle wiederfindet."""
treffer = re.search(r"\{.*\}", text, re.S)
if not treffer:
def _json_lesen(text: str) -> dict:
"""Holt das erste vollständige JSON-Objekt aus der Antwort.
raw_decode statt einer Regex: Antworten enden manchmal mit einem Nachsatz oder
einem zweiten Objekt — beides lief vorher in „Extra data".
"""
start = text.find("{")
if start < 0:
raise ValueError("Antwort enthält kein JSON")
roh = treffer.group(0)
roh = text[start:]
# strict=False lässt Zeilenumbrüche in Strings durch
leser = json.JSONDecoder(strict=False)
try:
# strict=False lässt Zeilenumbrüche in Strings durch
daten = json.loads(roh, strict=False)
return leser.raw_decode(roh)[0]
except json.JSONDecodeError:
daten = json.loads(_quotes_maskieren(roh), strict=False)
probleme = []
gesehen = set() # dieselbe Stelle nur einmal melden
for p in daten.get("probleme") or []:
if not isinstance(p, dict) or not p.get("original"):
continue
p["original"] = _ohne_strich(p["original"])
p["vorschlag"] = _ohne_strich(p.get("vorschlag"))
if not _anwendbar(p, felder):
continue
schluessel = (p.get("feld"), p["original"])
if schluessel in gesehen:
continue
gesehen.add(schluessel)
probleme.append(p)
return probleme
return leser.raw_decode(_quotes_maskieren(roh))[0]
def _anwendbar(p: dict, felder: tuple) -> bool:
"""Ein Problem ohne umsetzbaren Vorschlag ist für den Nutzer wertlos —
lieber verschweigen als einen leeren Hinweis anzeigen."""
feld = p.get("feld")
if feld not in felder:
return False # erfundenes Feld wie „struktur" — die Karte kennt es nicht
if not p["vorschlag"]:
return feld in LEERBAR # leerer Vorschlag streicht die Stelle
if "\n" in p["vorschlag"] or "\n" in p["original"]:
return feld in ZEILENFELDER # nur Listen haben Zeilen
return True
def _text(wert) -> str:
"""Listenfelder kommen mal als ein String mit Umbrüchen, mal als JSON-Liste."""
if isinstance(wert, list):
return "\n".join(str(z) for z in wert)
return str(wert) if wert is not None else ""
def _normal(zeile: str) -> str:
return re.sub(r"\s+", " ", zeile.strip().strip("-*·` ")).lower()
GEMESSEN = "## Vom System gemessen"
def katalogzeilen(stil: str) -> set:
"""Die zitierbaren Regeln: alle Aufzählungszeilen außer denen, die der Code
selbst misst. Sonst beruft sich das Modell auf die Füllwortliste und wendet sie
auf Wörter an, die gar nicht darin stehen."""
raus = set()
messbar = False
for zeile in stil.splitlines():
if zeile.startswith("## "):
messbar = zeile.strip() == GEMESSEN
elif zeile.strip().startswith("- ") and not messbar:
raus.add(_normal(zeile))
return raus
def _belegt(regel: str, zeilen: set) -> bool:
"""Steht die zitierte Regel wirklich im Katalog?
Ohne diesen Nachweis findet ein Modell immer irgendetwas zu verbessern und der
Lauf konvergiert nie. Kleine Zitierfehler lassen wir durch, freie Erfindungen nicht.
"""
regel = _normal(regel)
if not regel:
return False
if regel in zeilen:
return True
return any(difflib.SequenceMatcher(None, regel, z).ratio() >= 0.9 for z in zeilen)
def _felder_lesen(text: str, erlaubt: tuple, zeilen: set = None,
gemessen: tuple = ()) -> dict:
"""feld -> {"neu": str, "grund": str}. Unbekannte Felder fallen weg.
Ohne belegte Katalogzeile fällt die Änderung weg — außer der Code hat den
Verstoß selbst gemessen, dann steht er ohnehin fest."""
daten = _json_lesen(text).get("felder") or {}
raus = {}
for feld, wert in daten.items():
if feld not in erlaubt:
continue
if not isinstance(wert, dict): # manche Modelle kürzen auf den reinen Wert ab
wert = {"neu": wert, "grund": ""}
neu = _ohne_strich(_text(wert.get("neu")))
if "\n" in neu and feld not in ZEILENFELDER:
continue # nur Listen haben Zeilen
if not neu and feld not in LEERBAR:
continue # titel und badge dürfen nicht verschwinden
if zeilen is not None and feld not in gemessen:
if not _belegt(_text(wert.get("regel")), zeilen):
continue
raus[feld] = {"neu": neu, "grund": _text(wert.get("grund")).strip(),
"regel": _text(wert.get("regel")).strip()}
return raus
def _maengel(bereich: str, vorschlaege: dict) -> dict:
"""Was am Vorschlag selbst noch messbar falsch ist. Leer = brauchbar."""
raus = {}
for feld, wert in vorschlaege.items():
befunde = struktur.befunde(bereich, {feld: wert["neu"]})
if befunde:
raus[feld] = befunde
return raus
def _stellen(feld: str, alt: str, neu: str, grund: str, regel: str = "") -> list[dict]:
"""Aus alt und neu die einzelnen Änderungen ableiten.
`original` ist damit immer eine echte Zeile der Karte — das Modell kann sie
nicht mehr danebenlegen. Jede Stelle ist für sich anwendbar: Ersetzungen und
Streichungen greifen über den Zeilentext, Einfügungen hängen sich an die
Zeile davor und schreiben sie mit.
"""
if feld not in ZEILENFELDER:
return [] if alt.strip() == neu.strip() else [_stelle(feld, alt, neu, grund, regel)]
a = struktur._zeilen(feld, alt)
b = struktur._zeilen(feld, neu)
if a == b:
return []
if not a: # leeres Listenfeld — nichts, woran sich etwas festmachen ließe
return [_stelle(feld, "", "\n".join(b), grund, regel, ganz=True)]
raus = []
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, a, b).get_opcodes():
if tag == "equal":
continue
if tag == "insert":
# An die Zeile davor hängen; am Anfang an die dahinter
if i1 > 0:
anker, block = a[i1 - 1], [a[i1 - 1]] + b[j1:j2]
else:
anker, block = a[0], b[j1:j2] + [a[0]]
raus.append(_stelle(feld, anker, "\n".join(block), grund, regel))
continue
# replace und delete: die erste alte Zeile trägt den Ersatz, der Rest fällt weg
raus.append(_stelle(feld, a[i1], "\n".join(b[j1:j2]), grund, regel))
for zeile in a[i1 + 1:i2]:
raus.append(_stelle(feld, zeile, "", grund, regel))
# Mehrere Änderungen an einer Liste sind ein Umbau, kein Strauß von Einzelfixes.
# Einzeln angenommen ergäben sie Dubletten oder Lücken — also nur zusammen.
if len(raus) > 1:
return [_stelle(feld, "\n".join(a), "\n".join(b), grund, regel, ganz=True)]
return raus
def _stelle(feld: str, original: str, vorschlag: str, grund: str, regel: str = "",
ganz: bool = False) -> dict:
return {"feld": feld, "original": original, "vorschlag": vorschlag,
"ganzes_feld": ganz, "regel": regel, # belegte Katalogzeile, nachvollziehbar
"problem": grund or "Entspricht nicht dem Katalog"}
async def _stimme(system: str, aufgabe: dict, offen: list, erlaubt: tuple,
zeilen: set, temperatur: float) -> dict:
"""Eine Meinung, schon gegen die messbaren Vorgaben gefiltert. Was der Prüfer
selbst falsch schreibt, bekommt einen Versuch zur Nachbesserung."""
gemessen = tuple({b["feld"] for b in offen})
nachricht = inhalt(aufgabe, offen)
vorschlaege = {}
for versuch in range(VERSUCHE):
antwort = await agents.frage(system, nachricht, temperatur)
vorschlaege = _felder_lesen(antwort, erlaubt, zeilen, gemessen)
maengel = _maengel(aufgabe["bereich"], vorschlaege)
if not maengel:
return vorschlaege
if versuch == VERSUCHE - 1:
return {f: v for f, v in vorschlaege.items() if f not in maengel}
nachricht = inhalt(aufgabe, offen) + "\n" + NACHBESSERN.format(
maengel="\n".join(
f'- {feld} „{b["original"]}“: {b["problem"]}'
for feld, befunde in maengel.items() for b in befunde
)
)
return vorschlaege
def _konsens(fassungen: list[dict]) -> dict:
"""Die Fassung, die den anderen am ähnlichsten ist. Ein Ausreißer unter drei
zieht das Ergebnis so nicht mit."""
if len(fassungen) == 1:
return fassungen[0]
def naehe(a):
return sum(difflib.SequenceMatcher(None, a["neu"], b["neu"]).ratio()
for b in fassungen)
return max(fassungen, key=naehe)
def _mehrheit(proben: list[dict]) -> dict:
"""Nur was mehrere Stimmen unabhängig bemängeln, wird ein Vorschlag.
Bei Grenzfällen urteilt ein Modell mal so, mal so — einzelne Stimmen führten
dazu, dass jede Runde andere Felder aufschlugen und der Lauf nie zur Ruhe kam.
"""
zaehler = Counter(feld for p in proben for feld in p)
return {
feld: _konsens([p[feld] for p in proben if feld in p])
for feld, treffer in zaehler.items() if treffer >= MEHRHEIT
}
async def _neue_felder(system: str, aufgabe: dict, offen: list, erlaubt: tuple,
zeilen: set = None) -> dict:
"""Mehrere unabhängige Stimmen, dann die Mehrheit."""
proben = await asyncio.gather(*(
_stimme(system, aufgabe, offen, erlaubt, zeilen, t) for t in TEMPERATUREN
), return_exceptions=True)
# Eine kaputte Antwort kostet ihre Stimme, nicht die ganze Prüfstelle
gute = [p for p in proben if isinstance(p, dict)]
if not gute:
raise next(p for p in proben if isinstance(p, BaseException))
return _mehrheit(gute)
async def _pruefe(stil: dict, aufgabe: dict) -> list[dict]:
"""Erst messen, dann fragen. Was der Code selbst reparieren kann, geht ohne
Anfrage durch; der Rest hängt als Auftrag am Ausschnitt."""
"""Erst messen, dann umschreiben lassen, dann den Unterschied ausrechnen."""
bereich = BEREICHE[aufgabe["bereich"]]
befunde = struktur.befunde(aufgabe["bereich"], aufgabe["felder"])
fertig = [b for b in befunde if b["vorschlag"]]
offen = [b for b in befunde if not b["vorschlag"]]
system = SYSTEM.format(
stil=stil[aufgabe["bereich"]],
gegenstand=bereich["gegenstand"],
felder=_felder_text(bereich["felder"]),
hinweis=bereich["hinweis"],
)
antwort = await agents.frage(system, inhalt(aufgabe, offen))
offen = struktur.befunde(aufgabe["bereich"], aufgabe["felder"])
vorschlaege = await _neue_felder(system, aufgabe, offen, bereich["felder"],
katalogzeilen(stil[aufgabe["bereich"]]))
probleme = list(fertig)
belegt = {(b["feld"], b["original"]) for b in fertig}
for p in _antwort_lesen(antwort, bereich["felder"]):
if (p["feld"], p["original"]) in belegt:
continue # der gemessene Befund geht vor, er ist exakt
probleme.append(p)
probleme = []
for feld, wert in vorschlaege.items():
probleme += _stellen(feld, aufgabe["felder"].get(feld, ""), wert["neu"],
wert["grund"], wert.get("regel", ""))
# Herkunft stempelt der Code, nicht das Modell — es kennt nur seinen Ausschnitt
for p in probleme:
p["feature"] = aufgabe["feature"]

View File

@@ -8,10 +8,22 @@ Abweichung als Problem und schlägt einen korrigierten Text vor.
Gilt für jedes Feld.
- Sprache: Deutsch, Präsens, keine Anrede
- Kein Satzzeichen am Zeilenende (kein Punkt, kein Semikolon)
- Keine Füllwörter. Die Liste ist abschließend: auch, noch, einfach, jeweils, ggf.
- Keine Klammern und keine Schrägstriche außer in Icon-Angaben
- Icons heißen `<Name>-Icon`. Der Name darf ein Zeichen sein: `x-Icon`, `+-Icon`,
`Stift-Icon`. Ein Doppelpunkt mit dem tatsächlichen Zeichen dahinter ist erlaubt
(`Nacht-Icon: ☾`)
- Maustasten als `LMB`, `RMB`, `MMB`
## Vom System gemessen
Diese Vorgaben prüft der Code selbst, exakt und für jede Zeile. Halte dich beim
Schreiben daran — aber berufe dich nie darauf. Als `regel` sind sie nicht zulässig.
- Kein Satzzeichen am Zeilenende, kein Punkt und kein Semikolon
- Keine Füllwörter. Die Liste ist abschließend: auch, noch, einfach, jeweils, ggf.
- Keine Klammern und keine Schrägstriche
- Titel der Karte: höchstens 3 Wörter
- Titel einer Funktion: höchstens 5 Wörter
- Ziel: höchstens 2 Sätze, je höchstens 12 Wörter
- Regeln: höchstens 10 Wörter je Zeile
- Testen: höchstens 8 Wörter je Zeile
- Badge: genau `kern` oder `optional`

View File

@@ -1,6 +1,6 @@
## Badge der Karte
Feld `badge`. Genau zwei Werte erlaubt: `kern` oder `optional`.
Feld `badge`.
- `kern`: ohne die Karte ist das Projekt nicht benutzbar
- `optional`: nützlich, aber verzichtbar

View File

@@ -2,7 +2,7 @@
Feld `regeln`, wenn `funktion` leer ist. Gilt für die ganze Karte.
- Eine Zeile je Regel, höchstens 10 Wörter
- Eine Zeile je Regel
- 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

View File

@@ -2,7 +2,6 @@
Feld `titel`, wenn `funktion` leer ist.
- Höchstens 3 Wörter
- Substantiv, oder Objekt mit Verb im Infinitiv
- Benennt die Sache, kein ganzer Satz

View File

@@ -2,7 +2,6 @@
Feld `ziel`, wenn `funktion` leer ist.
- Höchstens 2 Sätze, je höchstens 12 Wörter
- Beschreibt den Zweck der Karte, nicht die Bedienung
- Entfällt, wenn der Titel selbsterklärend ist — dann bleibt das Feld leer

View File

@@ -2,7 +2,7 @@
Feld `regeln`, wenn `funktion` gesetzt ist. Gilt nur für diese Funktion.
- Eine Zeile je Regel, höchstens 10 Wörter
- Eine Zeile je Regel
- 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

View File

@@ -2,7 +2,7 @@
Feld `testen`. Gibt es nur bei Funktionen, nie auf Kartenebene.
- Eine Zeile je Schritt, höchstens 8 Wörter
- Eine Zeile je Schritt
- Schritte in Reihenfolge der Bedienung
- Kein Schritt der Bedienung fehlt
- Letzte Zeile beschreibt den Endzustand, keine Handlung

View File

@@ -2,7 +2,6 @@
Feld `titel`, wenn `funktion` gesetzt ist.
- Höchstens 5 Wörter
- Kurzsatz aus Objekt und Verb im Infinitiv
- Genau eine Tätigkeit

View File

@@ -2,7 +2,6 @@
Feld `ziel`, wenn `funktion` gesetzt ist.
- Höchstens 2 Sätze, je höchstens 12 Wörter
- Beschreibt den Zweck der Funktion, nicht die Bedienung
- Entfällt, wenn der Funktionstitel selbsterklärend ist — dann bleibt das Feld leer

View File

@@ -7,10 +7,11 @@ import agents
import features
import projekte
import pruefung
import struktur
ANTWORT = """```json
{"probleme": [{"feld": "regeln", "original": "Zwei Aussagen, und noch eine",
"problem": "Zwei Aussagen je Regel", "vorschlag": "Zwei Aussagen"}]}
{"felder": {"titel": {"regel": "Sprache: Deutsch, Präsens, keine Anrede",
"neu": "Anmeldung", "grund": "Titel nennt Tätigkeit"}}}
```"""
@@ -39,7 +40,7 @@ async def warte_bis_fertig(projekt):
async def test_lauf_sammelt_probleme(projekt, monkeypatch):
monkeypatch.setattr(agents, "pruefe_konfiguration", lambda: None)
monkeypatch.setattr(agents, "frage", lambda system, inhalt: _antwort())
monkeypatch.setattr(agents, "frage", lambda system, inhalt, temperatur=0.0: _antwort())
await pruefung.starte(projekt)
await warte_bis_fertig(projekt)
@@ -49,7 +50,8 @@ async def test_lauf_sammelt_probleme(projekt, monkeypatch):
assert s["meldung"] == ""
assert len(s["probleme"]) == 2
assert s["probleme"][0]["feature"] in ("eins", "zwei")
assert s["probleme"][0]["vorschlag"] == "Zwei Aussagen"
assert s["probleme"][0]["vorschlag"] == "Anmeldung"
assert s["probleme"][0]["problem"] == "Titel nennt Tätigkeit"
async def _antwort():
@@ -66,7 +68,7 @@ async def test_ohne_konfiguration_kein_lauf(projekt, monkeypatch):
async def test_providerfehler_landet_in_meldung(projekt, monkeypatch):
monkeypatch.setattr(agents, "pruefe_konfiguration", lambda: None)
async def kaputt(system, inhalt):
async def kaputt(system, inhalt, temperatur=0.0):
raise RuntimeError("Provider antwortet mit HTTP 500")
monkeypatch.setattr(agents, "frage", kaputt)
@@ -84,16 +86,17 @@ async def test_verworfener_lauf_stoppt(projekt, monkeypatch):
monkeypatch.setattr(agents.config, "PRUEFUNG_PARALLEL", 1)
aufrufe = []
async def zaehle(system, inhalt):
async def zaehle(system, inhalt, temperatur=0.0):
aufrufe.append(inhalt)
pruefung.beende("Test") # Panel wird nach der ersten Karte geschlossen
return '{"probleme": []}'
return '{"felder": {}}'
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
# Nur die erste Karte wird angefragt — mit ihren drei Stimmen
assert len(aufrufe) == len(pruefung.TEMPERATUREN)
async def test_parallel_begrenzt(projekt, monkeypatch):
@@ -102,18 +105,19 @@ async def test_parallel_begrenzt(projekt, monkeypatch):
gleichzeitig = 0
hoechststand = 0
async def langsam(system, inhalt):
async def langsam(system, inhalt, temperatur=0.0):
nonlocal gleichzeitig, hoechststand
gleichzeitig += 1
hoechststand = max(hoechststand, gleichzeitig)
await asyncio.sleep(0.02)
gleichzeitig -= 1
return '{"probleme": []}'
return '{"felder": {}}'
monkeypatch.setattr(agents, "frage", langsam)
await pruefung.starte(projekt)
await warte_bis_fertig(projekt)
assert hoechststand == 1
# Die Schranke gilt je Prüfstelle; deren Stimmen laufen gemeinsam
assert hoechststand == len(pruefung.TEMPERATUREN)
assert pruefung.status(projekt)["fertig"] == 2
@@ -124,94 +128,6 @@ async def test_status_ohne_lauf():
}
def test_antwort_lesen_ohne_zaun():
probleme = pruefung._antwort_lesen('{"probleme": [{"feld": "titel", "original": "x", "vorschlag": "y"}]}')
assert probleme == [{"feld": "titel", "original": "x", "vorschlag": "y"}]
def test_listenstrich_wird_entfernt():
probleme = pruefung._antwort_lesen(
'{"probleme": [{"feld": "regeln", "original": "- alt", "vorschlag": "- neu"}]}')
assert probleme[0]["original"] == "alt"
assert probleme[0]["vorschlag"] == "neu"
def test_antwort_ohne_json():
with pytest.raises(ValueError):
pruefung._antwort_lesen("keine Ahnung")
def test_unmaskierte_quotes_im_original():
# Karteninhalt enthält Anführungszeichen; das Modell maskiert sie nicht
probleme = pruefung._antwort_lesen(
'{"probleme": [{"feld": "testen", "original": "Klick auf "Feature entfernen"",'
' "problem": "x", "vorschlag": "Klick auf "Feature entfernen" mit LMB"}]}')
assert probleme[0]["original"] == 'Klick auf "Feature entfernen"'
assert probleme[0]["vorschlag"] == 'Klick auf "Feature entfernen" mit LMB'
def test_gueltiges_json_bleibt_unberuehrt():
probleme = pruefung._antwort_lesen(
r'{"probleme": [{"feld": "testen", "original": "Klick auf \"X\"", "vorschlag": "y"}]}')
assert probleme[0]["original"] == 'Klick auf "X"'
def test_zeilenumbruch_im_string():
probleme = pruefung._antwort_lesen(
'{"probleme": [{"feld": "testen", "original": "erste\nzweite", "vorschlag": "y"}]}')
assert probleme[0]["original"] == "erste\nzweite"
def _eins(felder=pruefung.ALLE_FELDER, **werte):
p = {"feld": "testen", "original": "Bestätigen", "problem": "x", "vorschlag": "y"}
p.update(werte)
return pruefung._antwort_lesen(json.dumps({"probleme": [p]}), felder)
def test_mehrzeiliger_vorschlag_bleibt_erhalten():
# Die alte Zeile bleibt stehen, der Endzustand kommt dahinter — ohne Aktionsfeld
p = _eins(vorschlag="- Bestätigen mit LMB\n- Feature erstellt")[0]
assert p["vorschlag"] == "Bestätigen mit LMB\nFeature erstellt"
def test_leerer_vorschlag_nur_wo_das_feld_leer_sein_darf():
for feld in ("regeln", "testen", "ziel"):
assert len(_eins(feld=feld, vorschlag="")) == 1 # streicht die Stelle
for feld in ("titel", "badge"):
assert _eins(feld=feld, vorschlag="") == [] # wären danach kaputt
def test_mehrere_zeilen_nur_bei_listenfeldern():
for feld in ("regeln", "testen"):
assert len(_eins(feld=feld, vorschlag="a\nb")) == 1
for feld in ("titel", "badge", "ziel"):
assert _eins(feld=feld, vorschlag="a\nb") == []
def test_erfundenes_feld_wird_verworfen():
# Das Modell meldete „struktur" — kein Feld der Karte, also nie anwendbar
assert _eins(feld="struktur") == []
assert _eins(feld="") == []
def test_feld_muss_zum_bereich_passen():
feature = pruefung.BEREICHE["feature"]["felder"]
funktion = pruefung.BEREICHE["funktion"]["felder"]
assert _eins(feld="testen", felder=funktion) != []
assert _eins(feld="testen", felder=feature) == [] # Karte hat kein testen
assert _eins(feld="badge", vorschlag="kern", felder=feature) != []
assert _eins(feld="badge", vorschlag="kern", felder=funktion) == []
def test_dieselbe_stelle_nur_einmal():
probleme = pruefung._antwort_lesen(
'{"probleme": ['
'{"feld": "regeln", "original": "a", "problem": "eins", "vorschlag": "x"},'
'{"feld": "regeln", "original": "a", "problem": "zwei", "vorschlag": "y"},'
'{"feld": "testen", "original": "a", "problem": "drei", "vorschlag": "z"}]}')
assert [p["problem"] for p in probleme] == ["eins", "drei"] # feld trennt
KARTE = (
"<!-- titel -->\nLogin\n<!-- art -->\noptional\n<!-- ziel -->\nAnmelden\n"
"<!-- regeln -->\n- Kartenregel\n"
@@ -258,16 +174,16 @@ async def test_lauf_zaehlt_alle_stellen(datenordner, monkeypatch):
monkeypatch.setattr(agents, "pruefe_konfiguration", lambda: None)
gesehen = []
async def merke(system, inhalt):
async def merke(system, inhalt, temperatur=0.0):
gesehen.append((system, inhalt))
return '{"probleme": []}'
return '{"felder": {}}'
monkeypatch.setattr(agents, "frage", merke)
await pruefung.starte("Test")
await warte_bis_fertig("Test")
assert pruefung.status("Test")["gesamt"] == 3 # 1 Karte + 2 Funktionen
assert len(gesehen) == 3
assert len(gesehen) == 3 * len(pruefung.TEMPERATUREN)
# Der Kartenprüfer kennt die Badge-Regeln, der Funktionsprüfer nicht
karten_system = next(s for s, i in gesehen if "badge" in i)
assert "## Badge der Karte" in karten_system
@@ -279,10 +195,11 @@ async def test_herkunft_kommt_vom_code_nicht_vom_modell(datenordner, monkeypatch
features.anlegen("Test", KARTE)
monkeypatch.setattr(agents, "pruefe_konfiguration", lambda: None)
async def luegt(system, inhalt):
async def luegt(system, inhalt, temperatur=0.0):
# Das Modell behauptet eine fremde Funktion — das muss ignoriert werden
return ('{"probleme": [{"feld": "titel", "funktion": "Erfunden",'
' "original": "x", "vorschlag": "y"}]}')
return ('{"felder": {"titel": {"regel": "Sprache: Deutsch, Präsens, keine Anrede",'
' "neu": "Anders", "grund": "x"},'
' "funktion": {"neu": "Erfunden", "grund": "y"}}}')
monkeypatch.setattr(agents, "frage", luegt)
await pruefung.starte("Test")
@@ -301,6 +218,9 @@ def test_stil_katalog_je_bereich():
assert stil.startswith("# Stil-Katalog")
assert "## Allgemein" in stil
for stil in (feature, funktion):
assert pruefung.GEMESSEN in stil # messbare Vorgaben stehen beiden zur Verfügung
for ueberschrift in (
"## Titel der Karte", "## Badge der Karte",
"## Ziel der Karte", "## Regeln der Karte",
@@ -330,3 +250,183 @@ def test_stil_ordner_leer_bricht_ab(tmp_path, monkeypatch):
monkeypatch.setattr(pruefung.config, "STIL_DIR", tmp_path)
with pytest.raises(ValueError):
pruefung._stil_lesen("feature")
def _stellen(feld, alt, neu):
return [(s["original"], s["vorschlag"])
for s in pruefung._stellen(feld, alt, neu, "grund")]
TESTEN = "- RMB auf Hauptbereich\n- Klick auf \"Feature erstellen\"\n- Bestätigen mit LMB"
def test_ergaenzte_zeile_haengt_an_der_davor():
# Der Endzustand fehlt: die letzte Zeile bleibt, der neue Schritt kommt dahinter
assert _stellen("testen", TESTEN, TESTEN + "\n- Feature erstellt") == [
("Bestätigen mit LMB", "Bestätigen mit LMB\nFeature erstellt")]
def test_ergaenzung_am_anfang_haengt_an_der_dahinter():
assert _stellen("testen", TESTEN, "- Vollmodus öffnen\n" + TESTEN) == [
("RMB auf Hauptbereich", "Vollmodus öffnen\nRMB auf Hauptbereich")]
def test_ersetzte_zeile():
neu = TESTEN.replace("- Bestätigen mit LMB", "- Bestätigen")
assert _stellen("testen", TESTEN, neu) == [("Bestätigen mit LMB", "Bestätigen")]
def test_gestrichene_zeile():
neu = "- RMB auf Hauptbereich\n- Bestätigen mit LMB"
assert _stellen("testen", TESTEN, neu) == [('Klick auf "Feature erstellen"', "")]
def test_mehrere_aenderungen_werden_ein_umbau():
# Einzeln angenommen ergäben sie Dubletten oder Lücken — also nur zusammen
neu = "- RMB auf Hauptbereich\n- Feature anlegen"
stellen = pruefung._stellen("testen", TESTEN, neu, "grund")
assert len(stellen) == 1
assert stellen[0]["ganzes_feld"] is True
assert stellen[0]["original"] == "\n".join(struktur._zeilen("testen", TESTEN))
assert stellen[0]["vorschlag"] == "RMB auf Hauptbereich\nFeature anlegen"
def test_unveraendert_ergibt_keine_stelle():
assert _stellen("testen", TESTEN, TESTEN) == []
assert _stellen("titel", "Login", "Login") == []
assert _stellen("titel", "Login", " Login ") == []
def test_leeres_listenfeld_wird_ganz_gesetzt():
assert _stellen("testen", "", "- Eins\n- Zwei") == [("", "Eins\nZwei")]
def test_einzelfeld_wird_ganz_ersetzt():
assert _stellen("titel", "Login", "Anmeldung") == [("Login", "Anmeldung")]
assert _stellen("ziel", "Alt", "") == [("Alt", "")]
def test_einzelne_aenderung_zeigt_auf_eine_echte_zeile():
stellen = pruefung._stellen("testen", TESTEN, TESTEN + "\n- Fertig", "grund")
assert len(stellen) == 1
assert stellen[0]["ganzes_feld"] is False
assert stellen[0]["original"] in struktur._zeilen("testen", TESTEN)
def test_erfundenes_feld_wird_verworfen():
erlaubt = pruefung.BEREICHE["funktion"]["felder"]
antwort = ('{"felder": {"struktur": {"neu": "x", "grund": "y"},'
' "testen": {"neu": "- a", "grund": "z"}}}')
assert list(pruefung._felder_lesen(antwort, erlaubt)) == ["testen"]
def test_feld_darf_nicht_leer_werden_wenn_es_pflicht_ist():
erlaubt = pruefung.BEREICHE["feature"]["felder"]
leer = '{"felder": {"titel": {"neu": "", "grund": "x"}, "ziel": {"neu": "", "grund": "y"}}}'
assert list(pruefung._felder_lesen(leer, erlaubt)) == ["ziel"]
def test_mehrzeilig_nur_bei_listenfeldern():
erlaubt = pruefung.BEREICHE["funktion"]["felder"]
antwort = ('{"felder": {"titel": {"neu": "a\\nb", "grund": "x"},'
' "regeln": {"neu": "- a\\n- b", "grund": "y"}}}')
assert list(pruefung._felder_lesen(antwort, erlaubt)) == ["regeln"]
def test_neu_darf_liste_oder_string_sein():
erlaubt = pruefung.BEREICHE["funktion"]["felder"]
als_liste = '{"felder": {"testen": {"neu": ["- a", "- b"], "grund": "x"}}}'
als_string = '{"felder": {"testen": {"neu": "- a\\n- b", "grund": "x"}}}'
assert pruefung._felder_lesen(als_liste, erlaubt) == \
pruefung._felder_lesen(als_string, erlaubt)
def test_feld_ohne_huelle_wird_angenommen():
# Manche Antworten kürzen {"neu": ...} auf den reinen Wert ab
erlaubt = pruefung.BEREICHE["funktion"]["felder"]
gelesen = pruefung._felder_lesen('{"felder": {"titel": "Anmelden"}}', erlaubt)
assert gelesen == {"titel": {"neu": "Anmelden", "grund": "", "regel": ""}}
KATALOG = pruefung.katalogzeilen(pruefung._stil_lesen("funktion"))
def _mit_regel(regel):
antwort = ('{"felder": {"titel": {"regel": "%s", "neu": "Anmelden", "grund": "x"}}}'
% regel)
return pruefung._felder_lesen(
antwort, pruefung.BEREICHE["funktion"]["felder"], KATALOG)
def test_aenderung_braucht_eine_echte_katalogzeile():
assert _mit_regel("Genau eine Tätigkeit") != {} # steht so im Katalog
assert _mit_regel("genau eine tätigkeit") != {} # Groß-/Kleinschreibung egal
assert _mit_regel("- Genau eine Tätigkeit") != {} # Listenstrich egal
def test_gemessene_regeln_sind_nicht_zitierbar():
# Sonst beruft sich das Modell auf die Füllwortliste und wendet sie frei an
assert _mit_regel("Titel einer Funktion: höchstens 5 Wörter") == {}
assert _mit_regel("Keine Füllwörter. Die Liste ist abschließend:"
" auch, noch, einfach, jeweils, ggf.") == {}
assert "## Vom System gemessen" in pruefung._stil_lesen("funktion")
def test_erfundene_regel_wird_verworfen():
# Genau daran ist der Lauf vorher nicht konvergiert
assert _mit_regel("Platzhaltertext ist nicht erlaubt") == {}
assert _mit_regel("Ziel muss konkret sein") == {}
assert _mit_regel("") == {}
assert _mit_regel("Kein Geschmack") == {}
def test_gemessener_verstoss_braucht_keine_regel():
antwort = '{"felder": {"titel": {"neu": "Anmelden", "grund": "zu lang"}}}'
erlaubt = pruefung.BEREICHE["funktion"]["felder"]
assert pruefung._felder_lesen(antwort, erlaubt, KATALOG) == {}
assert pruefung._felder_lesen(antwort, erlaubt, KATALOG, ("titel",)) != {}
def test_katalogzeilen_sind_nur_die_aufzaehlung():
zeilen = pruefung.katalogzeilen("## Titel\n\nEinleitung\n\n- Erste Regel\n- Zweite\n")
assert zeilen == {"erste regel", "zweite"}
def test_json_mit_nachsatz_und_zaun():
for text in (
'{"felder": {}}\nDas war alles.',
'```json\n{"felder": {}}\n```',
'Hier: {"felder": {}} {"noch": "was"}',
):
assert pruefung._json_lesen(text) == {"felder": {}}
def test_json_ohne_objekt():
with pytest.raises(ValueError):
pruefung._json_lesen("keine Ahnung")
def _proben(*fassungen):
"""Eine Stimme je Eintrag; None heißt „diese Stimme sieht nichts"."""
return [f or {} for f in fassungen]
def test_uneinigkeit_faellt_weg():
# Grenzfälle beurteilt ein Modell mal so, mal so — die fliegen raus
eins = {"titel": {"neu": "A", "grund": "x"}}
assert pruefung._mehrheit(_proben(eins, eins, eins, None)) == {}
assert pruefung._mehrheit(_proben(eins, None, None, None)) == {}
def test_einstimmiges_bleibt():
eins = {"titel": {"neu": "A", "grund": "x"}}
assert pruefung._mehrheit(_proben(eins, eins, eins, eins)) == eins
def test_konsens_nimmt_die_mittlere_fassung():
# Der Ausreißer zieht das Ergebnis nicht mit
def f(text):
return {"titel": {"neu": text, "grund": "x"}}
proben = _proben(f("Anmeldung starten"), f("Anmeldung startet"),
f("Anmeldung beginnen"), f("Völlig anderer Text"))
assert pruefung._mehrheit(proben)["titel"]["neu"].startswith("Anmeldung")

View File

@@ -92,7 +92,9 @@ def test_auftrag_nennt_feld_und_stelle():
assert struktur.auftrag(b) == '- titel „Karten der Features prüfen“: Mehr als 3 Wörter'
async def test_pruefe_verbindet_messung_und_modell(datenordner, monkeypatch):
async def test_messung_ist_auftrag_und_tuersteher(datenordner, monkeypatch):
"""Die Messung geht als Auftrag rein und prüft die Antwort wieder nach.
Ein Vorschlag, der weiter verstößt, wird zurückgewiesen — mit Begründung."""
import agents
import features
import projekte
@@ -104,31 +106,48 @@ async def test_pruefe_verbindet_messung_und_modell(datenordner, monkeypatch):
"<!-- regeln -->\n- Auch Passwort prüfen.\n",
)
gesehen = []
# Jede Stimme liefert erst einen halbherzigen Vorschlag: Punkt weg, Füllwort bleibt
halb = '{"felder": {"regeln": {"neu": "- Auch Passwort prüfen", "grund": "Füllwort"}}}'
ganz = '{"felder": {"regeln": {"neu": "- Passwort prüfen", "grund": "Füllwort"}}}'
async def modell(system, inhalt):
async def modell(system, inhalt, temperatur=0.0):
gesehen.append(inhalt)
# Das Modell meldet ein erfundenes Feld und dieselbe Zeile noch einmal
return ('{"probleme": ['
'{"feld": "struktur", "original": "x", "vorschlag": "y"},'
'{"feld": "regeln", "original": "Auch Passwort prüfen.",'
' "problem": "Füllwort", "vorschlag": "Passwort prüfen"},'
'{"feld": "regeln", "original": "Auch Passwort prüfen.",'
' "problem": "zwei Aussagen", "vorschlag": "Passwort prüfen"}]}')
return ganz if "Dein Vorschlag verstößt" in inhalt else halb
monkeypatch.setattr(agents, "frage", modell)
aufgabe = pruefung.aufgaben(f)[1]
probleme = await pruefung._pruefe({"funktion": "<KATALOG>"}, aufgabe)
# Ein Befund für die Zeile, beide Verstöße auf einmal behoben — ohne Modell
assert [p["problem"] for p in probleme] == [
"Satzzeichen am Zeilenende, Füllwort „auch“",
]
# Eine Anfrage je Stimme, dazu je eine Nachbesserung
assert len(gesehen) == 2 * len(pruefung.TEMPERATUREN)
assert "Gemessen" in gesehen[0]
assert "Füllwort „auch“" in gesehen[0]
assert sum("Dein Vorschlag verstößt" in g for g in gesehen) == len(pruefung.TEMPERATUREN)
assert [p["original"] for p in probleme] == ["Auch Passwort prüfen."]
assert probleme[0]["vorschlag"] == "Passwort prüfen"
# Erfundenes Feld fällt raus, Doppelmeldung der gemessenen Zeile ebenso
assert probleme[0]["feld"] == "regeln"
assert probleme[0]["funktion"] == "Anmelden"
# Nichts zu kürzen -> kein Auftrag im Ausschnitt
assert "Gemessen" not in gesehen[0]
async def test_unverbesserlicher_vorschlag_faellt_weg(datenordner, monkeypatch):
import agents
import features
import projekte
projekte.anlegen("Test")
f = features.anlegen(
"Test",
"<!-- titel -->\nLogin\n<!-- funktion -->\nAnmelden\n"
"<!-- regeln -->\n- Auch Passwort prüfen.\n",
)
async def sturr(system, inhalt, temperatur=0.0):
return '{"felder": {"regeln": {"neu": "- Auch anders.", "grund": "x"}}}'
monkeypatch.setattr(agents, "frage", sturr)
probleme = await pruefung._pruefe({"funktion": "<KATALOG>"}, pruefung.aufgaben(f)[1])
assert probleme == [] # lieber nichts als ein Vorschlag, der wieder auffällt
def test_gemessene_befunde_haengen_am_ausschnitt(datenordner):