Files
creator2/backend/qa.py
2026-07-10 15:43:11 +02:00

88 lines
3.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 guide
import inventar
import korpus
import llm
import struktur
log = logging.getLogger("creator2.qa")
MODULE = {"korpus": korpus, "inventar": inventar, "artefakte": artefakte,
"struktur": struktur, "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,
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5,
"section_fehlt": 3.0, "marker_fehlend": 3.0, "ziel_ohne_anker": 3.0,
"fachlich_falsch": 3.0, "laenge": 0.5, "vorwaerts": 0.5,
}
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')",
"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.
Bestehende offene Zeilen bleiben (kein Duplikat)."""
alte = db.query("SELECT * FROM befunde WHERE run_id=? AND ebene=? AND status='offen'",
(ctx.run_id, ebene))
neu_keys = {(b["art"], str(b["item"])) for b in befunde}
alt_keys = set()
for alt in alte:
key = (alt["art"], alt["item"])
if key in neu_keys:
alt_keys.add(key)
else:
db.update("befunde", "id", alt["id"], status="repariert")
for b in befunde:
if (b["art"], str(b["item"])) not in alt_keys:
db.insert("befunde", run_id=ctx.run_id, ebene=ebene, art=b["art"],
item=str(b["item"]), detail=b["detail"][: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