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