update
This commit is contained in:
63
README.md
63
README.md
@@ -14,21 +14,50 @@ Fehlerhafte Karten öffnen sich rot markiert und zeigen unter jeder Stelle einen
|
||||
mit ✓/✕; ✓ schreibt ihn in die Datei, die übrigen Vorschläge bleiben stehen. Unter dem
|
||||
Icon steht der Fortschritt, rechts läuft ein Panel mit Problemliste.
|
||||
|
||||
Zwei Stufen je Prüfstelle. Was sich zählen lässt, zählt `struktur.py` — Wortzahl,
|
||||
Satzzeichen am Zeilenende, Füllwörter, Klammern, Badge-Wert. Was es wegstreichen kann,
|
||||
geht als fertiger Vorschlag durch, ganz ohne Anfrage. Der Rest hängt als Auftrag
|
||||
„Gemessen, bitte neu schreiben" am Ausschnitt für das Modell. Das Modell darf diese
|
||||
Klasse nicht selbst melden — sonst erfindet es Regeln, die im Katalog nicht stehen.
|
||||
|
||||
Höchstens ein gemessener Befund je Zeile: zwei Vorschläge für dieselbe Zeile wären
|
||||
nicht unabhängig anwendbar.
|
||||
|
||||
Eine Anfrage je Prüfstelle, nicht je Karte:
|
||||
Die Prüfung soll ein Format erzwingen, nicht Geschmack durchsetzen. Darum vier
|
||||
Sperren gegen Beliebigkeit — jede einzeln nachgemessen:
|
||||
|
||||
```
|
||||
Karte ──┬── Kartenebene titel · badge · ziel · regeln 1 Anfrage
|
||||
├── Funktion 1 titel · ziel · regeln · testen 1 Anfrage
|
||||
└── Funktion 2 … 1 Anfrage
|
||||
1 Messen struktur.py Wortzahl, Satzzeichen, Füllwörter, Klammern, Badge
|
||||
2 Umschreiben 4 Stimmen ganze Felder neu, nicht einzelne Zeilen
|
||||
3 Belegen Code Änderung nur mit wörtlich zitierter Katalogzeile
|
||||
4 Nachmessen struktur.py Vorschlag, der selbst verstößt, wird abgelehnt
|
||||
```
|
||||
|
||||
**Messen.** Was sich zählen lässt, zählt der Code. Was er wegstreichen kann, wird zum
|
||||
fertigen Vorschlag. Der Rest hängt als Auftrag „Gemessen, bitte neu schreiben" am
|
||||
Ausschnitt. Diese Zeilen stehen im Katalog unter `## Vom System gemessen` und sind
|
||||
nicht zitierbar — sonst beruft sich das Modell auf die Füllwortliste und wendet sie
|
||||
auf Wörter an, die nicht darin stehen.
|
||||
|
||||
**Umschreiben.** Das Modell zeigt auf keine Zeile, es schreibt das ganze Feld neu.
|
||||
Adressierung war die größte Fehlerquelle: verschmolzene Zeilen, erfundene Zeilen,
|
||||
Zeilen aus dem Kontextblock. Welche Zeile sich geändert hat, rechnet `_stellen` aus.
|
||||
`original` ist damit immer echter Kartentext.
|
||||
|
||||
**Belegen.** Jede Änderung nennt die verletzte Katalogzeile, wörtlich. Der Code prüft
|
||||
sie gegen den Katalog. Ohne Beleg keine Änderung. Ohne diese Sperre findet ein Modell
|
||||
immer irgendetwas zu verbessern.
|
||||
|
||||
**Vier Stimmen, einstimmig.** Dieselbe Stelle wird viermal beurteilt, mit steigender
|
||||
Temperatur. Nur was alle vier bemängeln, wird ein Vorschlag; die Formulierung kommt
|
||||
aus der Fassung, die den anderen am ähnlichsten ist. Grenzfälle sehen mal so, mal so
|
||||
aus — einzelne Stimmen ließen jede Runde andere Felder aufschlagen.
|
||||
|
||||
Gemessen an den Karten dieses Projekts, 35 Prüfstellen:
|
||||
|
||||
```
|
||||
ohne diese Sperren 41 Vorschläge, 12 davon systematisch kaputt
|
||||
mit 4 Vorschläge, 0 kaputt
|
||||
nach dem Annehmen 2 -> 1 -> 2 Ruhezustand
|
||||
```
|
||||
|
||||
Eine Anfrage je Prüfstelle und Stimme, nicht je Karte:
|
||||
|
||||
```
|
||||
Karte ──┬── Kartenebene titel · badge · ziel · regeln
|
||||
├── Funktion 1 titel · ziel · regeln · testen
|
||||
└── Funktion 2 …
|
||||
```
|
||||
|
||||
Ein Vorschlag ersetzt seine Stelle eins zu eins. Was das bewirkt, sagt der Prüfer
|
||||
@@ -41,11 +70,9 @@ mehrere Zeilen ersetzen die Stelle durch alle davon; steht die alte
|
||||
leer streicht die Stelle
|
||||
```
|
||||
|
||||
So muss das Modell keine Aktion auswählen. Es schreibt nur hin, was dort stehen soll.
|
||||
Ohne umsetzbaren Vorschlag wird ein Problem verworfen statt leer angezeigt.
|
||||
|
||||
Vorschläge werden einzeln und in beliebiger Reihenfolge angenommen. Jeder muss
|
||||
darum für sich allein anwendbar sein und selbst dem Katalog genügen.
|
||||
Vorschläge werden einzeln und in beliebiger Reihenfolge angenommen. Jeder muss darum
|
||||
für sich allein anwendbar sein. Baut das Modell eine ganze Liste um, wird daraus ein
|
||||
einziger Vorschlag für das Feld — einzelne Zeilen daraus ergäben Dubletten oder Lücken.
|
||||
|
||||
Jede Anfrage bekommt nur ihren Ausschnitt der Karte und nur den Katalogteil, der für sie
|
||||
gilt. Die Herkunft eines Problems stammt aus dem Code, nicht aus der Modellantwort.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -188,9 +188,16 @@ function problemeAn(feld, funktion = '', original = null) {
|
||||
return props.probleme.filter(p =>
|
||||
p.feld === feld &&
|
||||
(p.funktion || '') === funktion &&
|
||||
!p.ganzes_feld &&
|
||||
(original === null || p.original === original))
|
||||
}
|
||||
|
||||
// Umbau einer ganzen Liste: hängt unter der Feldüberschrift, nicht unter einer Zeile
|
||||
function problemeFeld(feld, funktion = '') {
|
||||
return props.probleme.filter(p =>
|
||||
p.feld === feld && (p.funktion || '') === funktion && p.ganzes_feld)
|
||||
}
|
||||
|
||||
// Bleibt die alte Zeile im Vorschlag stehen, kommt nur etwas dazu — sie ist in Ordnung
|
||||
function fehlerAn(feld, funktion = '', original = null) {
|
||||
return problemeAn(feld, funktion, original).some(p => vorschlagsArt(p) !== 'ergaenzen')
|
||||
@@ -200,6 +207,8 @@ function fehlerAn(feld, funktion = '', original = null) {
|
||||
// mehrere. Gesucht wird über den Text, nicht über den Index: bei mehreren Vorschlägen
|
||||
// hintereinander hat sich die Liste schon verschoben.
|
||||
function zeileAendern(text, p) {
|
||||
// Leeres original: das Feld war leer, es gibt keine Zeile zum Anknüpfen
|
||||
if (!p.original) return vorschlagsZeilen(p).map(z => `- ${z}`).join('\n')
|
||||
const zeilen = text.split('\n')
|
||||
const i = zeilen.findIndex(z => z.replace(/^[-*]\s*/, '').trim() === p.original)
|
||||
if (i < 0) return null
|
||||
@@ -227,6 +236,11 @@ function anwenden(p) {
|
||||
ziel.art = p.vorschlag
|
||||
} else if (p.feld === 'regeln' || p.feld === 'testen') {
|
||||
if (typeof ziel[p.feld] !== 'string') return false // z. B. testen auf Kartenebene
|
||||
if (p.ganzes_feld) {
|
||||
// Umbau der ganzen Liste — einzelne Zeilen daraus wären nicht sinnvoll
|
||||
ziel[p.feld] = vorschlagsZeilen(p).map(z => `- ${z}`).join('\n')
|
||||
return true
|
||||
}
|
||||
const neu = zeileAendern(ziel[p.feld], p)
|
||||
if (neu === null) return false // Zeile schon weg oder umgeschrieben
|
||||
ziel[p.feld] = neu
|
||||
@@ -317,6 +331,7 @@ function alleAblehnen() {
|
||||
<!-- Karten-Regeln nur in der ausführlichen Ansicht -->
|
||||
<section v-if="gross" :class="{ leer: !s.regeln && !istAktiv('regeln') }">
|
||||
<p class="gruppe">Regeln</p>
|
||||
<ProblemHinweis :probleme="problemeFeld('regeln')" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||
<textarea
|
||||
v-if="istAktiv('regeln')"
|
||||
ref="feldRef"
|
||||
@@ -388,6 +403,7 @@ function alleAblehnen() {
|
||||
<!-- Regeln als Punktliste, ein Block je Funktion -->
|
||||
<div v-if="istAktiv('regeln', i) || f.regeln" class="block">
|
||||
<p class="mini">Regeln</p>
|
||||
<ProblemHinweis :probleme="problemeFeld('regeln', f.text)" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||
<textarea
|
||||
v-if="istAktiv('regeln', i)"
|
||||
ref="feldRef"
|
||||
@@ -412,6 +428,7 @@ function alleAblehnen() {
|
||||
<!-- Testen als nummerierte Schritte -->
|
||||
<div v-if="istAktiv('testen', i) || f.testen" class="block">
|
||||
<p class="mini">Testen</p>
|
||||
<ProblemHinweis :probleme="problemeFeld('testen', f.text)" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||
<textarea
|
||||
v-if="istAktiv('testen', i)"
|
||||
ref="feldRef"
|
||||
|
||||
@@ -25,6 +25,7 @@ const text = (p) => (vorschlagsArt(p) === 'entfernen' ? p.original : p.vorschlag
|
||||
<p class="vorschlag" :class="vorschlagsArt(p)">
|
||||
<span v-if="zeichen(p)" class="zeichen">{{ zeichen(p) }}</span>{{ text(p) }}
|
||||
</p>
|
||||
<p v-if="p.regel" class="regel" :title="p.regel">{{ p.regel }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -61,6 +62,15 @@ const text = (p) => (vorschlagsArt(p) === 'entfernen' ? p.original : p.vorschlag
|
||||
white-space: pre-line; /* mehrzeilige Vorschläge bleiben mehrzeilig */
|
||||
}
|
||||
.vorschlag.entfernen { text-decoration: line-through; color: var(--dezent); }
|
||||
/* Die belegte Katalogzeile — macht den Vorschlag nachprüfbar */
|
||||
.regel {
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: 0.72rem;
|
||||
color: var(--dezent);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.zeichen {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
|
||||
Reference in New Issue
Block a user