update
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
55
backend/tests/test_source_links.py
Normal file
55
backend/tests/test_source_links.py
Normal 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
|
||||
57
backend/tests/test_textkit.py
Normal file
57
backend/tests/test_textkit.py
Normal 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("12–15") == "12–15"
|
||||
|
||||
|
||||
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³))"
|
||||
Reference in New Issue
Block a user