104 lines
4.7 KiB
Python
104 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,
|
|
}
|
|
|
|
|
|
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
|