This commit is contained in:
team3
2026-07-06 14:44:20 +02:00
parent f4c5116abb
commit cc27e53b9e
20 changed files with 504 additions and 267 deletions

View File

@@ -324,8 +324,9 @@ async def test_panel_confirms_demote(board_env, tmp_path, monkeypatch):
assert (await db.kanban_get_card(TOPIC, B, "b-2"))["stage"] == "dedup"
async def test_panel_overrules_single_vote(board_env, tmp_path, monkeypatch):
"""Nur 1 von 3 Panel-Stimmen bestätigt den Judge-Demote → Karte überlebt (Journal)."""
async def test_single_recheck_vote_confirms_proposal(board_env, tmp_path, monkeypatch):
"""M3.2: ein pass-1-Vorschlag wird schon von EINER Recheck-Stimme bestätigt → rejected.
(15/17 mehrheitlich geretteten Karten waren später QA-Dubletten — Overturn nur einstimmig.)"""
db, ctx, files = board_env
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-filter-recheck-", _confirm_votes({"1"}, {"fragments": {"1": 2}, "drop": []})),
@@ -335,6 +336,23 @@ async def test_panel_overrules_single_vote(board_env, tmp_path, monkeypatch):
("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
])
c1 = await db.kanban_get_card(TOPIC, B, "b-1")
assert c1["stage"] == "rejected"
assert c1["payload"]["reason"] == "fragment"
assert c1["payload"]["parent_norm"] == "codeblock"
async def test_unanimous_recheck_keep_overrules_proposal(board_env, tmp_path, monkeypatch):
"""M3.2: nur ein EINSTIMMIGES Recheck-Panel (0 Demote-Stimmen) hebt den pass-1-Vorschlag auf."""
db, ctx, files = board_env
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-filter-recheck-", _confirm_votes(set(), {"fragments": {"1": 2}, "drop": []})),
("-filter-", {"fragments": {"1": 2}, "drop": []}),
]))
await _run_filter(db, ctx, tmp_path, [
("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
])
assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "dedup"
journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8"))
assert journal["ueberstimmt"] == ["Blockzitat"]
@@ -587,6 +605,45 @@ def test_canonical_key_camel_and_catalogue():
assert k("Section 2.1 Matching") == k("Matching")
def test_canonical_key_glued_problem_suffix():
"""Verklebtes Kompositum-Suffix „…problem" wird abgetrennt, damit „Cliquenproblem" und
„Clique" denselben Blocking-Key teilen (aak: sonst nie Dedup-Kandidat). Fugen-n/-s + Plural
inklusive; die generische Suffix-Regel darf keine echten Varianten über-mergen."""
from blocks import _canonical_key as k
assert k("Clique") == k("Cliquenproblem") == k("Cliquenprobleme") != ""
assert k("Set Cover") == k("SetCover-Problem") # bestehender Hyphen-Pfad bleibt
assert k("SAT") != k("3-SAT") # Varianten-Ziffer bleibt Signal
assert k("Problem") != k("Clique") # bloßes „Problem" wird nicht zum Stamm
def test_reference_strip_and_is_reference():
"""Katalog-Nummern werden gestrippt, der Konzeptname bleibt; reine Nummern → leer + Referenz.
Reale aak-Schadensfälle (Satz-/Bemerkungs-Titel liefen wörtlich bis done)."""
from blocks import _reference_strip as strip, _is_reference as isref
assert strip("Satz 7.13 (Christofides)") == "Christofides"
assert strip("Satz 7.6: Kriterium für Eulerschen Kreis") == "Kriterium für Eulerschen Kreis"
assert strip("N P via nicht-deterministische Turingmaschine (Definition 6.19)") \
== "N P via nicht-deterministische Turingmaschine"
assert strip("Bemerkung 7.22") == ""
assert strip("Vertex Cover") == "Vertex Cover" # kein Katalog-Gerüst → unverändert
assert isref("Bemerkung 7.22") and isref("Satz 7.18") and isref("Korollar 6.18")
assert isref("Bedingung (**)")
assert not isref("Satz 7.13 (Christofides)") # hat Konzept → keine Referenz
assert not isref("Satz 7.6: Kriterium für Eulerschen Kreis")
assert not isref("P⊆NP") and not isref("Σ*") # kurze Symbole bleiben echt
def test_is_named_statement_construction_suffix():
"""Reduktion mit Konstruktions-Suffix (: / = nach dem Ziel) ist ein Fragment, kein Statement —
darf nicht mehr vor Demotion geschützt sein (aak: „3-SAT ≤ K-COLOR: G=(V,E) Konstruktion")."""
from blocks import _is_named_statement as named
assert not named("3-SAT ≤ K-COLOR: G=(V,E) Konstruktion")
assert named("3-SAT ≤ Clique") # saubere Reduktion bleibt geschützt
assert named("Clique → Vertex Cover")
assert not named("X ist NP-vollständig") # unäre Aussage bleibt demotable
assert named("Satz von Cook/Levin: SAT ist NP-vollständig ⇔ …") # benanntes Ergebnis bleibt
def test_relation_guard_ignores_trailing_scaffolding():
"""Trailing „Reduktion/Transformation" ist kein Operand — sonst blockt der Guard
den korrekten Merge; Richtungs-Konflikte bleiben erkannt."""
@@ -1213,6 +1270,49 @@ async def test_namecheck_ok_behaelt_titel(testdb, tmp_path, monkeypatch):
assert block["payload"]["description"] == "Eigene Beschreibung"
async def test_namecheck_strips_source_numbering(testdb, tmp_path, monkeypatch):
"""M1.2: wählt der Check-Judge einen numerierten Titel, wird die Katalog-Nummer deterministisch
zum Konzept gestrippt ('Satz 7.13 (Christofides)''Christofides')."""
db = testdb
async def fake_members(topic, cid):
return [{"norm": "satz 7.13 (christofides)", "title": "Satz 7.13 (Christofides)",
"description": "3/2-Approximation für metrisches TSP", "readers": ["r1"], "sources": []},
{"norm": "b", "title": "B", "description": "db", "readers": ["r2"], "sources": []}]
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
return "ok", payload((0, '{"best": 1}', "")) # Judge wählt den numerierten Member-Titel
monkeypatch.setattr(bi, "_member_rows", fake_members)
monkeypatch.setattr(bi, "run_single_slot", fake_slot)
payload = {"title": "Satz 7.13 (Christofides)", "description": "3/2-Approximation",
"main_norm": "satz 7.13 (christofides)"}
await db.kanban_upsert_card(TOPIC, B, "c7", "cluster", "naming_check", payload)
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
await bi._namecheck_one(ctx, _mk_flow(tmp_path), {"card_id": "c7", "payload": payload})
block = await db.kanban_get_card(TOPIC, B, "b-c7")
assert block["payload"]["title"] == "Christofides"
async def test_consensus_gate_single_reader_majority_quorum(testdb, tmp_path, monkeypatch):
"""M5.1: Einzel-Reader-Fund → clarify mit Mehrheits-Quorum statt Einstimmigkeit."""
db = testdb
monkeypatch.setattr(bi, "source_folder", lambda t: None) # thema: kein Korpus
async def fake_members(topic, cid):
return [{"title": "Seltenes Konzept", "description": "einmal erwähnt",
"readers": ["r1"], "supplement": False}]
monkeypatch.setattr(bi, "_member_rows", fake_members)
monkeypatch.setattr(bi, "_rep", lambda rows: rows[0])
await db.kanban_upsert_card(TOPIC, B, "cs", "cluster", "consensus_gate", {})
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
await bi._proc_consensus_gate(ctx, _mk_flow(tmp_path), [{"card_id": "cs", "payload": {}}])
card = await db.kanban_get_card(TOPIC, B, "cs")
assert card["stage"] == "clarify"
assert card["payload"]["quorum"] == "majority"
# ── Sanierung: Titel auf Korpus-Form, Beschreibungspflicht (QA: fremd/hygiene) ──────
def test_sanierung_schema_varianten():
@@ -1229,6 +1329,11 @@ def test_sanierung_noetig():
assert bi._sanierung_noetig({"title": "k-Coloring", "description": "d"}, ctoks) # kein Korpus-Anker
assert not bi._sanierung_noetig({"title": "k-Color", "description": "d"}, ctoks)
assert not bi._sanierung_noetig({"title": "k-Coloring", "description": "d"}, None) # thema: kein Korpus
# M1.3: Referenz-Titel / unbalancierte Klammer / >80 Zeichen triggern auch ohne Korpus (Singletons)
assert bi._sanierung_noetig({"title": "Bemerkung 7.22", "description": "d"}, None)
assert bi._sanierung_noetig({"title": "N P (Definition 6.19", "description": "d"}, None)
assert bi._sanierung_noetig({"title": "A" * 81, "description": "d"}, None)
assert not bi._sanierung_noetig({"title": "Clique", "description": "d"}, None)
def _sanierung_env(tmp_path, monkeypatch, antwort):

View File

@@ -93,9 +93,53 @@ def test_note_kalibrierung():
assert qa.note({"fremd": 1.0}) == 0.0 # komplett fremdes Inventar = 0, nicht 7.7
def test_note_verdacht_zaehlt_nicht():
"""dubletten_verdacht ist Verdachtsliste, kein Urteil — beeinflusst die Note nicht."""
assert qa.note({"dubletten_verdacht": 1.0}) == 10.0
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():

View File

@@ -0,0 +1,55 @@
"""Link source = curated URL list (one page per line, no crawl following)."""
from pathlib import Path
import pytest
from fastapi import HTTPException
import blocks
from pipeline import GenContext
from routes import _validate_source
# --- _validate_source: per-line validation ---------------------------------
def test_validate_source_link_multiline_ok():
_validate_source("link", "https://a.com/x\nhttp://b.com/y") # no raise
def test_validate_source_link_rejects_bad_line():
with pytest.raises(HTTPException):
_validate_source("link", "https://a.com/x\nftp://bad")
def test_validate_source_link_rejects_empty():
with pytest.raises(HTTPException):
_validate_source("link", " \n ")
# --- _prepare_source: load every line, all pages as content, no triage -----
async def test_prepare_source_link_loads_all_urls_as_content(testdb, tmp_path, monkeypatch):
topic = "T"
folder = tmp_path / "source"
folder.mkdir()
captured = {}
def fake_load(urls, target, *, cancelled=None):
captured["urls"] = list(urls)
for i, u in enumerate(urls):
(Path(target) / f"p{i}.txt").write_text(f"QUELLE: {u}\n\nInhalt {i}", encoding="utf-8")
return len(urls)
monkeypatch.setattr(blocks, "load_pages", fake_load)
monkeypatch.setattr(blocks, "_convert_pdfs", lambda f: None)
monkeypatch.setattr(blocks, "_crawl_done", lambda t: False)
monkeypatch.setattr(blocks, "_step_idx", lambda t, n: 0) # step list needs source.json — irrelevant here
ctx = GenContext(topic=topic, provider="claude", is_cancelled=lambda: False)
q = {"type": "link", "location": " https://a.com/x \n\nhttps://b.com/y\n", "spec": ""}
ok = await blocks._prepare_source(ctx, lambda *a, **k: None, {"arbeit": tmp_path}, q, folder, "")
assert ok is True
assert captured["urls"] == ["https://a.com/x", "https://b.com/y"] # split + strip + blanks dropped
content = await testdb.list_content(topic)
assert len(content) == 2 # every page kept, no relevance triage

View File

@@ -0,0 +1,57 @@
"""Titel-Hygiene: klammer-bewusster Split + Dash-Normalisierung (M1.1).
Reale aak-Schadensfälle: ein en-Dash mit einseitigem Space INNERHALB eines Titels
wurde zum Separator → Split mitten im Titel („Aε-Algorithmus (Güte 1+ε, Laufzeit O(n3").
"""
from textkit import _norm_dash, _title, _split_top, _unclosed, _parse_selection
def test_norm_dash_top_level_only():
# einseitiger Space am Dash → Separator (Modelle liefern das so)
assert _norm_dash("Titel Beschreibung") == "Titel — Beschreibung"
assert _norm_dash("Titel— Beschreibung") == "Titel — Beschreibung"
# Dash INNERHALB offener Klammer bleibt unangetastet (kein Separator)
assert _norm_dash("f (a b) — Rest") == "f (a b) — Rest"
# ASCII-Hyphen und geklebte/rangebasierte Dashes sind nie Separatoren
assert _norm_dash("3-SAT") == "3-SAT"
assert _norm_dash("1215") == "1215"
def test_split_top_bracket_aware():
assert _split_top("a (b — c) — d", "") == "a (b — c)"
assert _split_top("a — b", "") == "a"
assert _split_top("kein Separator hier", "") == "kein Separator hier"
def test_unclosed_bracket_detection():
assert _unclosed("a (b") is True
assert _unclosed("a [b {c") is True
assert _unclosed("a (b) c") is False
assert _unclosed("a) b") is False # überzähliger Schließer ist kein Abschnitt
def test_title_keeps_bracketed_dash():
"""Der Separator innerhalb der Klammer zerschneidet den Titel nicht mehr."""
t = _title("Aε-Algorithmus (Güte 1+ε Laufzeit O(n³)) — der Approx-Algorithmus")
assert t == "Aε-Algorithmus (Güte 1+ε Laufzeit O(n³))"
def test_title_plain_split_unchanged():
assert _title("Vertex Cover — minimale Knotenüberdeckung") == "Vertex Cover"
assert _title("Ohne Separator bleibt ganz") == "Ohne Separator bleibt ganz"
def test_title_unclosed_bracket_keeps_entry():
"""Malformed: offene Klammer nie geschlossen → keinen abgeschnittenen Titel ausgeben,
stattdessen den ganzen Eintrag behalten (Repair = Split rückgängig)."""
e = "N P via Turingmaschine (Definition 6.19 — Rest"
assert _title(e) == e
def test_parse_selection_then_title_regression():
"""End-to-end der Parse-Kette (wie in der Pipeline): _norm_dash + _title lassen den
Klammer-Inhalt intakt statt mitten im Titel zu splitten (der reale aak-Bug)."""
line = "1. Aε-Algorithmus (Güte 1+ε Laufzeit O(n³)) — der Approximations-Algorithmus"
entries = _parse_selection(line)
assert _title(entries[1]) == "Aε-Algorithmus (Güte 1+ε Laufzeit O(n³))"