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

View File

@@ -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"]