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