Files
creator2/backend/qa.py
2026-07-12 20:02:13 +02:00

105 lines
4.7 KiB
Python

"""QA-Rahmen. Zwei Sorten Messung, strikt getrennt (Kernprinzip):
- Invarianten: deterministisch, je Ebene in deren messen() — können echt 100 % erreichen.
- Soll-QA: Abgleich gegen das eingefrorene Soll (steckt in den Invarianten von E1:
soll_ohne_atom / atom_ohne_soll — Judges vergleichen, raten nie).
Die Note ist eine deterministische Formel aus gemessenen Quoten (Lektion 70),
nie eine LLM-Schätzung. 10,0 gibt es nur bei null offenen Befunden."""
import logging
import artefakte
import db
import diagramme
import guide
import inventar
import korpus
import llm
import struktur
log = logging.getLogger("creator2.qa")
MODULE = {"korpus": korpus, "inventar": inventar, "artefakte": artefakte,
"struktur": struktur, "diagramme": diagramme, "guide": guide}
# Gewichte je Befund-Art: kritisch 3.0 (verletzt Kernprinzip), mittel 1.5, stil 0.5.
GEWICHTE = {
"korpus_leer": 3.0, "soll_leer": 3.0, "soll_wenig_belege": 1.5,
"soll_kandidat_offen": 3.0,
"atom_ohne_anker": 3.0, "atom_ohne_soll": 1.5, "soll_ohne_atom": 3.0,
"atom_ohne_flashcard": 1.5, "artefakt_unentschieden": 0.5,
"partition": 3.0, "band": 0.5, "vorwaerts_kante": 1.5, "zyklus": 3.0,
"level_konflikt": 3.0, "level_mix": 3.0, "kapitel_level": 3.0,
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5,
"kapitel_zerstueckelt": 1.5, "titel_katalog": 1.5, "quelle_unvollstaendig": 3.0,
"section_fehlt": 3.0, "marker_fehlend": 3.0, "marker_fremd": 3.0,
"ziel_ohne_anker": 3.0, "fachlich_falsch": 3.0, "laenge": 0.5, "vorwaerts": 0.5,
"det_check": 0.5, "fix_offen": 1.5,
"diagramm_parse_fehler": 3.0, "diagramm_ungegroundet": 3.0,
"diagramm_unverifiziert": 0.5, "diagramm_marker_tot": 3.0,
"beispiel_marker_tot": 3.0,
}
def _basis(topic: str, ebene: str) -> int:
"""Bezugsgröße der Quoten je Ebene (geprüfte Items)."""
z = {"korpus": "SELECT COUNT(*) n FROM soll WHERE topic=? AND status='bestaetigt'",
"inventar": "SELECT COUNT(*) n FROM atome WHERE topic=?"
" AND status NOT IN ('gemerged','verworfen')",
"artefakte": "SELECT COUNT(*) n FROM atome WHERE topic=?"
" AND status NOT IN ('gemerged','verworfen')",
"struktur": "SELECT COUNT(*) n FROM atome WHERE topic=?"
" AND status NOT IN ('gemerged','verworfen')",
"diagramme": "SELECT COUNT(*) n FROM bausteine WHERE topic=?",
"guide": "SELECT COUNT(*) n FROM bausteine WHERE topic=?"}[ebene]
row = db.one(z, (topic,))
return max(1, int(row["n"]) if row else 1)
def note(befunde: list[dict], basis: int) -> float:
if not befunde:
return 10.0
je_art: dict[str, int] = {}
for b in befunde:
je_art[b["art"]] = je_art.get(b["art"], 0) + 1
abzug = sum(GEWICHTE.get(art, 1.5) * min(1.0, n / basis) for art, n in je_art.items())
return round(min(9.9, max(0.0, 10.0 - abzug)), 1)
def _persistieren(ctx: llm.Kontext, ebene: str, befunde: list[dict]) -> None:
"""Offene Alt-Befunde, die nicht mehr auftreten → repariert; Neues → offen.
Schlüssel schließt detail EIN — mehrere Befunde gleicher (art, item) mit
verschiedenen Details (z. B. mehrere det_check je Section) sind legitim und
brauchen je eine Zeile; ohne detail kollabierten sie und alte Details blieben
für immer offen. topic-scoped (JOIN runs), damit auch offene Zeilen früherer
Läufe geschlossen werden, wenn der Befund nicht mehr auftritt."""
alte = db.query(
"SELECT b.* FROM befunde b JOIN runs r ON r.id=b.run_id"
" WHERE r.topic=? AND b.ebene=? AND b.status='offen'", (ctx.topic, ebene))
def schluessel(art, item, detail) -> tuple:
return (art, str(item), (detail or "")[:500])
neu_keys = {schluessel(b["art"], b["item"], b.get("detail", "")) for b in befunde}
alt_keys = set()
for alt in alte:
k = schluessel(alt["art"], alt["item"], alt["detail"])
if k in neu_keys:
alt_keys.add(k)
else:
db.update("befunde", "id", alt["id"], status="repariert")
for b in befunde:
if schluessel(b["art"], b["item"], b.get("detail", "")) not in alt_keys:
db.insert("befunde", run_id=ctx.run_id, ebene=ebene, art=b["art"],
item=str(b["item"]), detail=(b.get("detail") or "")[:500], status="offen")
async def messen(ctx: llm.Kontext, ebene: str, mit_llm: bool = True) -> tuple[float, list[dict]]:
modul = MODULE[ebene]
befunde = modul.messen(ctx)
if ebene == "guide" and mit_llm:
befunde = befunde + await guide.messen_llm(ctx)
_persistieren(ctx, ebene, befunde)
n = note(befunde, _basis(ctx.topic, ebene))
log.info("QA %s/%s: Note %.1f, %d Befunde", ctx.topic, ebene, n, len(befunde))
return n, befunde