"""QA-Detektoren: Fehler-Injektion auf Mini-Korpus — deterministisch, ohne LLM/Embedding.""" import json import qa CORPUS = {"Skript.txt": ( "Kapitel 1: Vertex Cover — Definition, Approximation und Beweis der Guete.\n\n" "Kapitel 2: Matching in Graphen — perfektes Matching und Augmentationswege.")} BLOCKS = [ {"title": "Vertex Cover", "description": "Knotenüberdeckung", "sources": ["Skript.txt"]}, {"title": "Matching", "description": "Paarung in Graphen", "sources": ["Skript.txt"]}, ] SUBS = {"vertex cover": ["Approximation der Guete"], "matching": ["Matching in Graphen", "Augmentationswege"]} def test_baseline_clean(monkeypatch): """Sauberes Soll-Inventar → alle Detektoren still (jeder Absatz ein Abschnitt).""" monkeypatch.setattr(qa, "SECTION_CHARS", 20) assert qa.dubletten(BLOCKS, emb_on=False) == [] assert qa.luecken(BLOCKS, SUBS, CORPUS) == [] assert qa.fremd(BLOCKS, CORPUS) == [] assert qa.hygiene(BLOCKS) == [] def test_injected_duplicate_found(): b = BLOCKS + [{"title": "Vertex-Cover-Problem", "description": "", "sources": []}] pairs = qa.dubletten(b, emb_on=False) assert any({p["a"], p["b"]} == {"Vertex Cover", "Vertex-Cover-Problem"} for p in pairs) def test_acronym_signal(): b = BLOCKS + [{"title": "VC (Vertex Cover)", "description": "", "sources": []}] pairs = qa.dubletten(b, emb_on=False) hit = next(p for p in pairs if "VC (Vertex Cover)" in (p["a"], p["b"]) and "Vertex Cover" in (p["a"], p["b"])) assert hit["signale"].get("akronym") is True def test_relation_operand_not_suspicious(): """Relation vs. Operand ist per Design getrennt — kein Verdachtspaar.""" b = BLOCKS + [{"title": "3-SAT ≤ Vertex Cover", "description": "", "sources": []}] pairs = qa.dubletten(b, emb_on=False) assert not any("≤" in p["a"] + p["b"] for p in pairs) def test_removed_block_creates_gap(monkeypatch): monkeypatch.setattr(qa, "SECTION_CHARS", 20) only_vc = [BLOCKS[0]] gaps = qa.luecken(only_vc, {"vertex cover": SUBS["vertex cover"]}, CORPUS) assert len(gaps) == 1 and "Matching" in gaps[0]["vorschau"] def test_foreign_block_flagged(): b = BLOCKS + [{"title": "Quantencomputer Grundlagen", "description": "", "sources": []}] assert qa.fremd(b, CORPUS) == ["Quantencomputer Grundlagen"] def test_beleg_flags_unbacked_sub(): rows = [{"block": "Matching", "sub_title": "Erfunden", "mentions": 0, "status": "consensus"}, {"block": "Matching", "sub_title": "Belegt", "mentions": 3, "status": "consensus"}] r = qa.beleg([{"title": "Matching", "sources": []}], rows) assert r["subs_ohne_beleg"] == ["Matching · Erfunden"] assert r["bloecke_ohne_quelle"] == ["Matching"] def test_hygiene_flags(): b = [{"title": "**Fett**", "description": "", "sources": []}, {"title": "Block (2)", "description": "ok", "sources": []}] h = {x["titel"]: x["probleme"] for x in qa.hygiene(b)} assert "markdown" in h["**Fett**"] and "leere-beschreibung" in h["**Fett**"] assert h["Block (2)"] == ["kollisions-suffix"] def test_sections_split_on_paragraphs(): secs = qa._sections("a\n\nb\n\nc", goal=3) assert len(secs) >= 2 and "".join(secs).replace("\n", "") == "abc" def test_note_deterministic_and_monotonic(): """Saubere Quoten → 10; jede zusätzliche Quote drückt die Note.""" sauber = {k: 0 for k in qa.NOTE_GEWICHTE} assert qa.note(sauber) == 10.0 schlechter = dict(sauber, luecken=0.05) noch_schlechter = dict(schlechter, fremd=0.05) assert 10.0 > qa.note(schlechter) > qa.note(noch_schlechter) >= 0.0 assert qa.note({k: 1 for k in qa.NOTE_GEWICHTE}) == 0.0 def test_note_kalibrierung(): """Gewicht = Punktabzug bei 100 %: 5 % Fremd × 2.5 → −1.25 → 8.8 gerundet.""" assert qa.note({"fremd": 0.05}) == 8.8 assert qa.note({"fremd": 1.0}) == 0.0 # komplett fremdes Inventar = 0, nicht 7.7 def test_note_dubletten_verdacht_zaehlt(): """dubletten_verdacht bildet das Dopplungs-Nutzerproblem ab → zählt jetzt (Gewicht 1.0), bleibt aber das leichteste Gewicht. 36 % Verdacht → −3.6 → 6.4; die Liste kann >1 sein → geklemmt.""" assert qa.note({"dubletten_verdacht": 0.0}) == 10.0 assert qa.note({"dubletten_verdacht": 0.36}) == 6.4 assert qa.note({"dubletten_verdacht": 2.0}) == 0.0 # >1 wird auf 1.0 geklemmt # sub_dubletten_verdacht bleibt gewichtslos (Artefakt-Verdacht, kein Urteil) assert qa.note({"sub_dubletten_verdacht": 1.0}, qa.NOTE_GEWICHTE_ARTEFAKTE) == 10.0 def test_note_konzept_luecken_zaehlt(): """Fehlende benannte Kernresultate drücken die Note (Gewicht 1.5): 20 % → −3.0 → 7.0.""" assert qa.note({"konzept_luecken": 0.0}) == 10.0 assert qa.note({"konzept_luecken": 0.2}) == 7.0 def test_named_results_only_numbered(monkeypatch): """Benannte Ergebnisse: nur NUMMERIERTE Katalog-Referenzen mit Namen zählen — Prosa ohne Nummer ('Satz von Goethe') und nackte Nummern ('Satz 7.18') liefern nichts (generisch).""" corpus = {"skript.txt": "Satz 7.13 (Christofides) liefert eine 3/2-Approximation.\n" "Satz 6.24: Satz von Cook und Levin.\n" "Nach Satz 7.18 folgt daraus die Schranke.\n" "Ein Satz von Goethe steht hier."} named = qa._named_results(corpus) assert "Christofides" in named assert any("Cook" in n and "Levin" in n for n in named) assert not any("Goethe" in n for n in named) # keine Nummer → kein False Positive assert all(n.strip() for n in named) def test_konzept_luecken_flags_missing(): """Benanntes Kernresultat ohne Baustein = Lücke; ein gedecktes Resultat nicht.""" corpus = {"s.txt": "Satz 7.13 (Christofides). Satz 6.16 (Kriterium für P=NP)."} named = qa._named_results(corpus) blocks = [{"title": "Christofides-Algorithmus", "description": "3/2-Approximation für TSP"}] gaps = qa.konzept_luecken(blocks, named) assert "Christofides" not in gaps assert any("P=NP" in g or "Kriterium" in g for g in gaps) def test_konzept_luecken_declension_tolerant(): """Andere Flexion im Baustein deckt das Resultat trotzdem — kein falscher Lücken-Alarm.""" named = qa._named_results({"s.txt": "Satz 7.6: Kriterium für Eulerschen Kreis."}) blocks = [{"title": "Kriterium für Eulerscher Kreis", "description": "Grad aller Knoten gerade"}] assert qa.konzept_luecken(blocks, named) == [] def test_note_artefakte_getrennt(): """Subs/Artefakte haben eigene Gewichte — zur Gate-Zeit existieren sie noch nicht und dürfen die Inventar-Note weder schönen noch drücken.""" assert "subs_ohne_beleg" not in qa.NOTE_GEWICHTE assert qa.note({"subs_ohne_beleg": 0.0, "verwaiste": 0.1}, qa.NOTE_GEWICHTE_ARTEFAKTE) == 9.0 assert qa.note({"subs_ohne_beleg": 1.0}, qa.NOTE_GEWICHTE_ARTEFAKTE) == 0.0 def test_artefakte_coverage_and_orphans(): subs = [{"block_norm": "b", "sub_norm": "s1: lange beschreibung", "status": "consensus"}, {"block_norm": "b", "sub_norm": "s2", "status": "consensus"}, {"block_norm": "b", "sub_norm": "alt", "status": "variant"}, {"block_norm": "b", "sub_norm": "weg", "status": "discarded"}] arts = [{"block_norm": "b", "sub_norm": "s1", "type": "flashcard"}, # Präfix-Treffer {"block_norm": "b", "sub_norm": "alt", "type": "flashcard"}, # variant → lebt, keine Waise {"block_norm": "b", "sub_norm": "weg", "type": "flashcard"}, # verworfen → Waise {"block_norm": "b", "sub_norm": "tot", "type": "flashcard"}] # fehlt → Waise fragen = [{"block_norm": "b", "sub_norm": "s1: lange beschreibung"}, {"block_norm": "b", "sub_norm": "s2"}] r = qa.artefakte(subs, arts, fragen) assert r["frage_abdeckung"] == 1.0 assert r["flashcard_abdeckung"] == 0.5 # nur s1 der beiden consensus-Subs assert r["verwaiste"] == ["flashcard: b · tot", "flashcard: b · weg"] def test_artefakte_prefix_family_resolves_to_consensus(): """Kurz-Key trifft consensus-Sub PLUS gefaltete Varianten mit gleichem Präfix — das ist keine Waise, das Ziel ist der consensus-Sub.""" subs = [{"block_norm": "b", "sub_norm": "auto: echte fassung", "status": "consensus"}, {"block_norm": "b", "sub_norm": "auto: variante eins", "status": "variant"}, {"block_norm": "b", "sub_norm": "auto: variante zwei", "status": "variant"}] arts = [{"block_norm": "b", "sub_norm": "auto", "type": "example"}] r = qa.artefakte(subs, arts, []) assert r["verwaiste"] == [] assert r["beispiel_abdeckung"] == 1.0 def test_artefakte_not_generated(): assert qa.artefakte([{"block_norm": "b", "sub_norm": "s", "status": "consensus"}], [], []) == {"status": "nicht generiert"} def test_fremd_glued_prefix_not_whitewashed(): """'αÜbergang' darf nicht über den Substring 'bergang'⊂'Übergang' als belegt gelten; Symbol-Varianten (Δ/∆) bleiben über die ASCII-Form gedeckt.""" corpus = {"S.txt": "Der Übergang ist wichtig.\n\nDer ∆TSP1 Algorithmus folgt."} b = [{"title": "αÜbergang", "description": "", "sources": []}, {"title": "ΔTSP1-Algorithmus", "description": "", "sources": []}] assert qa.fremd(b, corpus) == ["αÜbergang"] def test_note_ignores_unmeasured_quotes(): """unechte_bloecke zählt nur, wenn gemessen (--llm) — sonst weder Schaden noch Schönung.""" ohne = {k: 0.02 for k in qa.NOTE_GEWICHTE if k != "unechte_bloecke"} mit_null = dict(ohne, unechte_bloecke=0.0) mit_schaden = dict(ohne, unechte_bloecke=0.5) assert qa.note(mit_null) == qa.note(ohne) assert qa.note(mit_schaden) < qa.note(ohne) class _FakeEmb: """Gleicher Text → gleicher Einheitsvektor, sonst orthogonal (cos 1.0 / 0.0).""" @staticmethod def available(): return True @staticmethod def embed(texts): import numpy as np uniq = {t: k for k, t in enumerate(dict.fromkeys(texts))} arr = np.zeros((len(texts), max(len(uniq), 1))) for r, t in enumerate(texts): arr[r, uniq[t]] = 1.0 return arr def test_sub_dubletten_detector(monkeypatch): """Kandidaten in-block UND cross-block; nur consensus-Subs zählen.""" monkeypatch.setattr(qa, "embedding", _FakeEmb) rows = [{"block": "Alpha", "block_norm": "alpha", "sub_title": "Gleiche Aussage", "status": "consensus"}, {"block": "Beta", "block_norm": "beta", "sub_title": "Gleiche Aussage", "status": "consensus"}, {"block": "Beta", "block_norm": "beta", "sub_title": "Andere Aussage", "status": "consensus"}, {"block": "Beta", "block_norm": "beta", "sub_title": "Gleiche Aussage", "status": "variant"}] pairs = qa.sub_dubletten(rows) assert len(pairs) == 1 assert pairs[0]["cross"] is True and pairs[0]["cos"] == 1.0 assert qa.sub_dubletten(rows, emb_on=False) == [] def test_note_sub_dubletten(): """Bestätigte Sub-Dubletten drücken die Artefakt-Note; der bloße Verdacht nicht.""" assert qa.note({"sub_dubletten": 0.1}, qa.NOTE_GEWICHTE_ARTEFAKTE) == 9.0 assert qa.note({"sub_dubletten_verdacht": 1.0}, qa.NOTE_GEWICHTE_ARTEFAKTE) == 10.0 def test_zaehlbare_luecken(): """Mit LLM zählen widerlegte Lücken nicht; unbeurteilte ('?'/ohne Key) konservativ schon.""" lk = [{"llm": "ja"}, {"llm": "nein"}, {"llm": "?"}, {}] assert len(qa._zaehlbare_luecken(lk, llm=True)) == 3 assert len(qa._zaehlbare_luecken(lk, llm=False)) == 4 def test_description_anchors_cover(monkeypatch): """Am Gate existieren keine Subs — Beschreibungs-Tokens müssen Abschnitte decken.""" monkeypatch.setattr(qa, "SECTION_CHARS", 20) corpus = {"S.txt": "Kapitel 9: Augmentationswege und perfektes Matching."} block = [{"title": "Paarungen", "description": "perfektes Matching mit Augmentationswege", "sources": []}] assert qa.luecken(block, {}, corpus) == [] ohne = [{"title": "Paarungen", "description": "", "sources": []}] assert len(qa.luecken(ohne, {}, corpus)) == 1 def test_fremd_digit_suffix_tolerant(): """'ΔTSP1' matcht Korpus-'∆TSP' (tokenisiert zu 'tsp') via Ziffern-Suffix-Fallback.""" corpus = {"S.txt": "Der ∆TSP Algorithmus verdoppelt Kanten im Graphen."} b = [{"title": "ΔTSP1-Algorithmus", "description": "", "sources": []}, {"title": "Quantencomputer", "description": "", "sources": []}] assert qa.fremd(b, corpus) == ["Quantencomputer"] async def test_unecht_braucht_doppelt_nein(testdb, tmp_path, monkeypatch): """Echtheits-Urteil zählt nur nach Bestätiger-Pass: der Einzel-Judge flaggte pro Lauf andere Blöcke und pendelte die Note (aak: 9.3↔10.0 bei identischem Bestand).""" db = testdb for cid, titel in (("b1", "Wackelkandidat"), ("b2", "Zufallstreffer"), ("b3", "Solide")): await db.kanban_upsert_card("t", "inventory", cid, "block", "done_block", {"title": titel, "description": "d"}) monkeypatch.setattr(qa, "QA_DIR", tmp_path) async def fake_wave(template, topic, key, slot, items, **kw): if template != "QA-Bausteine": return {} if key.startswith("bausteine-b2"): # Bestätiger sieht nur die Geflaggten assert len(items) == 2 return {1: "nein", 2: "ja"} # nur der erste wird bestätigt return {1: "nein", 2: "nein", 3: "ja"} # Pass 1 flaggt zwei monkeypatch.setattr(qa, "judge_wave", fake_wave) report = await qa.qa_report("t", llm=True) assert report["unecht"] == ["Wackelkandidat"] async def test_write_report_spiegelt_note_als_event(testdb, tmp_path, monkeypatch): """Report-JSONs liegen nur auf der Lauf-Maschine — write_report spiegelt Note/Quoten als kind='qa'-Event in die DB, damit ein DB-Pull für die Run-Analyse reicht.""" db = testdb monkeypatch.setattr(qa, "QA_DIR", tmp_path) report = {"topic": "t", "run_id": "20260704-1452-b223", "note": 9.3, "note_artefakte": 8.0, "quoten": {"luecken": 0.1}, "quoten_artefakte": {"verwaiste": 0.0}} path = await qa.write_report(report) assert path.stem == "20260704-1452-b223" conn = await db.get_db() row = await (await conn.execute( "SELECT key, meta, run_id FROM events WHERE topic='t' AND kind='qa'")).fetchone() assert row and row[0] == "20260704-1452-b223" meta = json.loads(row[1]) assert meta["note"] == 9.3 and meta["note_artefakte"] == 8.0 assert meta["quoten"] == {"luecken": 0.1} and meta["quoten_artefakte"] == {"verwaiste": 0.0} assert row[2] == "" # manuelle QA ohne Lauf → leeres run_id ist korrekt async def test_topic_delete_entfernt_qa_ordner(testdb, tmp_path, monkeypatch): """DELETE /topics räumt auch storage/qa// — Reports gehören zum Topic.""" import routes monkeypatch.setattr(routes, "topic_dir", lambda t: tmp_path / "topics" / t) monkeypatch.setattr(qa, "QA_DIR", tmp_path / "qa") qdir = tmp_path / "qa" / "t" qdir.mkdir(parents=True) (qdir / "alt.json").write_text("{}", encoding="utf-8") await routes.remove_topic("t") assert not qdir.exists() def test_luecken_key_stabil_gegen_whitespace(): """Freispruch-Schlüssel: Datei + normalisierter Vorschau-Hash — Whitespace-Varianten derselben Fundstelle mappen auf denselben Key, andere Vorschau nicht.""" a = {"datei": "f.txt", "abschnitt": 3, "vorschau": "Der Satz\nvon Foo"} b = {"datei": "f.txt", "abschnitt": 7, "vorschau": "der satz von foo"} c = {"datei": "f.txt", "abschnitt": 3, "vorschau": "ganz anderer Text"} assert qa.luecken_key(a) == qa.luecken_key(b) assert qa.luecken_key(a) != qa.luecken_key(c) assert qa.luecken_key(a).startswith("f.txt||") def test_offene_luecken_filtert_freispruch_und_nein(): rep = {"luecken": [{"datei": "f", "vorschau": "a", "llm": "ja"}, {"datei": "f", "vorschau": "b", "llm": "nein"}, {"datei": "f", "vorschau": "c", "freispruch": True}], "konzept_luecken": ["Satz von Foo"]} lk, kl = qa.offene_luecken(rep) assert [x["vorschau"] for x in lk] == ["a"] and kl == ["Satz von Foo"] def test_named_results_ueberspringt_umbrochene_saetze(): """Doppelpunkt-Fang, der am Zeilenumbruch endet und klein weiterläuft, ist ein umbrochener SATZ, kein Konzeptname — solche Phantom-Lücken kann kein Block ankern. Echte Namen (auch \\n-terminiert mit großer Folgezeile) bleiben.""" text = ("Konsequenz von Satz 6.16: Wenn ein NP-vollständiges Entscheidungsproblem in P\n" "liegt, dann sind alle NP Entscheidungsprobleme in P.\n\n" "Satz 7.13 (Christofides). Beweis folgt.\n" "Satz 6.24: Cook-Levin.\n" "Satz 9.1: Vier-Farben-Satz\nDer Beweis nutzt Computer.\n") assert sorted(qa._named_results({"f": text})) == ["Christofides", "Cook-Levin", "Vier-Farben-Satz"]