diff --git a/README.md b/README.md index dbf9417..1eea0cc 100644 --- a/README.md +++ b/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. diff --git a/backend/agents.py b/backend/agents.py index 2c96d19..02f1616 100644 --- a/backend/agents.py +++ b/backend/agents.py @@ -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: diff --git a/backend/pruefung.py b/backend/pruefung.py index 7fc662f..d83b885 100644 --- a/backend/pruefung.py +++ b/backend/pruefung.py @@ -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": {{"": {{"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 +- : {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"] diff --git a/backend/stil/allgemein.md b/backend/stil/allgemein.md index fdea5b5..0016b78 100644 --- a/backend/stil/allgemein.md +++ b/backend/stil/allgemein.md @@ -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 `-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` diff --git a/backend/stil/feature-badge.md b/backend/stil/feature-badge.md index 630d6af..3369017 100644 --- a/backend/stil/feature-badge.md +++ b/backend/stil/feature-badge.md @@ -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 diff --git a/backend/stil/feature-regeln.md b/backend/stil/feature-regeln.md index f132c92..f1fc272 100644 --- a/backend/stil/feature-regeln.md +++ b/backend/stil/feature-regeln.md @@ -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 diff --git a/backend/stil/feature-titel.md b/backend/stil/feature-titel.md index 98ca343..3085778 100644 --- a/backend/stil/feature-titel.md +++ b/backend/stil/feature-titel.md @@ -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 diff --git a/backend/stil/feature-ziel.md b/backend/stil/feature-ziel.md index 0d99a42..d14870b 100644 --- a/backend/stil/feature-ziel.md +++ b/backend/stil/feature-ziel.md @@ -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 diff --git a/backend/stil/funktion-regeln.md b/backend/stil/funktion-regeln.md index ded34be..041f7cc 100644 --- a/backend/stil/funktion-regeln.md +++ b/backend/stil/funktion-regeln.md @@ -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 diff --git a/backend/stil/funktion-testen.md b/backend/stil/funktion-testen.md index 82a33b3..97fc3a9 100644 --- a/backend/stil/funktion-testen.md +++ b/backend/stil/funktion-testen.md @@ -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 diff --git a/backend/stil/funktion-titel.md b/backend/stil/funktion-titel.md index 62f72a6..1e76e28 100644 --- a/backend/stil/funktion-titel.md +++ b/backend/stil/funktion-titel.md @@ -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 diff --git a/backend/stil/funktion-ziel.md b/backend/stil/funktion-ziel.md index 709ff41..00978dd 100644 --- a/backend/stil/funktion-ziel.md +++ b/backend/stil/funktion-ziel.md @@ -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 diff --git a/backend/tests/test_pruefung.py b/backend/tests/test_pruefung.py index 0a40c62..06469e3 100644 --- a/backend/tests/test_pruefung.py +++ b/backend/tests/test_pruefung.py @@ -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 = ( "\nLogin\n\noptional\n\nAnmelden\n" "\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") diff --git a/backend/tests/test_struktur.py b/backend/tests/test_struktur.py index e9edd34..7b9cfd0 100644 --- a/backend/tests/test_struktur.py +++ b/backend/tests/test_struktur.py @@ -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): "\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": ""}, 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", + "\nLogin\n\nAnmelden\n" + "\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": ""}, pruefung.aufgaben(f)[1]) + assert probleme == [] # lieber nichts als ein Vorschlag, der wieder auffällt def test_gemessene_befunde_haengen_am_ausschnitt(datenordner): diff --git a/frontend/src/components/FeatureKarte.vue b/frontend/src/components/FeatureKarte.vue index 577cbae..db6c157 100644 --- a/frontend/src/components/FeatureKarte.vue +++ b/frontend/src/components/FeatureKarte.vue @@ -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() {

Regeln

+