diff --git a/backend/blocks.py b/backend/blocks.py index 96aa5a1..c6e82e8 100644 --- a/backend/blocks.py +++ b/backend/blocks.py @@ -656,7 +656,18 @@ def _finder_material(folder, sources: list[str] | None, queries: list[str]) -> t return "", "Backed, not invented. Verify uncertain points via web search.", ("files" if folder else "full") -def _build_research_prompt(topic: str, out_path: Path, instructions: str, type: str, folder: Path | None, fokus: str = "", section: str = "") -> str: +def material_folder(topic: str) -> Path | None: + """Korpus fürs Inline-Material: echte Quelle (uni/projekt/link) oder bei thema die + Fundstellen der Research-Reader (arbeit/material/*.txt). Nur für Evidence-Packs — + Konsens-Gate und QA messen unverändert gegen die konfigurierte Quelle (Messinvarianz).""" + f = source_folder(topic) + if f is not None: + return f + d = arbeit_dir(topic) / "material" + return d if d.is_dir() and any(d.glob("*.txt")) else None + + +def _build_research_prompt(topic: str, out_path: Path, instructions: str, type: str, folder: Path | None, fokus: str = "", section: str = "", material_path: Path | None = None) -> str: if section: # Section mode (uni/projekt): text directly in the prompt → small context, no file reading. source = section @@ -664,6 +675,13 @@ def _build_research_prompt(topic: str, out_path: Path, instructions: str, type: source = _prompt(_SOURCE_TEMPLATE[type], project=folder) else: source = _prompt("Blocks-Source-Thema", topic=topic) + if material_path is not None: + # thema: Fundstellen sichern — sie werden das Inline-Material der Folgeschritte + # (Finder/Facts liefen sonst mit eigener Websuche → Reasoning-Schleifen, Retries) + source += ("\n\nALSO write the file " + str(material_path) + " as you research: for every " + "source you use, append one line with the URL followed by the relevant excerpt " + "(plain text). Later steps back all facts ONLY against this material — " + "an excerpt you skip here cannot back anything later.") return _prompt( "Blocks-Research", topic=topic, source=source, blocks_path=out_path, focus=fokus, extra=_extra(instructions), @@ -790,6 +808,7 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled work_dir = files["arbeit"] folder = source_folder(topic) + mat = material_folder(topic) # thema: Research-Fundstellen als Inline-Korpus caps = "files" if folder else "full" # Source for the evidence exam in the clarify step (discards invented/unsupportable subs). _type = load_source(topic).get("type", "thema") @@ -896,7 +915,7 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i # Material once per package (excerpts are round-invariant; one query per block # keeps the coverage guarantee of _evidence_pack). material, backing, round_caps = await asyncio.to_thread( - _finder_material, folder, sources, [str(entries[num]) for num in chunk]) + _finder_material, mat, sources, [str(entries[num]) for num in chunk]) start = time.monotonic() round_n = 0 while not is_cancelled(): @@ -1031,9 +1050,9 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i if pending: # Inline evidence: corpus excerpts in the prompt (no self-research); the judge # answers as TEXT, the engine persists the j-file (resume + majority unchanged). - ev = _evidence_pack(folder, sources, + ev = _evidence_pack(mat, sources, [title_by_num[num] for num in chunk] - + [s for num in chunk for s in shown_by_num.get(num, [])]) if folder else "" + + [s for num in chunk for s in shown_by_num.get(num, [])]) if mat else "" j_source = _prompt("Blocks-Source-Inline", excerpts=ev) if ev else source def _sink(result, p): @@ -1147,7 +1166,7 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i "Subbausteine. Suche gezielt nach WEITEREN belegbaren Kernaspekten, die " "oben fehlen. Nimm NUR auf, was die Quellen wirklich hergeben — nicht aufblähen.") material, backing, round_caps = await asyncio.to_thread( - _finder_material, folder, sources, [str(entries[num]) for num in lacking]) + _finder_material, mat, sources, [str(entries[num]) for num in lacking]) paths = [work_dir / f"subblock-x{k}-c{c}-{i}.md" for i in (1, 2, 3)] keys = [f"blocks-{topic}-{ns}subblock-x{k}-c{c}-{i}" for i in (1, 2, 3)] new = await _one_round(f"{lbl}Subblocks catch-up {c} X{k}", lacking, assignment, paths, keys, known, focus, material, backing, round_caps) @@ -1494,7 +1513,7 @@ async def _luecken_runde(ctx: GenContext, files: dict, title: str, luecken: list known = ("\n\nBEREITS ERFASST — liste diese NICHT erneut:\n" + "\n".join(f"- {s}" for s in have)) if have else "" material, backing, caps = await asyncio.to_thread( - _finder_material, folder, sources, [title] + list(luecken)) + _finder_material, material_folder(topic), sources, [title] + list(luecken)) paths = [work_dir / f"luecken-{ns}r1-{i}.md" for i in (1, 2, 3)] slots = [{ "key": f"blocks-{topic}-{ns}luecken-r1-{i}", @@ -1901,7 +1920,8 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst Zeichen) und endeten mit leerem Turn — Retry-Wellen à 60–90 s seriell pro Block. No-Tool-Calls mit Inline-Material hatten 0 solcher Fälle (Muster: Facts-Check). Fail-open: ohne Treffer bleibt die alte Selbst-Recherche.""" - ev = _evidence_pack(folder, sources, queries) if folder else "" + mat = material_folder(topic) + ev = _evidence_pack(mat, sources, queries) if mat else "" if ev: return _prompt("Blocks-Source-Inline", excerpts=ev), "none" return source, caps @@ -1974,7 +1994,8 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst cites = [bf.get("source", "") for fm in per.values() for fk in fm.values() for bf in fk.get("cited_facts", [])] fallback = [blocks[i][0] for i in idxs] + [fk["sub"] for fm in per.values() for fk in fm.values()] - ev = _cited_evidence(folder, sources, cites, fallback) if folder else "" + mat = material_folder(topic) + ev = _cited_evidence(mat, sources, cites, fallback) if mat else "" c_source = _prompt("Blocks-Source-Inline", excerpts=ev) if ev else source pending = [j for j in panel if _facts_check_schema(_json_file(chk_path(ci, j))) is None] tmap = {asyncio.create_task( diff --git a/backend/board_inventory.py b/backend/board_inventory.py index a740c1b..3cef658 100644 --- a/backend/board_inventory.py +++ b/backend/board_inventory.py @@ -209,6 +209,10 @@ async def _research_once(ctx: GenContext, flow: Flow, q: dict, folder, instructi work_dir = flow.work_dir caps = "files" if folder else "full" p = work_dir / f"research-{tag}.md" + mp = None + if folder is None: # thema: Fundstellen als Material für die Inline-Folgeschritte sichern + (work_dir / "material").mkdir(parents=True, exist_ok=True) + mp = work_dir / "material" / f"research-{tag}.txt" stop = asyncio.Event() buf: list[str] = [] # assistant text streamed live from the JSON events @@ -237,7 +241,7 @@ async def _research_once(ctx: GenContext, flow: Flow, q: dict, folder, instructi await run_single_slot( ctx, f"Research {tag}", key=f"blocks-{ctx.topic}-research-{tag}", prompt=_build_research_prompt(ctx.topic, p, instructions, q["type"], folder, - fokus=fokus, section=section), + fokus=fokus, section=section, material_path=mp), role="quick", capabilities=caps, payload=(lambda result, p=p: _file_payload(p)), timeout=RESEARCH_RUNTIME, on_line=_on_line, diff --git a/backend/tests/test_subblocks.py b/backend/tests/test_subblocks.py index 84af8e5..94678df 100644 --- a/backend/tests/test_subblocks.py +++ b/backend/tests/test_subblocks.py @@ -372,6 +372,41 @@ async def test_clarify_inline_evidence_no_tools(sub_env, monkeypatch, tmp_path): 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") + assert blx.material_folder("t") == tmp_path / "quelle" + monkeypatch.setattr(blx, "source_folder", lambda t: None) + monkeypatch.setattr(blx, "arbeit_dir", lambda t: tmp_path / "arbeit") + assert blx.material_folder("t") is None # kein Material-Ordner + md = tmp_path / "arbeit" / "material" + md.mkdir(parents=True) + assert blx.material_folder("t") is None # leer zählt nicht + (md / "research-1.txt").write_text("x", encoding="utf-8") + 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).""" @@ -418,6 +453,7 @@ async def test_facts_check_inline_cited_evidence(sub_env, monkeypatch, tmp_path) 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"}], @@ -455,6 +491,7 @@ async def test_facts_find_inline_evidence_no_tools(sub_env, monkeypatch, tmp_pat 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": "",