update
This commit is contained in:
255
backend/tests/test_agents_api.py
Normal file
255
backend/tests/test_agents_api.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""Direkter Text-API-Pfad (MiniMax) + RAM-Gate für opencode-Spawns."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import agents
|
||||
|
||||
TOPIC = "t"
|
||||
|
||||
|
||||
# ── Routing: wann läuft ein Call über die API statt über opencode? ───────────────────
|
||||
|
||||
def test_use_text_api_routing(monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "k")
|
||||
monkeypatch.delenv("CREATOR_TEXT_API", raising=False)
|
||||
api = agents._use_text_api
|
||||
assert api("minimax", "minimax/MiniMax-M3", "none", None) is True
|
||||
assert api("minimax", "minimax-kalt/MiniMax-M2.7-highspeed", "none", None) is True
|
||||
# Tools, Streaming, andere Provider/Modelle → Prozess-Pfad
|
||||
assert api("minimax", "minimax/MiniMax-M3", "files", None) is False
|
||||
assert api("minimax", "minimax/MiniMax-M3", "none", lambda s: None) is False
|
||||
assert api("claude", "claude-sonnet-4-6", "none", None) is False
|
||||
assert api("lokal", "ollama/qwen3.5:9b", "none", None) is False
|
||||
# Kill-Switch und fehlender Key → Fallback
|
||||
monkeypatch.setenv("CREATOR_TEXT_API", "0")
|
||||
assert api("minimax", "minimax/MiniMax-M3", "none", None) is False
|
||||
monkeypatch.delenv("CREATOR_TEXT_API")
|
||||
monkeypatch.delenv("MINIMAX_API_KEY")
|
||||
assert api("minimax", "minimax/MiniMax-M3", "none", None) is False
|
||||
|
||||
|
||||
async def test_run_agent_dispatches_to_api(monkeypatch):
|
||||
"""none+minimax → API-Runner; opencode wird nicht angefasst (auch kein which-Check)."""
|
||||
called = {}
|
||||
|
||||
async def fake_api(agent_key, prompt, timeout, model, label=""):
|
||||
called["api"] = (agent_key, model)
|
||||
return 0, "out", "", {"input": 1, "output": 1, "reasoning": 0, "cache_read": 0, "cache_write": 0}
|
||||
|
||||
async def fail_oc(*a, **kw):
|
||||
raise AssertionError("opencode-Pfad darf nicht laufen")
|
||||
|
||||
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "k")
|
||||
monkeypatch.setattr(agents, "_run_text_api", fake_api)
|
||||
monkeypatch.setattr(agents, "_run_opencode", fail_oc)
|
||||
monkeypatch.setattr(agents.shutil, "which", lambda c: None) # API-Pfad braucht kein Binary
|
||||
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("minimax", "minimax/MiniMax-M3"))
|
||||
rc, out, err = await agents.run_agent("blocks-t-a", "p", 5, provider="minimax", role="judge")
|
||||
assert (rc, out) == (0, "out") and called["api"][1] == "minimax/MiniMax-M3"
|
||||
|
||||
|
||||
async def test_run_agent_kill_switch_uses_opencode(monkeypatch):
|
||||
called = {}
|
||||
|
||||
async def fake_oc(agent_key, prompt, timeout, provider, model, capabilities, on_line=None, label=""):
|
||||
called["oc"] = agent_key
|
||||
return 0, "out", ""
|
||||
|
||||
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "k")
|
||||
monkeypatch.setenv("CREATOR_TEXT_API", "0")
|
||||
monkeypatch.setattr(agents, "_run_opencode", fake_oc)
|
||||
monkeypatch.setattr(agents.shutil, "which", lambda c: "/bin/true")
|
||||
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("minimax", "minimax/MiniMax-M3"))
|
||||
rc, *_ = await agents.run_agent("blocks-t-b", "p", 5, provider="minimax")
|
||||
assert rc == 0 and called["oc"] == "blocks-t-b"
|
||||
|
||||
|
||||
async def test_run_agent_api_tokens_in_event_meta(monkeypatch):
|
||||
"""API-Tokens landen im Event-Meta; die OpenCode-Session-DB wird NICHT konsultiert."""
|
||||
recorded = []
|
||||
|
||||
async def sink(**kw):
|
||||
recorded.append(kw)
|
||||
|
||||
async def fake_api(agent_key, prompt, timeout, model, label=""):
|
||||
return 0, "out", "", {"input": 5, "output": 2, "reasoning": 0, "cache_read": 100, "cache_write": 0}
|
||||
|
||||
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "k")
|
||||
monkeypatch.setattr(agents, "on_event", sink)
|
||||
monkeypatch.setattr(agents, "_run_text_api", fake_api)
|
||||
monkeypatch.setattr(agents, "_session_tokens", lambda k: pytest.fail("Session-DB-Lookup im API-Pfad"))
|
||||
monkeypatch.setattr(agents.shutil, "which", lambda c: "/bin/true")
|
||||
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("minimax", "minimax/MiniMax-M3"))
|
||||
rc, *_ = await agents.run_agent("blocks-t-tok", "p", 5, provider="minimax", scope=TOPIC)
|
||||
assert rc == 0
|
||||
assert recorded and recorded[0]["meta"]["tokens"] == {
|
||||
"input": 5, "output": 2, "reasoning": 0, "cache_read": 100, "cache_write": 0}
|
||||
|
||||
|
||||
# ── API-Runner: Request-Bau, Antwort-Extraktion, Fehlerfälle ─────────────────────────
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, status_code=200, data=None, text=""):
|
||||
self.status_code = status_code
|
||||
self._data = data or {}
|
||||
self.text = text
|
||||
|
||||
def json(self):
|
||||
return self._data
|
||||
|
||||
|
||||
def _fake_client(monkeypatch, seen, resp=None, exc=None):
|
||||
class _Client:
|
||||
def __init__(self, **kw):
|
||||
seen["client_kw"] = kw
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def post(self, url, json=None, headers=None):
|
||||
seen.update(url=url, body=json, headers=headers)
|
||||
if exc is not None:
|
||||
raise exc
|
||||
return resp
|
||||
|
||||
monkeypatch.setattr(agents.httpx, "AsyncClient", _Client)
|
||||
|
||||
|
||||
async def test_api_request_and_response(monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "geheim")
|
||||
seen = {}
|
||||
resp = _FakeResp(data={
|
||||
"content": [{"type": "thinking", "thinking": "hm"},
|
||||
{"type": "text", "text": "A"}, {"type": "text", "text": "B"}],
|
||||
"usage": {"input_tokens": 5, "output_tokens": 2,
|
||||
"cache_read_input_tokens": 100, "cache_creation_input_tokens": 1},
|
||||
"stop_reason": "end_turn"})
|
||||
_fake_client(monkeypatch, seen, resp=resp)
|
||||
rc, out, err, tokens = await agents._run_text_api("k", "PROMPT", 5, "minimax-kalt/MiniMax-M3")
|
||||
assert (rc, out, err) == (0, "AB", "") # Thinking-Block übersprungen
|
||||
assert tokens == {"input": 5, "output": 2, "reasoning": 0, "cache_read": 100, "cache_write": 1}
|
||||
assert seen["url"] == agents._API_URL
|
||||
assert seen["headers"]["x-api-key"] == "geheim"
|
||||
assert seen["headers"]["anthropic-version"] == agents._API_VERSION
|
||||
b = seen["body"]
|
||||
assert b["model"] == "MiniMax-M3" and b["max_tokens"] == agents._API_MAX_TOKENS
|
||||
assert b["messages"] == [{"role": "user", "content": "PROMPT"}]
|
||||
assert b["temperature"] == 0.2 and b["thinking"] == {"type": "disabled"} # kalt-Route
|
||||
|
||||
|
||||
async def test_api_model_options_per_route(monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "k")
|
||||
seen = {}
|
||||
resp = _FakeResp(data={"content": [{"type": "text", "text": "x"}], "usage": {}})
|
||||
_fake_client(monkeypatch, seen, resp=resp)
|
||||
await agents._run_text_api("k", "p", 5, "minimax-kalt/MiniMax-M2.7-highspeed")
|
||||
assert seen["body"]["temperature"] == 0.3 and "thinking" not in seen["body"]
|
||||
await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3") # nativ: Endpunkt-Defaults
|
||||
assert "temperature" not in seen["body"] and "thinking" not in seen["body"]
|
||||
|
||||
|
||||
async def test_api_errors(monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "k")
|
||||
seen = {}
|
||||
_fake_client(monkeypatch, seen, resp=_FakeResp(status_code=500, text="kaputt"))
|
||||
rc, out, err, tokens = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
|
||||
assert rc == 1 and out == "" and "HTTP 500" in err and tokens is None
|
||||
|
||||
_fake_client(monkeypatch, seen, exc=httpx.ConnectError("down"))
|
||||
rc, _, err, _ = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
|
||||
assert rc == 1 and "ConnectError" in err
|
||||
|
||||
_fake_client(monkeypatch, seen, exc=httpx.ReadTimeout("langsam"))
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
|
||||
|
||||
# leere Antwort (nur Thinking) → rc 1, Tokens bleiben sichtbar
|
||||
resp = _FakeResp(data={"content": [{"type": "thinking", "thinking": "…"}],
|
||||
"usage": {"input_tokens": 3}, "stop_reason": "end_turn"})
|
||||
_fake_client(monkeypatch, seen, resp=resp)
|
||||
rc, _, err, tokens = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
|
||||
assert rc == 1 and "empty response" in err and tokens["input"] == 3
|
||||
|
||||
|
||||
async def test_api_truncation_flagged(monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "k")
|
||||
seen = {}
|
||||
resp = _FakeResp(data={"content": [{"type": "text", "text": "halb"}],
|
||||
"usage": {}, "stop_reason": "max_tokens"})
|
||||
_fake_client(monkeypatch, seen, resp=resp)
|
||||
rc, out, err, _ = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
|
||||
assert rc == 0 and out == "halb" and "max_tokens" in err
|
||||
|
||||
|
||||
# ── RAM-Gate ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def ram_gate(monkeypatch):
|
||||
monkeypatch.setattr(agents, "RAM_MIN_FREE_PCT", 20)
|
||||
monkeypatch.setattr(agents, "_RAM_POLL_S", 0.01)
|
||||
monkeypatch.setattr(agents, "_opencode_recent_starts", [])
|
||||
return monkeypatch
|
||||
|
||||
|
||||
async def test_ram_gate_admits_with_free_ram(ram_gate):
|
||||
ram_gate.setattr(agents, "_opencode_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: (4_000_000, 8_000_000)) # 50 % frei
|
||||
assert await agents._ram_gate("k") is True
|
||||
assert len(agents._opencode_recent_starts) == 1 # Commit registriert
|
||||
|
||||
|
||||
async def test_ram_gate_waits_when_low(ram_gate):
|
||||
ram_gate.setattr(agents, "_opencode_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: (800_000, 8_000_000)) # 10 % frei
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(agents._ram_gate("k"), 0.1)
|
||||
# RAM wird frei → Gate lässt nach ≥1 Poll durch
|
||||
vals = iter([(800_000, 8_000_000)])
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: next(vals, (4_000_000, 8_000_000)))
|
||||
assert await agents._ram_gate("k") is True
|
||||
|
||||
|
||||
async def test_ram_gate_floor_and_fail_open(ram_gate):
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: (100_000, 8_000_000)) # fast nichts frei
|
||||
ram_gate.setattr(agents, "_opencode_running", 1) # unter Floor
|
||||
assert await agents._ram_gate("k") is True
|
||||
ram_gate.setattr(agents, "_opencode_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: None) # kein /proc/meminfo
|
||||
assert await agents._ram_gate("k") is True
|
||||
ram_gate.setattr(agents, "RAM_MIN_FREE_PCT", 0) # Gate aus
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: pytest.fail("Gate aus liest kein meminfo"))
|
||||
assert await agents._ram_gate("k") is True
|
||||
|
||||
|
||||
async def test_ram_gate_commit_accounting(ram_gate):
|
||||
"""Knapp über der Schwelle, aber 2 frische Zulassungen → deren geschätzter RSS zählt."""
|
||||
import time as _time
|
||||
ram_gate.setattr(agents, "_opencode_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: (1_700_000, 8_000_000)) # 21 % frei
|
||||
agents._opencode_recent_starts.extend([_time.monotonic(), _time.monotonic()])
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(agents._ram_gate("k"), 0.1)
|
||||
|
||||
|
||||
async def test_ram_gate_cancelled_scope(ram_gate):
|
||||
ram_gate.setattr(agents, "_opencode_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: (800_000, 8_000_000))
|
||||
agents.cancel_scope("blocks-cxl-")
|
||||
try:
|
||||
assert await agents._ram_gate("blocks-cxl-x") is False
|
||||
finally:
|
||||
agents.clear_scope("blocks-cxl-")
|
||||
|
||||
|
||||
def test_meminfo_reads_proc():
|
||||
mem = agents._meminfo()
|
||||
assert mem is not None and 0 < mem[0] <= mem[1] # Linux-Testumgebung
|
||||
538
backend/tests/test_block_calls.py
Normal file
538
backend/tests/test_block_calls.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""Verschmolzene Board-2-Calls (block_calls.py): Generate-Konsens, Verify-Faltung mit
|
||||
Fix-Tail, Artefakte in einem Durchgang — Agenten gefaked, gegen Test-DB."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import block_calls as bc
|
||||
import blocks as blx
|
||||
import board_artefacts as ba
|
||||
from pipeline import FAILED, OK, GenContext
|
||||
from textkit import _norm_title
|
||||
|
||||
TOPIC = "t"
|
||||
|
||||
|
||||
def _ctx():
|
||||
return GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
|
||||
|
||||
def _sub(title, level="beginner", relevance="relevant", kp=None, cf=None):
|
||||
return {"title": title, "level": level, "relevance": relevance,
|
||||
"key_points": [f"kp {title}"] if kp is None else kp,
|
||||
"prerequisites": "", "hurdles": "", "cited_facts": cf or [], "example_idea": ""}
|
||||
|
||||
|
||||
# ── Schemas ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_gen_schema_normalisiert():
|
||||
"""Gültige Einträge werden normalisiert; ungültiges level/relevance fällt auf ""
|
||||
(Stimme entfällt, der Sub bleibt); Einträge ohne Titel fliegen."""
|
||||
out = bc._gen_schema({"subs": [
|
||||
{"title": " **A** ", "level": "Beginner", "relevance": "RELEVANT",
|
||||
"key_points": ["k", ""], "cited_facts": [{"text": "t", "source": " s "},
|
||||
{"text": ""}, "quatsch"]},
|
||||
{"title": "B", "level": "profi", "relevance": "mittel"},
|
||||
{"title": " "},
|
||||
]})
|
||||
assert [e["title"] for e in out] == ["A", "B"]
|
||||
assert out[0]["level"] == "beginner" and out[0]["relevance"] == "relevant"
|
||||
assert out[0]["key_points"] == ["k"]
|
||||
assert out[0]["cited_facts"] == [{"text": "t", "source": "s"}]
|
||||
assert out[1]["level"] == "" and out[1]["relevance"] == ""
|
||||
|
||||
|
||||
def test_gen_schema_kaputt_ist_none():
|
||||
assert bc._gen_schema(None) is None
|
||||
assert bc._gen_schema({"subs": "x"}) is None
|
||||
assert bc._gen_schema({"subs": []}) is None
|
||||
assert bc._gen_schema({"subs": [{"level": "beginner"}]}) is None # nur titellose Einträge
|
||||
|
||||
|
||||
def test_verify_schema_pflichtkeys_und_leeres_verdikt():
|
||||
"""Mindestens EIN Pflicht-Key muss da sein; leere Listen heißen „alles ok"."""
|
||||
assert bc._verify_schema({}, 3) is None
|
||||
assert bc._verify_schema({"irgendwas": 1}, 3) is None
|
||||
v = bc._verify_schema({"gruppen": []}, 3)
|
||||
assert v["gruppen"] == [] and v["fremd"] == set() and v["luecken"] == []
|
||||
assert v["uebernehmen"] == {} and v["facts_probleme"] == [] and v["levels"] == {}
|
||||
|
||||
|
||||
def test_verify_schema_grenzen_und_normalisierung():
|
||||
"""ids außerhalb 1..n und bools fallen raus; Ein-Element-Gruppen zählen nicht;
|
||||
uebernehmen/levels werden casefolded bzw. enum-geprüft."""
|
||||
v = bc._verify_schema({
|
||||
"gruppen": [{"haupt": 2, "weitere": [1, 9, True]}, {"haupt": 3, "weitere": []}],
|
||||
"kataloge": [{"titel": " K ", "mitglieder": [1, 2]}, {"titel": "", "mitglieder": [1, 2]}],
|
||||
"fremd": [True, 1, "2", 9],
|
||||
"luecken": [" x ", "", 7],
|
||||
"uebernehmen": {"3": " JA ", "9": "ja"},
|
||||
"facts_probleme": [{"nr": 2, "discard": 1, "hinweis": " h "}, {"nr": 9}, "quatsch"],
|
||||
"levels": {"1": "expert", "2": "quatsch"},
|
||||
"relevanz": {"1": "peripheral"},
|
||||
}, 3)
|
||||
assert v["gruppen"] == [{"haupt": 2, "ids": [1, 2]}]
|
||||
assert v["kataloge"] == [{"titel": "K", "ids": [1, 2]}]
|
||||
assert v["fremd"] == {1, 2}
|
||||
assert v["luecken"] == ["x"]
|
||||
assert v["uebernehmen"] == {3: "ja"}
|
||||
assert v["facts_probleme"] == [{"nr": 2, "discard": True, "hinweis": "h"}]
|
||||
assert v["levels"] == {1: "expert"} and v["relevanz"] == {1: "peripheral"}
|
||||
|
||||
|
||||
def test_art_gen_schema_pattern_ist_pflicht():
|
||||
"""Ohne verwertbares pattern kein Verdikt (Leitner hängt an den Fragen);
|
||||
cards/examples sind best-effort und werden einzeln validiert."""
|
||||
assert bc._art_gen_schema({"cards": [], "examples": []}) is None
|
||||
assert bc._art_gen_schema("x") is None
|
||||
out = bc._art_gen_schema({
|
||||
"pattern": [{"block": "B", "subblock": "S", "question": "F?"},
|
||||
{"block": "B", "subblock": "", "question": "F?"}],
|
||||
"cards": [{"block": "B", "subblock": "S", "question": "F?", "answer": "A"},
|
||||
{"block": "B", "subblock": "S", "question": "F?"}],
|
||||
"examples": [{"block": "B", "subblock": "S", "problem": "P", "steps": ["s1", ""], "result": ""},
|
||||
{"block": "B", "subblock": "S", "problem": "P", "steps": []}],
|
||||
})
|
||||
assert len(out["pattern"]) == 1 and len(out["cards"]) == 1
|
||||
assert out["examples"] == [{"block": "B", "subblock": "S", "problem": "P",
|
||||
"steps": ["s1"], "result": ""}]
|
||||
|
||||
|
||||
def test_art_check_schema_varianten():
|
||||
"""{"ok": true} → leeres Verdikt; ohne bekannten Key None; Beispiel-Indizes sind
|
||||
1-basiert, bools/0 zählen nicht."""
|
||||
ok = bc._art_check_schema({"ok": True})
|
||||
assert ok == {"pattern": [], "pattern_ergaenzt": [], "examples_probleme": set()}
|
||||
assert bc._art_check_schema({"foo": 1}) is None
|
||||
v = bc._art_check_schema({"examples_probleme": [1, "2", {"index": 3}, True, 0, -1],
|
||||
"pattern_ergaenzt": [{"block": "B", "subblock": "S", "question": "F?"}]})
|
||||
assert v["examples_probleme"] == {1, 2, 3}
|
||||
assert len(v["pattern_ergaenzt"]) == 1 and v["pattern"] == []
|
||||
|
||||
|
||||
# ── Generate ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def env(testdb, tmp_path, monkeypatch):
|
||||
"""Ohne Modell (exakte Norm-Gleichheit), ohne Korpus (thema-Selbst-Recherche)."""
|
||||
monkeypatch.setattr(bc, "EMBEDDING_AKTIV", False)
|
||||
monkeypatch.setattr(blx, "EMBEDDING_AKTIV", False) # _dedup_subblocks aus
|
||||
monkeypatch.setattr(bc, "material_folder", lambda t: None)
|
||||
monkeypatch.setattr(bc, "load_source", lambda t: {"type": "thema"})
|
||||
return testdb, _ctx(), {"arbeit": tmp_path}
|
||||
|
||||
|
||||
def _mk_gen_race(outputs):
|
||||
"""_race-Fake: pro Generator-Slot (…-gN) die gescriptete Antwort als Text;
|
||||
fehlender Eintrag = Ausfall."""
|
||||
calls = []
|
||||
|
||||
async def fake_race(topic, label, slots, quorum, timeout, provider, on_update=None,
|
||||
cancelled=None, **kw):
|
||||
outs = []
|
||||
for slot in slots:
|
||||
calls.append(slot["key"])
|
||||
g = int(slot["key"].rsplit("-g", 1)[1])
|
||||
out = outputs.get(g)
|
||||
if out is not None:
|
||||
outs.append(slot["payload"]((0, json.dumps(out), "")))
|
||||
return [o for o in outs if o] or None
|
||||
|
||||
fake_race.calls = calls
|
||||
return fake_race
|
||||
|
||||
|
||||
async def test_generate_schnittmenge_wird_consensus(env, monkeypatch):
|
||||
"""Von beiden Generatoren genannt → consensus (Facts-Union); Einzelnennungen
|
||||
werden unsicher und gehen zum Prüfer."""
|
||||
db, ctx, files = env
|
||||
monkeypatch.setattr(bc, "_race", _mk_gen_race({
|
||||
1: {"subs": [_sub("Sub A", kp=["k1"]), _sub("Sub B")]},
|
||||
2: {"subs": [_sub("Sub A", kp=["k2"]), _sub("Sub C")]},
|
||||
}))
|
||||
gen = await bc._generate_block(ctx, files, "Alpha", "Grundkonzept")
|
||||
assert gen["raw"] == {"Alpha": ["Sub A"]}
|
||||
assert gen["facts"]["Alpha"]["sub a"]["key_points"] == ["k1", "k2"] # Union beider Nennungen
|
||||
assert {u["title"] for u in gen["unsicher"]} == {"Sub B", "Sub C"}
|
||||
assert gen["votes"]["sub a"]["level"] == ["beginner", "beginner"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows["sub a"] == "consensus"
|
||||
assert rows["sub b"] == rows["sub c"] == "candidate"
|
||||
|
||||
|
||||
async def test_generate_degraded_alles_unsicher(env, monkeypatch):
|
||||
"""Liefert nur EIN Generator, ist kein Konsens möglich — alles wird unsicher,
|
||||
der Prüfer entscheidet mit Material."""
|
||||
db, ctx, files = env
|
||||
monkeypatch.setattr(bc, "_race", _mk_gen_race({
|
||||
1: {"subs": [_sub("Sub A"), _sub("Sub B")]}, # g2 fällt aus
|
||||
}))
|
||||
gen = await bc._generate_block(ctx, files, "Alpha", "Grundkonzept")
|
||||
assert gen["raw"] == {"Alpha": []}
|
||||
assert {u["title"] for u in gen["unsicher"]} == {"Sub A", "Sub B"}
|
||||
assert not any(r["status"] == "consensus" for r in await db.list_subblocks(TOPIC, "alpha"))
|
||||
|
||||
|
||||
async def test_generate_beide_ausgefallen_ist_none(env, monkeypatch):
|
||||
db, ctx, files = env
|
||||
monkeypatch.setattr(bc, "_race", _mk_gen_race({}))
|
||||
assert await bc._generate_block(ctx, files, "Alpha", "d") is None
|
||||
|
||||
|
||||
async def test_generate_seed_garantie(env, monkeypatch):
|
||||
"""Ungedeckte Seeds gehen als unsicher zum Prüfer (Beleg-Gate liegt dort);
|
||||
lexikalisch gedeckte Seeds erzeugen keine Dublette."""
|
||||
db, ctx, files = env
|
||||
monkeypatch.setattr(bc, "_race", _mk_gen_race({
|
||||
1: {"subs": [_sub("Sub A")]}, 2: {"subs": [_sub("Sub A")]},
|
||||
}))
|
||||
gen = await bc._generate_block(ctx, files, "Alpha", "d",
|
||||
seeds=["Escaping Regeln", "Sub"])
|
||||
assert gen["raw"] == {"Alpha": ["Sub A"]}
|
||||
assert [u["title"] for u in gen["unsicher"]] == ["Escaping Regeln"] # „Sub" ist gedeckt
|
||||
assert gen["unsicher"][0]["key_points"] == [] # Seeds kommen ohne Beleg
|
||||
|
||||
|
||||
async def test_generate_resume_ohne_neue_calls(env, monkeypatch):
|
||||
"""Vorhandene gen-Dateien → kein neuer _race-Call, Ergebnis wird übernommen."""
|
||||
db, ctx, files = env
|
||||
fake = _mk_gen_race({1: {"subs": [_sub("Sub A")]}, 2: {"subs": [_sub("Sub A")]}})
|
||||
monkeypatch.setattr(bc, "_race", fake)
|
||||
gen1 = await bc._generate_block(ctx, files, "Alpha", "d")
|
||||
n = len(fake.calls)
|
||||
gen2 = await bc._generate_block(ctx, files, "Alpha", "d")
|
||||
assert len(fake.calls) == n # alles resumed
|
||||
assert gen2["raw"] == gen1["raw"]
|
||||
|
||||
|
||||
# ── Verify (+ Fix-Tail) ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _gen_von(title, subs, unsicher=None, votes=None):
|
||||
"""Karten-Payload wie aus _generate_block: raw/facts/unsicher/votes."""
|
||||
return {"raw": {title: list(subs)},
|
||||
"facts": {title: {_norm_title(s): {"key_points": [f"kp {s}"], "prerequisites": "",
|
||||
"hurdles": "", "cited_facts": [], "example_idea": ""}
|
||||
for s in subs}},
|
||||
"unsicher": unsicher or [], "votes": votes or {}}
|
||||
|
||||
|
||||
def _judge_slot(antworten, fix=None):
|
||||
"""run_single_slot-Fake: Prüfer-Antwort je j-Suffix, Fix-Antwort für -sb-fix-;
|
||||
fehlender Eintrag = FAILED."""
|
||||
calls = []
|
||||
|
||||
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||
calls.append({"key": key, "prompt": prompt})
|
||||
if "-sb-fix-" in key:
|
||||
if fix is None:
|
||||
return FAILED, None
|
||||
return OK, payload((0, json.dumps(fix), ""))
|
||||
j = key.rsplit("-j", 1)[-1]
|
||||
antwort = antworten.get(j)
|
||||
if antwort is None:
|
||||
return FAILED, None
|
||||
return OK, payload((0, json.dumps(antwort), ""))
|
||||
|
||||
fake.calls = calls
|
||||
return fake
|
||||
|
||||
|
||||
async def _seed_rows(db, bnorm, titles, status="consensus"):
|
||||
for t in titles:
|
||||
await db.put_subblock(TOPIC, bnorm, _norm_title(t), bnorm.title(), t, status=status)
|
||||
|
||||
|
||||
async def test_verify_gruppe_faltet_einstimmig(env, monkeypatch):
|
||||
"""Beide Prüfer gruppieren 1+2 → haupt gewinnt, Verlierer wird variant und seine
|
||||
Facts wandern per Union zum Gewinner; Dissens-Gruppen falten nicht."""
|
||||
db, ctx, files = env
|
||||
subs = ["Marker Regel", "Marker Regel im Detail erklärt", "Eigenes Thema"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
verdikt = {"gruppen": [{"haupt": 2, "weitere": [1]}]}
|
||||
fake = _judge_slot({"1": verdikt, "2": {"gruppen": [{"haupt": 2, "weitere": [1]},
|
||||
{"haupt": 3, "weitere": [2]}]}})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert res["raw"] == {"Alpha": [subs[1], subs[2]]} # Gruppe 2+3 war einseitig → kein Fold
|
||||
wf = res["facts"]["Alpha"][_norm_title(subs[1])]
|
||||
assert wf["key_points"] == [f"kp {subs[1]}", f"kp {subs[0]}"] # Union geerbt
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows[_norm_title(subs[0])] == "variant"
|
||||
assert rows[_norm_title(subs[1])] == "consensus"
|
||||
|
||||
|
||||
async def test_verify_fremd_nur_einstimmig(env, monkeypatch):
|
||||
"""Fremd 2/2 → discarded + raus; einseitig fremd → bleibt."""
|
||||
db, ctx, files = env
|
||||
subs = ["CSS Regel", "Echte Regel"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
fake = _judge_slot({"1": {"fremd": [1, 2]}, "2": {"fremd": [1]}})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert res["raw"] == {"Alpha": ["Echte Regel"]}
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows["css regel"] == "discarded" and rows["echte regel"] == "consensus"
|
||||
|
||||
|
||||
async def test_verify_uebernahme_braucht_beide(env, monkeypatch):
|
||||
"""Unsicher-Eintrag wird nur bei 2/2 „ja" consensus (samt Generator-Facts);
|
||||
sonst discarded."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A"]
|
||||
unsicher = [_sub("Unsicher B", kp=["kp b"]), _sub("Unsicher C")]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
await _seed_rows(db, "alpha", ["Unsicher B", "Unsicher C"], status="candidate")
|
||||
fake = _judge_slot({"1": {"uebernehmen": {"2": "ja", "3": "ja"}},
|
||||
"2": {"uebernehmen": {"2": "ja", "3": "nein"}}})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs, unsicher=unsicher), {})
|
||||
assert res["raw"] == {"Alpha": ["Sub A", "Unsicher B"]}
|
||||
assert res["facts"]["Alpha"]["unsicher b"]["key_points"] == ["kp b"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows["unsicher b"] == "consensus" and rows["unsicher c"] == "discarded"
|
||||
# der Prüfer-Prompt weist die Unsicher-Nummern aus
|
||||
assert "UNSICHER" in fake.calls[0]["prompt"] and "entries 2–3" in fake.calls[0]["prompt"]
|
||||
|
||||
|
||||
async def test_verify_facts_discard_nur_2von2(env, monkeypatch):
|
||||
"""Facts-Discard ist irreversibel → nur 2/2; die einseitige Stimme ohne Hinweis
|
||||
löst auch keinen Fix aus."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A", "Sub B"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
fake = _judge_slot({"1": {"facts_probleme": [{"nr": 1, "discard": True},
|
||||
{"nr": 2, "discard": True}]},
|
||||
"2": {"facts_probleme": [{"nr": 1, "discard": True}]}})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert res["raw"] == {"Alpha": ["Sub B"]}
|
||||
assert not any("-sb-fix-" in c["key"] for c in fake.calls)
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows["sub a"] == "discarded" and rows["sub b"] == "consensus"
|
||||
|
||||
|
||||
async def test_verify_korrektur_ab_einer_stimme(env, monkeypatch):
|
||||
"""Ein Hinweis EINES Prüfers reicht: der Fix-Call läuft und ersetzt die Facts des
|
||||
beanstandeten Subs; die Einstufung bleibt."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A", "Sub B"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
fix = {"subs": [_sub("Sub A", kp=["korrigierte Aussage"])]}
|
||||
fake = _judge_slot({"1": {"facts_probleme": [{"nr": 1, "hinweis": "Zahl falsch"}]},
|
||||
"2": {"gruppen": []}}, fix=fix)
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert any("-sb-fix-" in c["key"] for c in fake.calls)
|
||||
side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
|
||||
assert side["Sub A"]["facts"]["key_points"] == ["korrigierte Aussage"]
|
||||
assert res["facts"]["Alpha"]["sub a"]["key_points"] == ["korrigierte Aussage"]
|
||||
assert side["Sub B"]["facts"]["key_points"] == ["kp Sub B"] # unbeanstandet
|
||||
|
||||
|
||||
async def test_verify_luecke_belegt_wird_neuer_sub(env, monkeypatch):
|
||||
"""Lücken-Schnitt beider Prüfer → Fix legt den belegten Fund als neuen consensus-Sub
|
||||
an; ein unbelegter „Fund" verfällt am Beleg-Gate."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
fix = {"subs": [_sub("Escaping von Sonderzeichen", level="expert", kp=["belegt"]),
|
||||
_sub("Unbelegte Behauptung", kp=[])]}
|
||||
fake = _judge_slot({"1": {"luecken": ["Escaping fehlt"]},
|
||||
"2": {"luecken": ["Escaping unbehandelt"]}}, fix=fix)
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert res["raw"] == {"Alpha": ["Sub A", "Escaping von Sonderzeichen"]}
|
||||
neu = next(s for s in res["sidecar"]["Alpha"] if s["title"] == "Escaping von Sonderzeichen")
|
||||
assert neu["level"] == "expert" and neu["facts"]["key_points"] == ["belegt"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows[_norm_title("Escaping von Sonderzeichen")] == "consensus"
|
||||
assert _norm_title("Unbelegte Behauptung") not in rows
|
||||
|
||||
|
||||
async def test_verify_ersatzrichter_bei_ausfall(env, monkeypatch):
|
||||
"""Fällt EIN Prüfer aus, springt der Ersatz jE ein — Einstimmigkeit mit ihm faltet."""
|
||||
db, ctx, files = env
|
||||
subs = ["CSS Regel", "Echte Regel"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
fake = _judge_slot({"1": {"fremd": [1]}, "E": {"fremd": [1]}}) # j2 → FAILED
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert res["raw"] == {"Alpha": ["Echte Regel"]}
|
||||
assert [c["key"].rsplit("-j", 1)[-1] for c in fake.calls] == ["1", "2", "E"]
|
||||
|
||||
|
||||
async def test_verify_failopen_verwirft_nur_unsicher(env, monkeypatch):
|
||||
"""Nur 1 Prüfer (auch der Ersatz fällt aus) → fail-open: consensus bleibt unangetastet,
|
||||
unsicher wird verworfen (ohne Panel keine Übernahme-Entscheidung)."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A"]
|
||||
unsicher = [_sub("Unsicher B")]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
await _seed_rows(db, "alpha", ["Unsicher B"], status="candidate")
|
||||
fake = _judge_slot({"1": {"fremd": [1], "uebernehmen": {"2": "ja"}}}) # j2+jE → FAILED
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs, unsicher=unsicher), {})
|
||||
assert res["raw"] == {"Alpha": ["Sub A"]} # fremd-Einzelstimme wirkt NICHT
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows["sub a"] == "consensus" and rows["unsicher b"] == "discarded"
|
||||
|
||||
|
||||
async def test_verify_level_korrektur_wiegt_doppelt(env, monkeypatch):
|
||||
"""Explizite Prüfer-Korrektur (×2) schlägt die Generator-Stimme; Patt fällt auf
|
||||
advanced/relevant (heutige Defaults)."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A", "Sub B"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
votes = {"sub a": {"level": ["beginner"], "relevance": []},
|
||||
"sub b": {"level": ["beginner", "expert"], "relevance": []}}
|
||||
fake = _judge_slot({"1": {"levels": {"1": "expert"}}, "2": {"gruppen": []}})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs, votes=votes), {})
|
||||
side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
|
||||
assert side["Sub A"]["level"] == "expert" # 2× Korrektur > 1× Generator
|
||||
assert side["Sub B"]["level"] == "advanced" # 1:1-Patt → Default
|
||||
assert side["Sub A"]["relevance"] == "relevant" # keine Stimme → Default
|
||||
|
||||
|
||||
async def test_verify_resume_ohne_neue_calls(env, monkeypatch):
|
||||
"""Vorhandene verify-j-Dateien → kein neuer Prüfer-Call."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
fake = _judge_slot({"1": {"gruppen": []}, "2": {"gruppen": []}})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
n = len(fake.calls)
|
||||
await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert len(fake.calls) == n
|
||||
|
||||
|
||||
# ── Artefakte ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sidecar(titles):
|
||||
return [{"title": t, "level": "beginner", "relevance": "relevant",
|
||||
"facts": {"key_points": [f"kp {t}"]}} for t in titles]
|
||||
|
||||
|
||||
def _art_slot(gen_out, check_out):
|
||||
"""run_single_slot-Fake für Artefakte: gen_out je Teil (dict oder callable(prompt)),
|
||||
check_out fürs Prüfer-Verdikt."""
|
||||
calls = []
|
||||
|
||||
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||
calls.append({"key": key, "prompt": prompt})
|
||||
if "-art-gen-" in key:
|
||||
out = gen_out(prompt) if callable(gen_out) else gen_out
|
||||
return OK, payload((0, json.dumps(out), ""))
|
||||
if "-art-check-" in key:
|
||||
if check_out is None:
|
||||
return FAILED, None
|
||||
return OK, payload((0, json.dumps(check_out), ""))
|
||||
raise AssertionError(f"unerwarteter Call {key}")
|
||||
|
||||
fake.calls = calls
|
||||
return fake
|
||||
|
||||
|
||||
async def test_artefakte_ein_call_liefert_alles(env, monkeypatch):
|
||||
"""EIN Generator-Call liefert pattern+cards+examples, der Prüfer sagt ok →
|
||||
Rohfassung wird übernommen, block-Feld auf den Karten-Block normiert."""
|
||||
db, ctx, files = env
|
||||
gen_out = {"pattern": [{"block": "Echo", "subblock": "Sub A", "question": "F?"}],
|
||||
"cards": [{"block": "Echo", "subblock": "Sub A", "question": "F?", "answer": "A"}],
|
||||
"examples": [{"block": "Echo", "subblock": "Sub A", "problem": "P",
|
||||
"steps": ["s1"], "result": "R"}]}
|
||||
fake = _art_slot(gen_out, {"ok": True})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A"]))
|
||||
assert [c["key"] for c in fake.calls if "-art-gen-" in c["key"]].__len__() == 1
|
||||
assert res["pattern"] == {"Alpha": [{"subblock": "Sub A", "question": "F?"}]}
|
||||
assert res["artefacts"]["flashcard"] == [{"block": "Alpha", "subblock": "Sub A",
|
||||
"question": "F?", "answer": "A"}]
|
||||
assert res["artefacts"]["example"][0]["block"] == "Alpha" # Agent-Echo „Echo" normiert
|
||||
|
||||
|
||||
async def test_artefakte_check_entfernt_beispiel_und_ergaenzt_frage(env, monkeypatch):
|
||||
"""examples_probleme wirft das beanstandete Beispiel; pattern_ergaenzt füllt die
|
||||
fehlende Frage nach — der Prüfer-Prompt listet den fraglosen Sub."""
|
||||
db, ctx, files = env
|
||||
gen_out = {"pattern": [{"block": "Alpha", "subblock": "Sub A", "question": "F?"}],
|
||||
"cards": [],
|
||||
"examples": [{"block": "Alpha", "subblock": "Sub A", "problem": "P1", "steps": ["x"], "result": ""},
|
||||
{"block": "Alpha", "subblock": "Sub A", "problem": "P2", "steps": ["y"], "result": ""}]}
|
||||
check = {"examples_probleme": [{"index": 1}],
|
||||
"pattern_ergaenzt": [{"block": "Alpha", "subblock": "Sub B", "question": "F B?"}]}
|
||||
fake = _art_slot(gen_out, check)
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A", "Sub B"]))
|
||||
assert [e["problem"] for e in res["artefacts"]["example"]] == ["P2"]
|
||||
assert res["pattern"]["Alpha"] == [{"subblock": "Sub A", "question": "F?"},
|
||||
{"subblock": "Sub B", "question": "F B?"}]
|
||||
check_prompt = next(c["prompt"] for c in fake.calls if "-art-check-" in c["key"])
|
||||
assert "STILL MISSING A QUESTION" in check_prompt and "Sub B" in check_prompt
|
||||
|
||||
|
||||
async def test_artefakte_split_ab_schwelle(env, monkeypatch):
|
||||
"""> ART_SPLIT_SUBS Subs → ZWEI parallele Generator-Calls, jeder sieht nur seine
|
||||
Hälfte; die Ergebnisse werden zusammengeführt."""
|
||||
db, ctx, files = env
|
||||
monkeypatch.setattr(bc, "ART_SPLIT_SUBS", 2)
|
||||
|
||||
def gen_out(prompt):
|
||||
subs = [t for t in ("Sub A", "Sub B", "Sub C") if f"- {t}" in prompt]
|
||||
return {"pattern": [{"block": "Alpha", "subblock": s, "question": f"F {s}?"} for s in subs],
|
||||
"cards": [], "examples": []}
|
||||
|
||||
fake = _art_slot(gen_out, {"ok": True})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A", "Sub B", "Sub C"]))
|
||||
gen_keys = [c["key"] for c in fake.calls if "-art-gen-" in c["key"]]
|
||||
assert len(gen_keys) == 2 and gen_keys[0].endswith("-t1") and gen_keys[1].endswith("-t2")
|
||||
assert [p["subblock"] for p in res["pattern"]["Alpha"]] == ["Sub A", "Sub B", "Sub C"]
|
||||
|
||||
|
||||
async def test_artefakte_check_ausfall_uebernimmt_rohfassung(env, monkeypatch):
|
||||
"""Prüfer ohne Ergebnis → fail-open, die Generator-Rohfassung zählt."""
|
||||
db, ctx, files = env
|
||||
gen_out = {"pattern": [{"block": "Alpha", "subblock": "Sub A", "question": "F?"}],
|
||||
"cards": [], "examples": []}
|
||||
fake = _art_slot(gen_out, None)
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A"]))
|
||||
assert res["pattern"] == {"Alpha": [{"subblock": "Sub A", "question": "F?"}]}
|
||||
|
||||
|
||||
async def test_artefakte_leerer_block(env):
|
||||
db, ctx, files = env
|
||||
res = await bc._artefakte_block(ctx, files, "Alpha", [])
|
||||
assert res == {"pattern": {"Alpha": []}, "artefacts": {"flashcard": [], "example": []}}
|
||||
|
||||
|
||||
# ── Migration der alten Stage-Treppe ────────────────────────────────────────────────
|
||||
|
||||
async def test_migriere_alt_karten(testdb):
|
||||
"""Karten in alten Stages gehen mit reduziertem Payload zurück nach generate
|
||||
(alte Zwischenstände sind für die verschmolzenen Calls wertlos); Terminal- und
|
||||
Neu-Struktur-Karten bleiben unangetastet."""
|
||||
db = testdb
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "facts",
|
||||
{"title": "Alpha", "description": "d", "n_size": 3,
|
||||
"sources": ["s1"], "raw": {"Alpha": ["alt"]},
|
||||
"facts": {"Alpha": {}}})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "beta", "ablock", "question_pattern",
|
||||
{"title": "Beta", "sidecar": {}})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "gamma", "ablock", "done_artefact",
|
||||
{"title": "Gamma", "raw": {"Gamma": ["bleibt"]}})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "delta", "ablock", "verify",
|
||||
{"title": "Delta", "raw": {"Delta": ["neu"]}})
|
||||
n = await ba.migriere_alt_karten(TOPIC)
|
||||
assert n == 2
|
||||
alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
|
||||
assert alpha["stage"] == "generate"
|
||||
assert alpha["payload"] == {"title": "Alpha", "description": "d", "n_size": 3, "sources": ["s1"]}
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == "generate"
|
||||
gamma = await db.kanban_get_card(TOPIC, "artefacts", "gamma")
|
||||
assert gamma["stage"] == "done_artefact" and gamma["payload"]["raw"] == {"Gamma": ["bleibt"]}
|
||||
delta = await db.kanban_get_card(TOPIC, "artefacts", "delta")
|
||||
assert delta["stage"] == "verify" and delta["payload"]["raw"] == {"Delta": ["neu"]}
|
||||
@@ -70,39 +70,36 @@ async def board_env(testdb, tmp_path, monkeypatch):
|
||||
return False
|
||||
monkeypatch.setattr(bi, "_emb_ok", no_emb)
|
||||
|
||||
async def fake_subblocks(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl="", sources=None):
|
||||
title = list(entries.values())[0].split(" — ")[0]
|
||||
return {title: ["Sub Eins", "Sub Zwei"]}
|
||||
async def fake_generate(ctx, files, title, description, instructions="", ns="", lbl="",
|
||||
sources=None, seeds=None):
|
||||
subs = ["Sub Eins", "Sub Zwei"]
|
||||
return {"raw": {title: list(subs)},
|
||||
"facts": {title: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []}
|
||||
for s in subs}},
|
||||
"unsicher": [], "votes": {}}
|
||||
|
||||
async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="", sources=None):
|
||||
facts = {t: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []}
|
||||
for s in subs} for t, subs in raw.items()}
|
||||
return facts, {}
|
||||
async def fake_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None):
|
||||
subs = gen["raw"].get(title) or []
|
||||
bfacts = gen["facts"].get(title) or {}
|
||||
sidecar = [{"title": s, "level": "beginner",
|
||||
"relevance": "relevant" if i == 0 else "peripheral",
|
||||
"facts": bfacts.get(_norm_title(s)) or {}}
|
||||
for i, s in enumerate(subs)]
|
||||
return {"raw": {title: list(subs)}, "facts": {title: bfacts}, "sidecar": {title: sidecar}}
|
||||
|
||||
async def fake_levels(ctx, set_p, files, raw, instructions, ns="", lbl=""):
|
||||
return {t: [{"title": s, "level": "beginner"} for s in subs] for t, subs in raw.items()}
|
||||
|
||||
async def fake_relevance(ctx, set_p, files, sidecar, instructions, ns="", lbl=""):
|
||||
return {1: "relevant", 2: "peripheral"}
|
||||
|
||||
async def fake_pattern(ctx, set_p, files, sidecar, instructions, ns="", lbl=""):
|
||||
return {t: [{"subblock": subs[0]["title"], "question": f"Was ist {t}?"}]
|
||||
for t, subs in sidecar.items()}
|
||||
|
||||
async def fake_artefacts(ctx, set_p, files, sidecar, instructions, ns="", lbl=""):
|
||||
return {"flashcard": [{"block": t, "subblock": subs[0]["title"], "front": "F", "back": "B"}
|
||||
for t, subs in sidecar.items()], "example": []}
|
||||
async def fake_artefakte(ctx, files, title, sidecar_subs, instructions="", ns="", lbl=""):
|
||||
if not sidecar_subs:
|
||||
return {"pattern": {title: []}, "artefacts": {"flashcard": [], "example": []}}
|
||||
first = sidecar_subs[0]["title"]
|
||||
return {"pattern": {title: [{"subblock": first, "question": f"Was ist {title}?"}]},
|
||||
"artefacts": {"flashcard": [{"block": title, "subblock": first, "front": "F", "back": "B"}],
|
||||
"example": []}}
|
||||
|
||||
async def fake_outline(ctx, set_p, files, entries, instructions):
|
||||
return {"chapters": [{"title": "Kapitel 1", "numbers": sorted(entries)}]}
|
||||
|
||||
async def fake_konsolidierung(ctx, files, raw, facts_map, instructions="", ns="", lbl=""):
|
||||
return None
|
||||
|
||||
for name, fn in [("_subblocks_block", fake_subblocks), ("_facts_block", fake_facts),
|
||||
("_levels_block", fake_levels), ("_relevance_block", fake_relevance),
|
||||
("_question_pattern_block", fake_pattern), ("_artefacts_block", fake_artefacts),
|
||||
("_outline_block", fake_outline), ("_konsolidiere_subblocks", fake_konsolidierung)]:
|
||||
for name, fn in [("_generate_block", fake_generate), ("_verify_block", fake_verify),
|
||||
("_artefakte_block", fake_artefakte), ("_outline_block", fake_outline)]:
|
||||
monkeypatch.setattr(ba, name, fn)
|
||||
|
||||
class _EmbOff: # Cross-Block-Barrier reicht ohne Modell alle Karten durch
|
||||
@@ -238,16 +235,17 @@ async def test_filter_judges_run_parallel(board_env, monkeypatch):
|
||||
|
||||
|
||||
async def test_empty_subblocks_completes_without_deadletter(board_env, monkeypatch):
|
||||
"""Legitim leere Subbausteine ({} statt None) → Karte läuft bis done_artefact durch."""
|
||||
"""Legitim leere Subbausteine (leere raw-Liste statt None) → Karte läuft bis done_artefact durch."""
|
||||
import asyncio
|
||||
import board_artefacts as ba
|
||||
import blocks as blx
|
||||
db, ctx, files = board_env
|
||||
|
||||
async def empty_subs(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl="", sources=None):
|
||||
return {}
|
||||
monkeypatch.setattr(ba, "_subblocks_block", empty_subs)
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "subblocks",
|
||||
async def empty_gen(ctx, files, title, description, instructions="", ns="", lbl="",
|
||||
sources=None, seeds=None):
|
||||
return {"raw": {title: []}, "facts": {title: {}}, "unsicher": [], "votes": {}}
|
||||
monkeypatch.setattr(ba, "_generate_block", empty_gen)
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "generate",
|
||||
{"title": "Leerer Block", "description": "d"})
|
||||
ok = await asyncio.wait_for(
|
||||
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
|
||||
@@ -712,18 +710,18 @@ def test_agent_priority_order():
|
||||
|
||||
|
||||
async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
|
||||
"""Gliederung startet, sobald alle Karten die facts-Stage passiert haben —
|
||||
"""Gliederung startet, sobald alle Karten die generate-Stage passiert haben —
|
||||
parallel zu den restlichen Artefakt-Stages des langsamsten Blocks."""
|
||||
import asyncio
|
||||
import board_artefacts as ba
|
||||
db, ctx, files = board_env
|
||||
await _seed(db)
|
||||
base_levels = ba._levels_block
|
||||
base_verify = ba._verify_block
|
||||
snapshot = {}
|
||||
|
||||
async def slow_levels(ctx, set_p, files, raw, instructions, ns="", lbl=""):
|
||||
await asyncio.sleep(0.8) # keeps one card in `levels` while the outline fires
|
||||
return await base_levels(ctx, set_p, files, raw, instructions, ns=ns, lbl=lbl)
|
||||
async def slow_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None):
|
||||
await asyncio.sleep(0.8) # keeps one card in `verify` while the outline fires
|
||||
return await base_verify(ctx, files, title, gen, q, instructions, ns=ns, lbl=lbl, sources=sources)
|
||||
|
||||
base_outline = ba._outline_block
|
||||
|
||||
@@ -732,7 +730,7 @@ async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
|
||||
snapshot["unfinished"] = sum(1 for c in cards if c["stage"] != "done_artefact")
|
||||
return await base_outline(ctx, set_p, files, entries, instructions)
|
||||
|
||||
monkeypatch.setattr(ba, "_levels_block", slow_levels)
|
||||
monkeypatch.setattr(ba, "_verify_block", slow_verify)
|
||||
monkeypatch.setattr(ba, "_outline_block", spy_outline)
|
||||
ok = await asyncio.wait_for(
|
||||
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
|
||||
@@ -750,7 +748,7 @@ async def test_outline_facts_from_payloads(board_env, tmp_path):
|
||||
db, ctx, files = board_env
|
||||
await db.kanban_upsert_card(TOPIC, B, "b-1", "block", "done_block",
|
||||
{"title": "Alpha", "description": "d"})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "levels",
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "verify",
|
||||
{"title": "Alpha", "facts": {"Alpha": {"sub eins": {
|
||||
"sub": "Sub Eins", "prerequisites": "Beta zuerst"}}}})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "outline", "outline", "outline",
|
||||
@@ -764,16 +762,16 @@ async def test_outline_facts_from_payloads(board_env, tmp_path):
|
||||
|
||||
async def test_card_view_stepper(testdb):
|
||||
"""Aktive artefacts-Karte mit Step-Name → step_i/step_n; Alt-String bleibt tolerierbar."""
|
||||
r = {"board": "artefacts", "card_id": "alpha", "stage": "facts", "retries": 0,
|
||||
r = {"board": "artefacts", "card_id": "alpha", "stage": "verify", "retries": 0,
|
||||
"payload": {"title": "Alpha"}}
|
||||
v = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "Facts check 1/2…", "step": "Facts check"}})
|
||||
assert v["status"] == "active" and v["info"] == "Facts check 1/2…"
|
||||
assert v["step_i"] == 2 and v["step_n"] == 3 and v["steps"][0] == "Facts find"
|
||||
v = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "Fix 1/2…", "step": "Fix"}})
|
||||
assert v["status"] == "active" and v["info"] == "Fix 1/2…"
|
||||
assert v["step_i"] == 2 and v["step_n"] == 2 and v["steps"][0] == "Verify"
|
||||
# legacy plain-string live info → no stepper, no crash
|
||||
v2 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": "Facts find 0/1…"})
|
||||
assert v2["info"] == "Facts find 0/1…" and "step_n" not in v2
|
||||
v2 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": "Verify 0/1…"})
|
||||
assert v2["info"] == "Verify 0/1…" and "step_n" not in v2
|
||||
# step outside the card's stage group (e.g. supplement note) → no stepper
|
||||
v3 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "x", "step": "Subblocks find"}})
|
||||
v3 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "x", "step": "Generate"}})
|
||||
assert "step_n" not in v3
|
||||
|
||||
|
||||
@@ -808,17 +806,17 @@ async def test_seed_map_resolves_cascade(testdb):
|
||||
|
||||
|
||||
def test_per_block_functions_accept_wrapper_kwargs():
|
||||
"""Die board_artefacts-Wrapper übergeben ns/lbl (subblocks auch seeds) — ein fehlender
|
||||
Parameter stirbt sonst erst im Echt-Lauf als TypeError (Fakes verdecken die Signatur)."""
|
||||
"""Die board_artefacts-Prozessoren übergeben ns/lbl (generate auch seeds/sources) —
|
||||
ein fehlender Parameter stirbt sonst erst im Echt-Lauf als TypeError (Fakes verdecken
|
||||
die Signatur)."""
|
||||
import inspect
|
||||
import blocks as blx
|
||||
for fn in ("_subblocks_block", "_facts_block", "_levels_block", "_relevance_block",
|
||||
"_question_pattern_block", "_artefacts_block"):
|
||||
params = inspect.signature(getattr(blx, fn)).parameters
|
||||
import block_calls as bc
|
||||
for fn in ("_generate_block", "_verify_block", "_artefakte_block"):
|
||||
params = inspect.signature(getattr(bc, fn)).parameters
|
||||
assert "ns" in params and "lbl" in params, fn
|
||||
assert "seeds" in inspect.signature(blx._subblocks_block).parameters
|
||||
for fn in ("_subblocks_block", "_facts_block"): # Board 2 reicht die Block-Quellen durch
|
||||
assert "sources" in inspect.signature(getattr(blx, fn)).parameters, fn
|
||||
assert "seeds" in inspect.signature(bc._generate_block).parameters
|
||||
for fn in ("_generate_block", "_verify_block"): # Board 2 reicht die Block-Quellen durch
|
||||
assert "sources" in inspect.signature(getattr(bc, fn)).parameters, fn
|
||||
|
||||
|
||||
# ── Inventar-Härtung: Sanitizer, Akronym-Regel, Supplement-Beleg ─────────────────────
|
||||
@@ -906,7 +904,7 @@ async def _run_flow(ctx, files, timeout=30, **kw):
|
||||
|
||||
|
||||
async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch):
|
||||
"""Note unter Schwelle → Flow endet sauber, Board-2-Karten warten in subblocks."""
|
||||
"""Note unter Schwelle → Flow endet sauber, Board-2-Karten warten in generate."""
|
||||
import qa as qa_mod
|
||||
db, ctx, files = board_env
|
||||
|
||||
@@ -918,7 +916,7 @@ async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch):
|
||||
await _seed(db)
|
||||
ok = await _run_flow(ctx, files)
|
||||
assert ok
|
||||
warten = await db.kanban_cards(TOPIC, board="artefacts", stage="subblocks")
|
||||
warten = await db.kanban_cards(TOPIC, board="artefacts", stage="generate")
|
||||
assert len(warten) == 4 # alle Blöcke gespawnt, keiner verarbeitet
|
||||
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0
|
||||
|
||||
@@ -981,7 +979,7 @@ def test_qa_view_pausiert_logic(tmp_path, monkeypatch):
|
||||
(tmp_path / TOPIC).mkdir()
|
||||
(tmp_path / TOPIC / "r1.json").write_text(_json.dumps(
|
||||
{"note": 5.0, "quoten": {"fremd": 0.2}, "fremd": ["X"], "unecht": ["Y"]}), encoding="utf-8")
|
||||
counts = {"inventory": {"done_block": 3}, "artefacts": {"subblocks": 4}}
|
||||
counts = {"inventory": {"done_block": 3}, "artefacts": {"generate": 4}}
|
||||
v = bi._qa_view(TOPIC, counts, None)
|
||||
assert v["pausiert"] is True and v["note"] == 5.0 and v["befunde"] == ["X", "Y"]
|
||||
from types import SimpleNamespace
|
||||
@@ -1115,11 +1113,11 @@ def test_hat_anker_ziffern_suffix():
|
||||
assert not bi._hat_anker("Algorithmus Verfahren", ctoks) # nur Stopwörter → kein Anker
|
||||
|
||||
|
||||
async def test_reset_subblocks_loescht_globale_dateien(testdb, tmp_path):
|
||||
"""Reset auf Spalte subblocks: DB-Spiegel UND globale Sidecar-Dateien + ab-*-Resume-Slots
|
||||
async def test_reset_generate_loescht_globale_dateien(testdb, tmp_path):
|
||||
"""Reset auf Spalte generate: DB-Spiegel UND globale Sidecar-Dateien + ab-*-Resume-Slots
|
||||
weg — Reste des Vor-Laufs würden sonst in den frischen Lauf zurückmergen."""
|
||||
db = testdb
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "artefacts", {"title": "Alpha"})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "artefakte", {"title": "Alpha"})
|
||||
await db.put_subblock(TOPIC, "alpha", "s1", "Alpha", "S1", status="consensus")
|
||||
arbeit = tmp_path / "arbeit"
|
||||
(arbeit / "ab-alpha").mkdir(parents=True)
|
||||
@@ -1128,7 +1126,7 @@ async def test_reset_subblocks_loescht_globale_dateien(testdb, tmp_path):
|
||||
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"):
|
||||
files[k] = tmp_path / f"{k}.json"
|
||||
files[k].write_text("{}", encoding="utf-8")
|
||||
moved = await bi.reset_board_from_stage(TOPIC, "artefacts", "subblocks", files)
|
||||
moved = await bi.reset_board_from_stage(TOPIC, "artefacts", "generate", files)
|
||||
assert moved == 1
|
||||
assert not await db.list_subblocks(TOPIC)
|
||||
assert not (arbeit / "ab-alpha").exists()
|
||||
|
||||
@@ -84,8 +84,9 @@ async def test_e2e_rerun_idempotent(fake_welt, testdb, tmp_path):
|
||||
|
||||
@pytest.mark.parametrize("stoerung", [
|
||||
{"muster": r"-sub-crossblock-.*-j1$", "modus": "fehler", "mal": 3}, # Ersatzrichter jE
|
||||
{"muster": r"-sub-konsolidierung-.*-j1$", "modus": "garbage", "mal": 1}, # Retry heilt
|
||||
{"muster": r"-facts-c\d+$", "modus": "fehler", "mal": 1}, # Slot-Restart
|
||||
{"muster": r"-sb-verify-.*-j1$", "modus": "garbage", "mal": 1}, # Ersatz-Richter jE
|
||||
{"muster": r"-sb-gen-.*-g1$", "modus": "fehler", "mal": 1}, # degraded: 1 Generator
|
||||
{"muster": r"-art-gen-.*-t1$", "modus": "fehler", "mal": 1}, # Slot-Restart
|
||||
{"muster": r"-research-2$", "modus": "fehler", "mal": 3}, # 1 Producer tot
|
||||
])
|
||||
async def test_e2e_stoerungen_flow_endet(fake_welt, testdb, tmp_path, stoerung):
|
||||
|
||||
@@ -156,7 +156,7 @@ async def test_restart_artefact_card_wipes_only_that_block(testdb):
|
||||
await db.upsert_question_pattern(TOPIC, norm, "s1", norm.title(), "Sub Eins", "Frage?")
|
||||
await db.put_sub_artifact(TOPIC, norm, "s1", "flashcard", norm.title(), "Sub Eins", "{}")
|
||||
assert await bi.restart_artefact_card(TOPIC, "alpha") is True
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "subblocks"
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "generate"
|
||||
assert await db.list_subblocks(TOPIC, "alpha") == []
|
||||
assert len(await db.list_subblocks(TOPIC, "beta")) == 1 # untouched
|
||||
assert await bi.restart_artefact_card(TOPIC, "gibtsnicht") is False
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Sub-Konsolidierung: In-Block-Panel (blocks._konsolidiere_subblocks) und
|
||||
Cross-Block-Barrier (board_artefacts._proc_konsolidierung) — Judges gefaked, gegen Test-DB."""
|
||||
"""Cross-Block-Konsolidierung (board_artefacts._proc_konsolidierung) und Finalize —
|
||||
Judges gefaked, gegen Test-DB. Die In-Block-Konsolidierung lebt seit dem Verschmelzungs-
|
||||
Umbau in block_calls._verify_block und wird in tests/test_block_calls.py getestet."""
|
||||
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import blocks
|
||||
import board_artefacts as ba
|
||||
@@ -39,218 +39,6 @@ async def _seed_block(db, bnorm, subs):
|
||||
await db.put_subblock(TOPIC, bnorm, blocks._norm_title(s), bnorm.title(), s, status="consensus")
|
||||
|
||||
|
||||
# ── In-Block ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_merge_on_unanimity(testdb, tmp_path, monkeypatch):
|
||||
"""Beide Judges gruppieren 1+2 → Gewinner (mehr key_points) bleibt, facts-Union,
|
||||
Verlierer wird DB-variant und fliegt aus raw/facts_map."""
|
||||
db = testdb
|
||||
subs = ["Durchstreichung: ~~text~~", "Durchstreichung: ~~text~~ streicht Text durch", "Fett: **text**"]
|
||||
await _seed_block(db, "betonung", subs)
|
||||
raw = {"Betonung": list(subs)}
|
||||
facts = {"Betonung": {
|
||||
blocks._norm_title(subs[0]): {"key_points": ["kp-a"], "cited_facts": [{"text": "z1"}]},
|
||||
blocks._norm_title(subs[1]): {"key_points": ["kp-b", "kp-c"], "cited_facts": [{"text": "z1"}, {"text": "z2"}]},
|
||||
}}
|
||||
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": ["Marker-Escaping fehlt"]},
|
||||
"j2": {"gruppen": [[2, 1]], "luecken": ["Escaping von Markern"]}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
|
||||
assert raw["Betonung"] == [subs[1], "Fett: **text**"] # Gewinner: 2 key_points > 1
|
||||
wf = facts["Betonung"][blocks._norm_title(subs[1])]
|
||||
assert wf["key_points"] == ["kp-b", "kp-c", "kp-a"]
|
||||
assert wf["cited_facts"] == [{"text": "z1"}, {"text": "z2"}] # Union ohne Doppel
|
||||
assert blocks._norm_title(subs[0]) not in facts["Betonung"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "betonung")}
|
||||
assert rows[blocks._norm_title(subs[0])] == "variant"
|
||||
assert rows[blocks._norm_title(subs[1])] == "consensus"
|
||||
journale = list(tmp_path.glob("sub-konsolidierung-*.json"))
|
||||
j = json.loads([p for p in journale if "-j" not in p.stem][0].read_text())
|
||||
# Lücken-Schnitt: Token-Überlappung beider Judges, Formulierung von j1 gewinnt
|
||||
assert j["gruppen"][0]["behalten"] == subs[1] and j["luecken"] == ["Marker-Escaping fehlt"]
|
||||
|
||||
|
||||
async def test_dissent_keeps_everything(testdb, tmp_path, monkeypatch):
|
||||
"""Nur ein Judge gruppiert → keine Einstimmigkeit → kein Merge."""
|
||||
db = testdb
|
||||
subs = ["Eintrag eins", "Eintrag zwei"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []},
|
||||
"j2": {"gruppen": [], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Block"] == subs
|
||||
assert all(r["status"] == "consensus" for r in await db.list_subblocks(TOPIC, "block"))
|
||||
|
||||
|
||||
async def test_judge_failure_fail_open(testdb, tmp_path, monkeypatch):
|
||||
"""Ein Judge UND der Ersatz ohne Ergebnis → fail-open, nichts ändert sich."""
|
||||
db = testdb
|
||||
subs = ["Eintrag eins", "Eintrag zwei"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []}}) # j2 UND j3 → FAILED
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Block"] == subs
|
||||
assert len(fake.calls) == 3 # j1, j2, Ersatz j3
|
||||
|
||||
|
||||
async def test_ersatzrichter_bei_ausfall(testdb, tmp_path, monkeypatch):
|
||||
"""j1 fällt aus → Ersatz j3 springt ein; Einstimmigkeit j2+j3 faltet.
|
||||
Vorher entwertete EIN Timeout die gute Stimme (13 Links-Dubletten überlebten)."""
|
||||
db = testdb
|
||||
subs = ["Kurz", "Deutlich längerer Eintrag"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
fake = _fake_slot({"j2": {"gruppen": [[1, 2]], "luecken": []},
|
||||
"j3": {"gruppen": [[2, 1]], "luecken": []}}) # j1 → FAILED
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Block"] == ["Deutlich längerer Eintrag"]
|
||||
|
||||
|
||||
async def test_negation_guard_blocks_merge(testdb, tmp_path, monkeypatch):
|
||||
"""Gegensätzliche Aussagen werden selbst bei einstimmigen Judges nicht gefaltet."""
|
||||
db = testdb
|
||||
subs = ["Tabs werden expandiert", "Tabs werden nicht expandiert"]
|
||||
await _seed_block(db, "tabs", subs)
|
||||
raw = {"Tabs": list(subs)}
|
||||
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []},
|
||||
"j2": {"gruppen": [[1, 2]], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Tabs"] == subs
|
||||
|
||||
|
||||
async def test_resume_skips_judges(testdb, tmp_path, monkeypatch):
|
||||
"""Vorhandene j-Dateien → kein neuer Agenten-Call, Ergebnis wird übernommen."""
|
||||
db = testdb
|
||||
subs = ["Eintrag eins", "Eintrag zwei lang"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
import hashlib
|
||||
h = hashlib.md5("\n".join(subs).encode()).hexdigest()[:8]
|
||||
for j in (1, 2):
|
||||
(tmp_path / f"sub-konsolidierung-{h}-j{j}.json").write_text(
|
||||
json.dumps({"gruppen": [[1, 2]], "luecken": []}), encoding="utf-8")
|
||||
|
||||
async def kein_agent(*a, **kw):
|
||||
raise AssertionError("Resume darf keinen Agenten starten")
|
||||
|
||||
monkeypatch.setattr(blocks, "run_single_slot", kein_agent)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Block"] == ["Eintrag zwei lang"]
|
||||
|
||||
|
||||
def test_schema_accepts_both_group_forms():
|
||||
"""Alte Listenform [1,4] und neue {haupt, weitere}-Form parsen beide; kataloge/fremd optional."""
|
||||
alt = blocks._konsolidierung_schema({"gruppen": [[1, 4]], "luecken": []}, 5)
|
||||
assert alt["gruppen"] == [{"haupt": None, "ids": [1, 4]}] and alt["fremd"] == set()
|
||||
neu = blocks._konsolidierung_schema(
|
||||
{"gruppen": [{"haupt": 4, "weitere": [1]}],
|
||||
"kataloge": [{"titel": "Katalog: Symbole", "mitglieder": [2, 3]}],
|
||||
"fremd": [5], "luecken": ["x"]}, 5)
|
||||
assert neu["gruppen"] == [{"haupt": 4, "ids": [1, 4]}]
|
||||
assert neu["kataloge"] == [{"titel": "Katalog: Symbole", "ids": [2, 3]}]
|
||||
assert neu["fremd"] == {5} and neu["luecken"] == ["x"]
|
||||
assert blocks._konsolidierung_schema({"gruppen": [{"haupt": 9, "weitere": [1]}]}, 5) == \
|
||||
{"gruppen": [], "kataloge": [], "fremd": set(), "luecken": []} # id out of range
|
||||
|
||||
|
||||
async def test_haupt_beats_heuristic(testdb, tmp_path, monkeypatch):
|
||||
"""Judges nennen den kürzeren Eintrag als haupt → er gewinnt trotz weniger key_points."""
|
||||
db = testdb
|
||||
subs = ["Basis", "Detailregel mit sehr langem Titel und Facts"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
facts = {"Block": {blocks._norm_title(subs[1]): {"key_points": ["a", "b", "c"]}}}
|
||||
fake = _fake_slot({"j1": {"gruppen": [{"haupt": 1, "weitere": [2]}], "luecken": []},
|
||||
"j2": {"gruppen": [{"haupt": 1, "weitere": [2]}], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
|
||||
assert raw["Block"] == ["Basis"]
|
||||
assert facts["Block"][blocks._norm_title("Basis")]["key_points"] == ["a", "b", "c"] # Union geerbt
|
||||
|
||||
|
||||
async def test_katalog_bundles_to_new_row(testdb, tmp_path, monkeypatch):
|
||||
"""Einstimmige Katalog-Mitglieder → neue consensus-Zeile mit Facts-Union, Mitglieder variant."""
|
||||
db = testdb
|
||||
subs = ["Pfeilsymbole: a b c", "Mengensymbole: d e f", "Eigene Regel"]
|
||||
await _seed_block(db, "mathe", subs)
|
||||
raw = {"Mathe": list(subs)}
|
||||
facts = {"Mathe": {blocks._norm_title(subs[0]): {"key_points": ["kp1"]},
|
||||
blocks._norm_title(subs[1]): {"key_points": ["kp2"]}}}
|
||||
kat = {"titel": "Symbolkatalog: Pfeile und Mengen", "mitglieder": [1, 2]}
|
||||
fake = _fake_slot({"j1": {"gruppen": [], "kataloge": [kat], "luecken": []},
|
||||
"j2": {"gruppen": [], "kataloge": [kat], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
|
||||
assert raw["Mathe"] == ["Eigene Regel", "Symbolkatalog: Pfeile und Mengen"]
|
||||
kn = blocks._norm_title("Symbolkatalog: Pfeile und Mengen")
|
||||
assert sorted(facts["Mathe"][kn]["key_points"]) == ["kp1", "kp2"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "mathe")}
|
||||
assert rows[kn] == "consensus"
|
||||
assert rows[blocks._norm_title(subs[0])] == "variant"
|
||||
|
||||
|
||||
async def test_katalog_dissent_keeps_members(testdb, tmp_path, monkeypatch):
|
||||
db = testdb
|
||||
subs = ["Pfeilsymbole: a b c", "Mengensymbole: d e f"]
|
||||
await _seed_block(db, "mathe", subs)
|
||||
raw = {"Mathe": list(subs)}
|
||||
fake = _fake_slot({"j1": {"gruppen": [], "kataloge": [{"titel": "K", "mitglieder": [1, 2]}], "luecken": []},
|
||||
"j2": {"gruppen": [], "kataloge": [], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Mathe"] == subs
|
||||
|
||||
|
||||
async def test_fremd_unanimous_discards(testdb, tmp_path, monkeypatch):
|
||||
"""Einstimmig fremd → discarded + raus; einseitig fremd → bleibt."""
|
||||
db = testdb
|
||||
subs = ["CSS display überschreibt Verhalten", "Echte Markdown-Regel", "Nur einer hält es für fremd"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
fake = _fake_slot({"j1": {"gruppen": [], "fremd": [1, 3], "luecken": []},
|
||||
"j2": {"gruppen": [], "fremd": [1], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
luecken = await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Block"] == [subs[1], subs[2]]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "block")}
|
||||
assert rows[blocks._norm_title(subs[0])] == "discarded"
|
||||
assert rows[blocks._norm_title(subs[2])] == "consensus"
|
||||
assert luecken == {}
|
||||
|
||||
|
||||
async def test_luecken_nur_bei_einstimmigkeit(testdb, tmp_path, monkeypatch):
|
||||
"""Nur Lücken mit Token-Überlappung BEIDER Judges überleben; einseitige fallen weg."""
|
||||
db = testdb
|
||||
subs = ["Eintrag eins", "Eintrag zwei"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
fake = _fake_slot({"j1": {"gruppen": [], "luecken": ["Inline-HTML fehlt", "Front-Matter"]},
|
||||
"j2": {"gruppen": [], "luecken": ["nichts zu Inline-HTML"]}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
luecken = await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert luecken == {"Block": ["Inline-HTML fehlt"]}
|
||||
|
||||
|
||||
async def test_kp_deckel_im_judge_prompt(testdb, tmp_path, monkeypatch):
|
||||
"""Prompt zeigt max. 3 key_points je Sub (Timeout-Schutz); die Union bleibt voll."""
|
||||
db = testdb
|
||||
subs = ["Eintrag eins", "Eintrag zwei"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
facts = {"Block": {blocks._norm_title(subs[0]): {"key_points": [f"kp{i}" for i in range(1, 6)]}}}
|
||||
fake = _fake_slot({"j1": {"gruppen": [], "luecken": []}, "j2": {"gruppen": [], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
|
||||
prompt = fake.calls[0]["prompt"]
|
||||
assert "kp3" in prompt and "kp4" not in prompt
|
||||
|
||||
|
||||
def test_luecken_schnitt_cap():
|
||||
l1 = [f"Aspekt-{k} fehlt" for k in ("eins", "zwei", "drei", "vier", "fünf")]
|
||||
assert blocks._luecken_schnitt(l1, list(l1)) == l1[:3] # Cap 3
|
||||
@@ -266,91 +54,6 @@ def test_neg_set_lemmatisiert():
|
||||
assert blocks._neg_set("niemals gerendert") == blocks._neg_set("nie gerendert")
|
||||
|
||||
|
||||
# ── Lücken-Nachfass ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _nachfass_env(db, monkeypatch, facts_result):
|
||||
subs = ["Eintrag eins"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
facts_map = {"Block": {}}
|
||||
|
||||
async def fake_race(topic, label, slots, quorum, timeout, provider, cancelled=None, grace=0):
|
||||
return [{"Block": ["Eintrag eins", "Neuer Aspekt"]}]
|
||||
|
||||
async def fake_facts(ctx, set_p, files, fraw, q, folder, instructions, ns="", lbl="", sources=None, slim=False):
|
||||
assert slim is True # Nachfass nutzt die schlanke Facts-Variante
|
||||
assert list(fraw["Block"]) == ["Neuer Aspekt"] # nur der frische Fund geht ins Gate
|
||||
return facts_result
|
||||
|
||||
monkeypatch.setattr(blocks, "_race", fake_race)
|
||||
monkeypatch.setattr(blocks, "_facts_block", fake_facts)
|
||||
monkeypatch.setattr(blocks, "EMBEDDING_AKTIV", False)
|
||||
return raw, facts_map
|
||||
|
||||
|
||||
async def test_nachfass_adopts_backed_find(testdb, tmp_path, monkeypatch):
|
||||
db = testdb
|
||||
nn = blocks._norm_title("Neuer Aspekt")
|
||||
raw, facts_map = await _nachfass_env(db, monkeypatch,
|
||||
({"Block": {nn: {"key_points": ["kp"]}}}, {}))
|
||||
n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"],
|
||||
raw, facts_map, {"type": "thema"}, None)
|
||||
assert n == 1 and raw["Block"] == ["Eintrag eins", "Neuer Aspekt"]
|
||||
assert facts_map["Block"][nn]["key_points"] == ["kp"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "block")}
|
||||
assert rows[nn] == "consensus"
|
||||
|
||||
|
||||
async def test_nachfass_drops_unbacked_find(testdb, tmp_path, monkeypatch):
|
||||
db = testdb
|
||||
nn = blocks._norm_title("Neuer Aspekt")
|
||||
raw, facts_map = await _nachfass_env(db, monkeypatch, ({}, {"Block": {nn}}))
|
||||
n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"],
|
||||
raw, facts_map, {"type": "thema"}, None)
|
||||
assert n == 0 and raw["Block"] == ["Eintrag eins"]
|
||||
assert not any(r["sub_norm"] == nn for r in await db.list_subblocks(TOPIC, "block"))
|
||||
|
||||
|
||||
async def test_nachfass_drops_find_without_facts(testdb, tmp_path, monkeypatch):
|
||||
"""HARTES Gate: kein Facts-Eintrag = kein Beleg = keine Übernahme — nicht nur
|
||||
aktiv Verworfenes fliegt (Bilder-Lauf: 13 von 18 kamen ohne Beleg durch)."""
|
||||
db = testdb
|
||||
raw, facts_map = await _nachfass_env(db, monkeypatch, ({}, {})) # Facts fand NICHTS
|
||||
n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"],
|
||||
raw, facts_map, {"type": "thema"}, None)
|
||||
assert n == 0 and raw["Block"] == ["Eintrag eins"]
|
||||
|
||||
|
||||
async def test_facts_stage_konsolidiert_nachfass_funde_erneut(testdb, tmp_path, monkeypatch):
|
||||
"""Kreis geschlossen: nach Übernahmen läuft die Konsolidierung ein zweites Mal;
|
||||
deren Lücken lösen KEINEN weiteren Nachfass aus."""
|
||||
db = testdb
|
||||
payload = {"title": "Alpha", "raw": {"Alpha": ["s1"]}}
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "facts", payload)
|
||||
calls = {"kons": 0, "nf": 0}
|
||||
|
||||
async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="", sources=None, slim=False):
|
||||
return {"Alpha": {}}, {}
|
||||
|
||||
async def fake_kons(ctx, files, raw, facts_map, instructions="", ns="", lbl=""):
|
||||
calls["kons"] += 1
|
||||
return {"Alpha": ["Lücke X"]} # meldet auch in Runde 2 — darf nicht erneut nachfassen
|
||||
|
||||
async def fake_nf(ctx, files, title, luecken, raw, facts_map, q, folder,
|
||||
instructions="", ns="", lbl="", sources=None):
|
||||
calls["nf"] += 1
|
||||
return 2
|
||||
|
||||
monkeypatch.setattr(ba, "_facts_block", fake_facts)
|
||||
monkeypatch.setattr(ba, "_konsolidiere_subblocks", fake_kons)
|
||||
monkeypatch.setattr(ba, "_luecken_runde", fake_nf)
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
await ba._proc_facts(_ctx(), flow, {"arbeit": tmp_path}, {"type": "thema"}, None, "",
|
||||
[{"card_id": "alpha", "payload": payload}])
|
||||
assert calls == {"kons": 2, "nf": 1}
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "levels"
|
||||
|
||||
|
||||
async def test_finalize_purges_stale_rows(testdb, tmp_path):
|
||||
"""Re-Run-Waisen: Finalize löscht Alt-Fragen/-Artefakte des Blocks vor dem Upsert."""
|
||||
db = testdb
|
||||
@@ -486,11 +189,13 @@ async def test_crossblock_without_embedding_advances(testdb, tmp_path, monkeypat
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == ba.DONE
|
||||
|
||||
|
||||
async def test_crossblock_nachzuegler_zurueck_zu_fragen(testdb, tmp_path, monkeypatch):
|
||||
"""Resume-Karte aus der alten Stage-Position (kein pattern im Payload) → zurück nach
|
||||
question_pattern, KEIN Dedup — finalize würde den Fold sonst re-spiegeln."""
|
||||
async def test_crossblock_nachzuegler_zurueck_zum_erzeugen(testdb, tmp_path, monkeypatch):
|
||||
"""Resume-Karte ohne pattern im Payload → zurück nach generate (bzw. artefakte bei
|
||||
vorhandenem sidecar), KEIN Dedup — finalize würde den Fold sonst re-spiegeln."""
|
||||
db = testdb
|
||||
flow, cards, files = await _cross_env(db, tmp_path, finalisiert=False)
|
||||
cards[1]["payload"]["sidecar"] = {"Beta": []} # hat Verify schon hinter sich
|
||||
await db.kanban_set_payload(TOPIC, "artefacts", "beta", cards[1]["payload"])
|
||||
|
||||
async def kein_agent(*a, **kw):
|
||||
raise AssertionError("Nachzügler dürfen keinen Dedup auslösen")
|
||||
@@ -498,8 +203,8 @@ async def test_crossblock_nachzuegler_zurueck_zu_fragen(testdb, tmp_path, monkey
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
for cid in ("alpha", "beta"):
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == "question_pattern"
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "generate"
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == "artefakte"
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert rows[blocks._norm_title("Gleiche Aussage")] == "consensus"
|
||||
|
||||
@@ -558,47 +263,3 @@ async def test_crossblock_chunking_faltet_global(testdb, tmp_path, monkeypatch):
|
||||
assert len(fake.calls) == 4 # 2 Chunks × j1/j2
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert set(rows.values()) == {"variant"} # beide Dubletten global gefaltet
|
||||
|
||||
|
||||
async def test_facts_nachfass_holt_nur_fehlende(testdb, tmp_path, monkeypatch):
|
||||
"""Nachfass ruft den slim-Facts-Lauf NUR mit den facts-losen Subs und merged die Funde;
|
||||
Vorhandenes bleibt unberührt, Subs werden nie verworfen."""
|
||||
gesehen = {}
|
||||
|
||||
async def fake_facts_block(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="",
|
||||
sources=None, slim=False):
|
||||
gesehen["raw"] = raw
|
||||
gesehen["slim"] = slim
|
||||
return ({"Alpha": {"ohne beleg": {"key_points": ["kp neu"]},
|
||||
"mit beleg": {"key_points": ["DARF NICHT GEWINNEN"]}}},
|
||||
{"Alpha": {"ohne beleg"}}) # discard-Urteil wird ignoriert
|
||||
|
||||
monkeypatch.setattr(blocks, "_facts_block", fake_facts_block)
|
||||
raw = {"Alpha": ["Mit Beleg", "Ohne Beleg"]}
|
||||
facts_map = {"Alpha": {"mit beleg": {"key_points": ["kp alt"]}}}
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
n = await blocks._facts_nachfass(ctx, {"arbeit": tmp_path}, raw, facts_map, {}, None)
|
||||
assert n == 1
|
||||
assert gesehen["slim"] and gesehen["raw"] == {"Alpha": ["Ohne Beleg"]}
|
||||
assert facts_map["Alpha"]["ohne beleg"]["key_points"] == ["kp neu"]
|
||||
assert facts_map["Alpha"]["mit beleg"]["key_points"] == ["kp alt"]
|
||||
assert raw["Alpha"] == ["Mit Beleg", "Ohne Beleg"] # kein Verwurf
|
||||
|
||||
|
||||
async def test_levels_merge_fuzzy_match(testdb, tmp_path, monkeypatch):
|
||||
"""Levels-Agent paraphrasiert den Sub-Titel → facts hängen trotzdem am Sidecar-Eintrag
|
||||
(eindeutiger Präfix-Match statt stillem Grounding-Verlust)."""
|
||||
db = testdb
|
||||
payload = {"title": "Alpha", "raw": {"Alpha": ["Marker Regel: Details dazu"]},
|
||||
"facts": {"Alpha": {blocks._norm_title("Marker Regel: Details dazu"):
|
||||
{"key_points": ["kp"]}}}}
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "levels", payload)
|
||||
cards = [{"card_id": "alpha", "payload": payload}]
|
||||
|
||||
async def fake_levels_block(ctx, set_p, files, raw, instructions, ns="", lbl=""):
|
||||
return {"Alpha": [{"title": "Marker Regel", "level": "beginner"}]} # gekürzter Titel
|
||||
|
||||
monkeypatch.setattr(ba, "_levels_block", fake_levels_block)
|
||||
await ba._proc_levels(_ctx(), Flow(TOPIC, work_dir=tmp_path), {"arbeit": tmp_path}, "", cards)
|
||||
card = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
|
||||
assert card["payload"]["sidecar"]["Alpha"][0]["facts"] == {"key_points": ["kp"]}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
"""Subbaustein-Qualität: Varianten-Konsens, Seed-Garantie, Nachfass, Outline-Review."""
|
||||
"""Subbaustein-Helfer: Varianten-Cluster, Evidence-Packs, Outline-Review, Hash/Key-Auflösung.
|
||||
Die verschmolzene Block-Pipeline (Generate/Verify/Artefakte) testet tests/test_block_calls.py."""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import blocks as blx
|
||||
from pipeline import GenContext
|
||||
|
||||
TOPIC = "t"
|
||||
_MD_PATH = re.compile(r"(/\S+\.md)")
|
||||
|
||||
|
||||
# ── _variant_clusters (pure) ─────────────────────────────────────────────────────────
|
||||
@@ -37,157 +35,6 @@ def test_variant_clusters_negation_guard():
|
||||
assert len(cl) == 2 # antonyms never merge, no matter the cosine
|
||||
|
||||
|
||||
# ── _subblocks_block integration (fake race + fake embeddings) ──────────────────────
|
||||
|
||||
def _fake_sims(texts):
|
||||
"""Markertoken matrix: same first word → 0.95, else 0."""
|
||||
n = len(texts)
|
||||
m = np.eye(n)
|
||||
key = lambda t: t.split()[0].casefold()
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
if i != j and key(texts[i]) == key(texts[j]):
|
||||
m[i][j] = 0.95
|
||||
return m
|
||||
|
||||
|
||||
def _mk_race(finder_by_agent):
|
||||
"""Key-routed _race fake. Finder round 1 → scripted per-agent subs; later finder and
|
||||
catch-up rounds → nothing; clarify judges echo the consensus lines from their prompt."""
|
||||
prompts = []
|
||||
|
||||
async def fake_race(topic, label, slots, quorum, timeout, provider, on_update=None,
|
||||
cancelled=None, *, grace=None, min_runtime=None, max_runtime=None, late=None):
|
||||
outs = []
|
||||
for slot in slots:
|
||||
key, prompt = slot["key"], slot["prompt"]
|
||||
prompts.append((key, prompt))
|
||||
fake_race.slots_seen.append(slot)
|
||||
if "-subblock-final-" in key:
|
||||
# no-tool judges reply as TEXT; the payload sink writes the j-file itself
|
||||
kons = re.search(r"Konsens \(≥2 finders\):\n(.*?)\nUnsicher", prompt, re.S)
|
||||
subs = [l[2:] for l in (kons.group(1).splitlines() if kons else [])
|
||||
if l.startswith("- ") and l != "- (keiner)"]
|
||||
if subs:
|
||||
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
|
||||
outs.append(slot["payload"]((0, text, "")))
|
||||
continue
|
||||
if "-r1-" in key:
|
||||
agent = int(key.rsplit("-", 1)[1])
|
||||
subs = finder_by_agent.get(agent) or []
|
||||
if subs: # Finder antworten als TEXT (Marker-Format), kein out_path mehr
|
||||
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
|
||||
outs.append(slot["payload"]((0, text, "")))
|
||||
outs = [o for o in outs if o]
|
||||
return outs or None
|
||||
fake_race.slots_seen = []
|
||||
return fake_race, prompts
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sub_env(testdb, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(blx, "EMBEDDING_AKTIV", True)
|
||||
monkeypatch.setattr(blx.embedding, "available", lambda: True)
|
||||
monkeypatch.setattr(blx.embedding, "embed_sims", _fake_sims)
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
files = {"arbeit": tmp_path}
|
||||
return testdb, ctx, files
|
||||
|
||||
|
||||
async def _run(ctx, files, monkeypatch, finder_by_agent, seeds=None):
|
||||
fake, prompts = _mk_race(finder_by_agent)
|
||||
monkeypatch.setattr(blx, "_race", fake)
|
||||
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-", seeds=seeds)
|
||||
return raw, prompts
|
||||
|
||||
|
||||
async def test_variant_consensus_end_to_end(sub_env, monkeypatch):
|
||||
"""3 Einzelfunde in 3 Formulierungen → EIN consensus-Repräsentant; Varianten gehen
|
||||
nicht als „Unsicher" ins Panel."""
|
||||
db, ctx, files = sub_env
|
||||
raw, prompts = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Umbruch braucht Marker"],
|
||||
2: ["Umbruch erfordert explizite Marker!"],
|
||||
3: ["Umbruch verlangt zwei Leerzeichen als Marker"],
|
||||
})
|
||||
assert raw == {"Alpha": ["Umbruch verlangt zwei Leerzeichen als Marker"]} # longest = rep
|
||||
rows = await db.list_subblocks(TOPIC, "alpha")
|
||||
status = sorted(r["status"] for r in rows)
|
||||
assert status == ["consensus", "variant", "variant"]
|
||||
clarify_prompts = [p for k, p in prompts if "-subblock-final-" in k]
|
||||
assert clarify_prompts and "Umbruch braucht Marker" not in clarify_prompts[0]
|
||||
|
||||
|
||||
async def test_seed_promotes_single_find(sub_env, monkeypatch):
|
||||
"""Seed deckt einen verworfenen Einzelfund lexikalisch → Promotion zu consensus."""
|
||||
db, ctx, files = sub_env
|
||||
raw, _ = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Alpha Grundlagen", "Zeilenumbruch Regeln im Detail"],
|
||||
2: ["Alpha Grundlagen"],
|
||||
}, seeds=["Zeilenumbruch Regeln"])
|
||||
assert "Zeilenumbruch Regeln im Detail" in raw["Alpha"]
|
||||
row = next(r for r in await db.list_subblocks(TOPIC, "alpha")
|
||||
if r["sub_norm"] == "zeilenumbruch regeln im detail")
|
||||
assert row["status"] == "consensus"
|
||||
|
||||
|
||||
async def test_seed_inserted_when_nothing_found(sub_env, monkeypatch):
|
||||
"""Seed ohne jeden Fund wird als eigener consensus-Sub eingefügt (Facts-Gate prüft später)."""
|
||||
db, ctx, files = sub_env
|
||||
raw, _ = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"],
|
||||
}, seeds=["Fußnoten Syntax"])
|
||||
assert "Fußnoten Syntax" in raw["Alpha"]
|
||||
row = next(r for r in await db.list_subblocks(TOPIC, "alpha")
|
||||
if r["sub_title"] == "Fußnoten Syntax")
|
||||
assert row["status"] == "consensus"
|
||||
|
||||
|
||||
async def test_seed_covered_no_duplicate(sub_env, monkeypatch):
|
||||
"""Seed lexikalisch von einem consensus-Sub abgedeckt → nichts eingefügt."""
|
||||
db, ctx, files = sub_env
|
||||
raw, _ = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Tabs werden zu Leerzeichen expandiert"], 2: ["Tabs werden zu Leerzeichen expandiert"],
|
||||
}, seeds=["Tabs"])
|
||||
assert raw == {"Alpha": ["Tabs werden zu Leerzeichen expandiert"]}
|
||||
|
||||
|
||||
async def test_wipe_false_is_idempotent(sub_env, monkeypatch):
|
||||
"""Zweiter Karten-Lauf kumuliert keine Mentions (per-Block-Wipe)."""
|
||||
db, ctx, files = sub_env
|
||||
await _run(ctx, files, monkeypatch, {1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||
first = {r["sub_norm"]: r["mentions"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
await _run(ctx, files, monkeypatch, {1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||
second = {r["sub_norm"]: r["mentions"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert first == second
|
||||
|
||||
|
||||
async def test_catchup_adds_and_stops(sub_env, monkeypatch, tmp_path):
|
||||
"""Block unter SUBBLOCK_MIN: Nachfass-Runde findet Neues → eigenes Final-File,
|
||||
Konsens wächst; zweite Runde ohne Neues → Ende."""
|
||||
db, ctx, files = sub_env
|
||||
fake, prompts = _mk_race({1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||
base = fake
|
||||
hit = {"n": 0}
|
||||
|
||||
async def with_catchup(topic, label, slots, *a, **k):
|
||||
if any("-subblock-x" in s["key"] for s in slots):
|
||||
hit["n"] += 1
|
||||
if hit["n"] == 1: # first catch-up round: both agents agree on one new sub
|
||||
text = "<!-- block: Alpha -->\n- Vertiefung der Konzepte"
|
||||
return [slot["payload"]((0, text, "")) for slot in slots[:2]]
|
||||
return None
|
||||
return await base(topic, label, slots, *a, **k)
|
||||
|
||||
monkeypatch.setattr(blx, "_race", with_catchup)
|
||||
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-")
|
||||
assert set(raw["Alpha"]) == {"Alpha Grundlagen", "Vertiefung der Konzepte"}
|
||||
assert (tmp_path / "subblock-final-c1-x1.md").exists()
|
||||
assert hit["n"] == 2 # round 2 ran, found nothing, loop ended
|
||||
|
||||
|
||||
# ── Outline-Review ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_outline_review_schema():
|
||||
@@ -238,52 +85,8 @@ async def test_outline_review_moves_block(testdb, tmp_path, monkeypatch):
|
||||
plan2 = await blx._outline_block(ctx, lambda *a, **k: None, files, entries, "")
|
||||
assert plan2["chapters"][0]["numbers"] == [1, 2, 6]
|
||||
|
||||
async def test_paraphrase_saturation_stops_early(sub_env, monkeypatch):
|
||||
"""Runde 2 liefert nur eine Paraphrase → zählt nicht als neu, Schleife endet ohne r3.
|
||||
Die Paraphrase liegt trotzdem in der DB (Mention fürs Cluster-Voting)."""
|
||||
db, ctx, files = sub_env
|
||||
base_fake, prompts = _mk_race({1: ["Umbruch braucht Marker"], 2: ["Umbruch braucht Marker"]})
|
||||
|
||||
async def with_r2(topic, label, slots, *a, **k):
|
||||
if any("-r2-" in s["key"] for s in slots):
|
||||
text = "<!-- block: Alpha -->\n- Umbruch erfordert explizite Marker!"
|
||||
return [slot["payload"]((0, text, "")) for slot in slots[:2]]
|
||||
return await base_fake(topic, label, slots, *a, **k)
|
||||
|
||||
monkeypatch.setattr(blx, "_race", with_r2)
|
||||
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-")
|
||||
assert raw["Alpha"] # Konsens steht
|
||||
assert not any("-r3-" in k for k, _ in prompts) # Paraphrase hielt die Schleife NICHT am Leben
|
||||
rows = await db.list_subblocks(TOPIC, "alpha")
|
||||
assert any(r["sub_title"] == "Umbruch erfordert explizite Marker!" for r in rows)
|
||||
|
||||
|
||||
async def test_round_cap_stops_endless_finders(sub_env, monkeypatch):
|
||||
"""Jede Runde ein echt neues Konzept → hartes Cap stoppt bei SUBBLOCK_MAX_ROUNDS."""
|
||||
db, ctx, files = sub_env
|
||||
_, prompts = _mk_race({})
|
||||
|
||||
async def endless(topic, label, slots, *a, **k):
|
||||
if "-subblock-final-" in slots[0]["key"]:
|
||||
return None # panel fails → consensus fallback
|
||||
outs = []
|
||||
import re as _re
|
||||
rn = _re.search(r"-r(\d+)-", slots[0]["key"])
|
||||
n = rn.group(1) if rn else "x"
|
||||
for slot in slots[:2]:
|
||||
prompts.append((slot["key"], slot["prompt"]))
|
||||
outs.append(slot["payload"]((0, f"<!-- block: Alpha -->\n- Konzept{n} ist eigenständig", "")))
|
||||
return outs
|
||||
|
||||
monkeypatch.setattr(blx, "_race", endless)
|
||||
await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-")
|
||||
max_round = max(int(k.split("-r")[1].split("-")[0]) for k, _ in prompts if "-r" in k and "-subblock-c" in k)
|
||||
assert max_round == blx.SUBBLOCK_MAX_ROUNDS
|
||||
|
||||
|
||||
# ── Inline-Evidenz für Judges (Token-Umbau) ──────────────────────────────────────────
|
||||
# ── Inline-Evidenz (Evidence-Packs für die verschmolzenen Calls) ─────────────────────
|
||||
|
||||
def _corpus(tmp_path):
|
||||
d = tmp_path / "korpus"
|
||||
@@ -348,51 +151,6 @@ def test_sink_json_writes_only_valid(tmp_path):
|
||||
assert bad is None and not (tmp_path / "x.json").exists()
|
||||
|
||||
|
||||
async def test_clarify_inline_evidence_no_tools(sub_env, monkeypatch, tmp_path):
|
||||
"""Mit Korpus: Judges UND Finder bekommen Auszüge inline und laufen ohne Tools
|
||||
(Text-Antwort); die Dateien schreibt die Engine. Der Finder verlor vorher 3–13
|
||||
Tool-Runden pro Call mit der Material-Suche via glob/grep/bash."""
|
||||
db, ctx, files = sub_env
|
||||
d = _corpus(tmp_path)
|
||||
monkeypatch.setattr(blx, "source_folder", lambda t: d)
|
||||
monkeypatch.setattr(blx, "load_source", lambda t: {"type": "uni"})
|
||||
fake, prompts = _mk_race({1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||
monkeypatch.setattr(blx, "_race", fake)
|
||||
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-", sources=["Skript.txt"])
|
||||
assert raw == {"Alpha": ["Alpha Grundlagen"]}
|
||||
judges = [s for s in fake.slots_seen if "-subblock-final-" in s["key"]]
|
||||
finders = [s for s in fake.slots_seen if "-r1-" in s["key"]]
|
||||
assert judges and all(s["capabilities"] == "none" for s in judges)
|
||||
assert "── Skript.txt" in judges[0]["prompt"]
|
||||
assert "ls/find" not in judges[0]["prompt"] # keine Selbst-Recherche-Anweisung mehr
|
||||
assert finders and all(s["capabilities"] == "none" for s in finders)
|
||||
assert "── Skript.txt" in finders[0]["prompt"] # Auszüge inline statt Dateisystem-Suche
|
||||
assert "web search" not in finders[0]["prompt"]
|
||||
assert list(tmp_path.glob("subblock-final-*-j*.md")) # Engine persistiert die Judge-Antwort
|
||||
|
||||
|
||||
async def test_thema_nutzt_research_material_inline(sub_env, monkeypatch, tmp_path):
|
||||
"""thema mit Research-Fundstellen (arbeit/material/*.txt): Finder bekommt sie inline
|
||||
und läuft ohne Tools — vorher eigene Websuche pro Call (Reasoning-Schleifen, Retries)."""
|
||||
db, ctx, files = sub_env
|
||||
monkeypatch.setattr(blx, "source_folder", lambda t: None)
|
||||
md = tmp_path / "arbeit" / "material"
|
||||
md.mkdir(parents=True)
|
||||
(md / "research-1.txt").write_text(
|
||||
"https://example.org/alpha\nAlpha Grundlagen: der Kernbegriff, gut belegt.\n",
|
||||
encoding="utf-8")
|
||||
monkeypatch.setattr(blx, "arbeit_dir", lambda t: tmp_path / "arbeit")
|
||||
fake, prompts = _mk_race({1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||
monkeypatch.setattr(blx, "_race", fake)
|
||||
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-")
|
||||
assert raw == {"Alpha": ["Alpha Grundlagen"]}
|
||||
finders = [s for s in fake.slots_seen if "-r1-" in s["key"]]
|
||||
assert finders and all(s["capabilities"] == "none" for s in finders)
|
||||
assert "── research-1.txt" in finders[0]["prompt"] # Fundstellen inline
|
||||
|
||||
|
||||
def test_material_folder_fallbacks(monkeypatch, tmp_path):
|
||||
"""Echte Quelle gewinnt; sonst arbeit/material mit Inhalt; sonst None."""
|
||||
monkeypatch.setattr(blx, "source_folder", lambda t: tmp_path / "quelle")
|
||||
@@ -407,115 +165,6 @@ def test_material_folder_fallbacks(monkeypatch, tmp_path):
|
||||
assert blx.material_folder("t") == md
|
||||
|
||||
|
||||
async def test_panel_2of3_kehrt_bei_einigkeit_zurueck(tmp_path):
|
||||
"""Zwei übereinstimmende Verdicts → Rückkehr ohne den langsamen Dritten; sein
|
||||
Ergebnis wird detached nachpersistiert (Resume)."""
|
||||
import asyncio as aio
|
||||
gesunken = {}
|
||||
|
||||
async def judge(j, delay, antwort):
|
||||
await aio.sleep(delay)
|
||||
return (0, antwort, "")
|
||||
|
||||
tasks = {aio.create_task(judge(1, 0.01, "a")): 1,
|
||||
aio.create_task(judge(2, 0.02, "a")): 2,
|
||||
aio.create_task(judge(3, 5.0, "b")): 3}
|
||||
|
||||
def sink(j, r):
|
||||
gesunken[j] = r[1]
|
||||
|
||||
import time
|
||||
t0 = time.monotonic()
|
||||
await blx._panel_2of3(tasks, sink, lambda: list(gesunken.values()), lambda s: s)
|
||||
assert time.monotonic() - t0 < 1.0 # nicht auf j3 gewartet
|
||||
assert gesunken == {1: "a", 2: "a"}
|
||||
|
||||
|
||||
async def test_panel_2of3_dissens_wartet_auf_dritten():
|
||||
"""Uneinige erste zwei → der dritte wird abgewartet (Mehrheit braucht ihn)."""
|
||||
import asyncio as aio
|
||||
gesunken = {}
|
||||
|
||||
async def judge(j, delay, antwort):
|
||||
await aio.sleep(delay)
|
||||
return (0, antwort, "")
|
||||
|
||||
tasks = {aio.create_task(judge(1, 0.01, "a")): 1,
|
||||
aio.create_task(judge(2, 0.02, "b")): 2,
|
||||
aio.create_task(judge(3, 0.1, "a")): 3}
|
||||
await blx._panel_2of3(tasks, lambda j, r: gesunken.__setitem__(j, r[1]),
|
||||
lambda: list(gesunken.values()), lambda s: s)
|
||||
assert gesunken == {1: "a", 2: "b", 3: "a"}
|
||||
|
||||
|
||||
async def test_facts_check_inline_cited_evidence(sub_env, monkeypatch, tmp_path):
|
||||
"""Facts-Check: zitierte Zeilenbereiche gehen inline mit, Judge läuft ohne Tools,
|
||||
die Check-Datei schreibt die Engine aus der Text-Antwort."""
|
||||
db, ctx, files = sub_env
|
||||
d = _corpus(tmp_path)
|
||||
monkeypatch.setattr(blx, "source_folder", lambda t: d)
|
||||
facts = {"facts": [{"block": "Alpha", "subblock": "Sub Eins", "key_points": ["k"],
|
||||
"prerequisites": "", "hurdles": "",
|
||||
"cited_facts": [{"text": "T", "source": "Skript.txt, Z.2"}],
|
||||
"example_idea": ""}]}
|
||||
|
||||
sh = blx._subs_hash({"Alpha": ["Sub Eins"]}) # Resume-Dateien tragen den Sub-Satz-Hash
|
||||
|
||||
async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||
if "-facts-erg-" in key:
|
||||
return blx.FAILED, None
|
||||
(tmp_path / f"facts-{sh}-c0.json").write_text(json.dumps(facts), encoding="utf-8")
|
||||
return blx.OK, None
|
||||
|
||||
seen = []
|
||||
|
||||
async def fake_agent(key, prompt, timeout, provider="", role="", capabilities="", scope=None, label="", **kw):
|
||||
seen.append((key, capabilities, prompt))
|
||||
return (0, '{"ok": true}', "")
|
||||
|
||||
monkeypatch.setattr(blx, "run_single_slot", fake_slot)
|
||||
monkeypatch.setattr(blx, "run_agent", fake_agent)
|
||||
res = await blx._facts_block(ctx, lambda *a, **k: None, {"arbeit": tmp_path},
|
||||
{"Alpha": ["Sub Eins"]}, {"type": "uni"}, d, "", ns="x-")
|
||||
assert res is not None
|
||||
facts_map, discarded = res
|
||||
assert "Alpha" in facts_map and not discarded
|
||||
assert len(seen) == blx.FACTS_CHECK_PANEL
|
||||
key, caps, prompt = seen[0]
|
||||
assert caps == "none" and "── Skript.txt · Z." in prompt
|
||||
assert (tmp_path / f"facts-check-{sh}-c0-j1.json").exists() # Engine persistiert die Antwort
|
||||
|
||||
|
||||
async def test_facts_find_inline_evidence_no_tools(sub_env, monkeypatch, tmp_path):
|
||||
"""Facts find/erg mit Korpus: Auszüge inline, Agent ohne Tools — Tool-Agenten
|
||||
verloren sich in Reasoning-Schleifen und endeten mit leerem Turn (Retry-Wellen)."""
|
||||
db, ctx, files = sub_env
|
||||
d = _corpus(tmp_path)
|
||||
monkeypatch.setattr(blx, "source_folder", lambda t: d)
|
||||
seen = []
|
||||
facts = {"facts": [{"block": "Alpha", "subblock": "Sub Eins", "key_points": ["k"],
|
||||
"prerequisites": "", "hurdles": "",
|
||||
"cited_facts": [{"text": "T", "source": "Skript.txt, Z.2"}],
|
||||
"example_idea": ""}]}
|
||||
|
||||
async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||
seen.append((key, capabilities, prompt))
|
||||
return blx.OK, payload((0, json.dumps(facts), ""))
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw): # Check-Panel
|
||||
return (0, '{"ok": true}', "")
|
||||
|
||||
monkeypatch.setattr(blx, "run_single_slot", fake_slot)
|
||||
monkeypatch.setattr(blx, "run_agent", fake_agent)
|
||||
res = await blx._facts_block(ctx, lambda *a, **k: None, {"arbeit": tmp_path},
|
||||
{"Alpha": ["Sub Eins"]}, {"type": "uni"}, d, "", ns="x-")
|
||||
assert res is not None
|
||||
finder = [s for s in seen if "-facts-c0" in s[0] or "-facts-erg-" in s[0]]
|
||||
assert finder and all(caps == "none" for _, caps, _ in finder)
|
||||
assert all("── Skript.txt" in prompt for _, _, prompt in finder) # Auszüge inline
|
||||
assert all("ls/find" not in prompt for _, _, prompt in finder)
|
||||
|
||||
|
||||
def test_sub_key_resolves_short_titles():
|
||||
"""Artefakt-Agenten echoen den Kurztitel; der Sub-Key heißt 'kurztitel: beschreibung'.
|
||||
Eindeutiger Präfix wird aufgelöst, Mehrdeutiges und Fehlendes bleibt unverändert."""
|
||||
|
||||
@@ -65,17 +65,17 @@ def _trainer(tmp_path, runner, f0, **kw):
|
||||
|
||||
|
||||
async def test_aco_konvergiert_auf_optimum(tmp_path):
|
||||
"""Gepflanztes Optimum (FACTS_CHUNK_SUBS=6) wird gefunden und bestätigt übernommen;
|
||||
"""Gepflanztes Optimum (GEN_PANEL=3) wird gefunden und bestätigt übernommen;
|
||||
die Pheromon-Spur konzentriert sich dort."""
|
||||
def bewertung(params):
|
||||
return _metrics(note=9.5, dauer=7.0) if params.get("FACTS_CHUNK_SUBS") == 6 else _metrics()
|
||||
return _metrics(note=9.5, dauer=7.0) if params.get("GEN_PANEL") == 3 else _metrics()
|
||||
|
||||
runner, f0 = _stub(bewertung)
|
||||
t = _trainer(tmp_path, runner, f0, max_trials=120)
|
||||
best = await t.run()
|
||||
assert best.get("FACTS_CHUNK_SUBS") == 6
|
||||
taus = t.pheromon["FACTS_CHUNK_SUBS"]
|
||||
assert max(taus, key=lambda k: taus[k]) == "6"
|
||||
assert best.get("GEN_PANEL") == 3
|
||||
taus = t.pheromon["GEN_PANEL"]
|
||||
assert max(taus, key=lambda k: taus[k]) == "3"
|
||||
|
||||
|
||||
async def test_uebernahme_braucht_bestaetigung(tmp_path):
|
||||
@@ -83,7 +83,7 @@ async def test_uebernahme_braucht_bestaetigung(tmp_path):
|
||||
zustand = {"mal": 0}
|
||||
|
||||
def bewertung(params):
|
||||
if params.get("FACTS_CHUNK_SUBS") == 6:
|
||||
if params.get("GEN_PANEL") == 3:
|
||||
zustand["mal"] += 1
|
||||
return _metrics(note=9.5) if zustand["mal"] == 1 else _metrics(note=8.0)
|
||||
return _metrics()
|
||||
@@ -91,7 +91,7 @@ async def test_uebernahme_braucht_bestaetigung(tmp_path):
|
||||
runner, f0 = _stub(bewertung)
|
||||
t = _trainer(tmp_path, runner, f0, max_trials=40)
|
||||
best = await t.run()
|
||||
assert best.get("FACTS_CHUNK_SUBS") != 6
|
||||
assert best.get("GEN_PANEL") != 3
|
||||
|
||||
|
||||
async def test_f0_filter_verwirft_kaputte_kandidaten(tmp_path):
|
||||
@@ -111,16 +111,16 @@ async def test_f0_filter_verwirft_kaputte_kandidaten(tmp_path):
|
||||
|
||||
async def test_resume_laedt_pheromon_und_cache(tmp_path):
|
||||
def bewertung(params):
|
||||
return _metrics(note=9.5) if params.get("FACTS_CHUNK_SUBS") == 6 else _metrics()
|
||||
return _metrics(note=9.5) if params.get("GEN_PANEL") == 3 else _metrics()
|
||||
|
||||
runner, f0 = _stub(bewertung)
|
||||
t = _trainer(tmp_path, runner, f0, max_trials=60)
|
||||
await t.run()
|
||||
best, tau = t.best_params, dict(t.pheromon["FACTS_CHUNK_SUBS"])
|
||||
best, tau = t.best_params, dict(t.pheromon["GEN_PANEL"])
|
||||
runner2, f02 = _stub(bewertung)
|
||||
t2 = _trainer(tmp_path, runner2, f02, max_trials=0) # kein Budget: alles aus Persistenz
|
||||
assert t2.best_params == best
|
||||
assert t2.pheromon["FACTS_CHUNK_SUBS"] == tau
|
||||
assert t2.pheromon["GEN_PANEL"] == tau
|
||||
|
||||
|
||||
async def test_budget_stoppt(tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user