"""Guide generation as a consensus pipeline. Outline: select the blocks (deterministic per format) → 3 proposals (Grace) that order the blocks into chapters by NUMBER → a judge merges the proposals into one coherent order. Writing: one writer per block. Reading exam: Check→Fix (one round), follow-up rounds check only replaced sections; remaining complaints then stand. Step files are kept → an abort preserves progress, ▶ resumes at the open step. """ import asyncio import json import logging import math from datetime import datetime, timezone from pathlib import Path import uuid from agents import run_agent from blocks import _convert_pdfs, source_folder from config import ( DEFAULT_PROVIDER, FORMAT_PURPOSE, CONSENSUS_GRACE, READABILITY_ACTIVE, TEMPLATES_DIR, ) import readability from database import list_guides, update_guide, list_blocks, list_subblocks, set_guide_content, get_guide_content, get_outline from fsutil import atomic_write_json, atomic_write_text from jsonio import read_json_file as _json_file, parse_json_text as _parse_json_text from paths import blocks_path, guide_content_path, project_dir, subblocks_path from pipeline import ( CANCELLED, FAILED, GenContext, _claude_error, _extra, _fail, _gather_error, _gather_progress, _log, _prompt, _race, _semaphore, _set_progress, _set_step, _timeout, clear_guide_cancelled, is_guide_cancelled, run_single_slot, ) from textkit import ( _unique_title, _load_blocks, _norm_title, _parse_fragment, _split_chunks, _title, _resolve_title, _title_index, ) log = logging.getLogger("creator.guide") GUIDE_STEPS = ("Outline", "Content", "Content-Check", "Writing", "Reading-Exam") # Content/Content-Check/Reading-Exam run in packets of ~GUIDE_CHUNK blocks per agent. # Only the writer (Writing) stays at 1 agent per block (variable lengths, no trimming, no # length alignment between blocks). GUIDE_CHUNK = 10 # Check steps as a panel: CHECK_PANEL judges per chunk, section flagged on a majority. # A single judge is bias/sampling prone; a small panel is more stable. CHECK_PANEL = 3 # Reading exam: only ONE round (Check + Fix). Follow-up rounds added little value # (1 agent per block checks finely anyway) but cost extra agents. READING_ROUNDS = 1 # Valid level values: new (learning path) + old (difficulty) backward-compatible. _LEVELS_OK = ("beginner", "advanced", "expert", "easy", "medium", "hard") async def _load_subblocks(topic: str) -> dict[str, list[dict]]: """Subblocks per block — DB-first ({title, level, relevance}), fallback sidecar file. Both missing → {} (guide takes everything).""" out: dict[str, list[dict]] = {} for r in await list_subblocks(topic): if r["status"] == "consensus" and r["sub_title"] and r["level"] in _LEVELS_OK: try: facts = json.loads(r["facts"]) if r.get("facts") else {} except (ValueError, TypeError): facts = {} out.setdefault(r["block"], []).append( {"title": r["sub_title"], "level": r["level"], "relevance": r["relevance"], "facts": facts}) if out: return out data = _json_file(subblocks_path(topic)) if not isinstance(data, dict): return {} for title, subs in data.items(): if not isinstance(subs, list): continue good = [s for s in subs if isinstance(s, dict) and str(s.get("title", "")).strip() and s.get("level") in _LEVELS_OK] if good: out[title] = good return out def _level_label(s: dict) -> str: """View level of a subblock: peripheral → 'peripheral' (level 4), otherwise the level (1–3).""" return "peripheral" if s.get("relevance") == "peripheral" else (s.get("level") or "beginner") def _assignment_subs(chunk: list[dict], entries: dict[int, str], subs_by_title: dict[str, list[dict]]) -> str: """Lists the blocks per chapter, with their subblocks and level labels beneath.""" lines: list[str] = [] for ch in chunk: lines.append(f"CHAPTER: {ch['title']}") for num in ch["nums"]: lines.append(f"- {entries[num]}") for s in subs_by_title.get(_title(entries[num]), []): lines.append(f" [{_level_label(s)}] {s['title']}") return "\n".join(lines) def _guide_files(content_path: Path) -> dict: d, stem = content_path.parent, content_path.stem return { "outline_slots": [d / f"{stem}.outline-{i}.json" for i in (1, 2, 3)], "outline": d / f"{stem}.outline.json", # judge output # chunk/reading-check/fix files are dynamic: # {stem}.chunk-i.md, {stem}.lese-check-r{n}-{i}.json, {stem}.fix-r{n}-{i}.md } def guide_slot_files(content_path: Path) -> list[Path]: """All step files of a guide (for a fresh start).""" return [p for p in content_path.parent.glob(f"{content_path.stem}.*") if p != content_path] def _done_path(content_path: Path) -> Path: return content_path.parent / f"{content_path.stem}.done" def guide_done_step(content_path: Path) -> int: """Highest FULLY completed step index (marker per topic+format). -1 = none. If the content file exists, all steps are done.""" if content_path.exists(): return len(GUIDE_STEPS) - 1 try: return int(_done_path(content_path).read_text(encoding="utf-8").strip()) except (OSError, ValueError): return -1 def _set_done(content_path: Path, step: int) -> None: """Set marker to `step` — monotone (only increase), except on the re-run reset (force).""" if step > guide_done_step(content_path): atomic_write_text(_done_path(content_path), str(step)) def _reset_done(content_path: Path, step: int) -> None: """Set marker hard to `step` (for re-run from step; step may decrease).""" if step < 0: _done_path(content_path).unlink(missing_ok=True) else: atomic_write_text(_done_path(content_path), str(step)) # Slot-file globs per step (index = GUIDE_STEPS). Stem-anchored, collision-free. _STEP_GLOBS = ( ("outline*",), # 0 Outline (incl. selection filter) ("content-chunk-*", "content-nach-*"), # 1 Content (incl. follow-up round) ("content-check-*", "content-fix-*"), # 2 Content-Check ("chunk-*",), # 3 Writing (chunk-* also matches chunk-nach-*) ("lese-check-*", "fix-r*"), # 4 Reading-Exam ) def _reset_guide_from_step(content_path: Path, step: int) -> None: """Re-run from step: delete content + all slot files of steps ≥ step. Earlier steps stay → the resume rebuilds from `step` (everything below is reused).""" content_path.unlink(missing_ok=True) # no longer "done" → no fresh-start wipe d, stem = content_path.parent, content_path.stem for globs in _STEP_GLOBS[step:]: for pat in globs: for p in d.glob(f"{stem}.{pat}"): p.unlink(missing_ok=True) _reset_done(content_path, step - 1) # steps < step count as done def _read_problems_schema(data): """{"ok": true} → [] · {"problems": [{"section", "problem"}]} → list · else None.""" if not isinstance(data, dict): return None if data.get("ok") is True: return [] p = data.get("problems") if not isinstance(p, list) or not p: return None out = [] for x in p: if not isinstance(x, dict) or not isinstance(x.get("section"), str) or not isinstance(x.get("problem"), str): return None out.append({"section": x["section"].strip(), "problem": x["problem"].strip()}) return out or None def _panel_problems(judge_paths: list[Path], valid: set[int], idx: dict[str, int]) -> dict[int, str]: """Panel aggregation: several judge outputs of a chunk → flagged {num: problem}. One vote per judge that names a section. Flagged when more than half of the DELIVERED (validly parsed) judges name it (3→≥2, 2→≥2, 1→≥1). Robust against a single failure: missing files do not count. Problem text from the first naming judge. """ outputs = [p for p in (_read_problems_schema(_json_file(j)) for j in judge_paths) if p is not None] if not outputs: return {} votes: dict[int, int] = {} problem: dict[int, str] = {} for out in outputs: seen: set[int] = set() for item in out: num = _resolve_title(idx, item["section"]) if num is None or num not in valid or num in seen: continue seen.add(num) votes[num] = votes.get(num, 0) + 1 problem.setdefault(num, item["problem"]) threshold = len(outputs) / 2 return {num: problem[num] for num, v in votes.items() if v > threshold} def _resolve_outline(data, entries: dict[int, str], target_min: int, target_max: int) -> list[dict] | None: """{"chapters": [{"title", "numbers": [1, 3, 7]}]} → [{"title", "nums"}]. Numbers are the IDs from `entries` (1-based, as presented to the agent). `target_min`/`target_max` = allowed range of selected blocks (with a small tolerance). """ if not isinstance(data, dict) or not isinstance(data.get("chapters"), list): return None valid = set(entries) chapters: list[dict] = [] seen: set[int] = set() total = unknown = 0 for ch in data["chapters"]: if not isinstance(ch, dict) or not isinstance(ch.get("numbers"), list): return None nums = [] for t in ch["numbers"]: total += 1 num = t if isinstance(t, int) and not isinstance(t, bool) else None if num is None or num not in valid: unknown += 1 elif num not in seen: nums.append(num) seen.add(num) if nums: chapters.append({"title": str(ch.get("title", "")).strip() or "Chapter", "nums": nums}) if not chapters or total == 0: return None if (total - unknown) / total < 0.85: return None if len(seen) < 0.9 * target_min or len(seen) > 1.1 * target_max: return None return chapters def _fallback_outline(entries: dict[int, str]) -> list[dict]: """Deterministic outline when the agents deliver none: one chapter with all selected blocks in order. Guarantees full coverage.""" return [{"title": "Contents", "nums": list(entries)}] def _with_remainder(plan: list[dict], entries: dict[int, str]) -> list[dict]: """Ensures that EVERY selected block is in the plan — missing ones land in an "Additional" chapter (against agents/judges that drop blocks).""" present = {num for ch in plan for num in ch.get("nums", [])} missing = [num for num in entries if num not in present] return [*plan, {"title": "Additional", "nums": missing}] if missing else plan def _facts_grounding(subs_raw: dict[str, list[dict]]) -> str: """Verified sub-facts (extract-once from the blocks phase) as a grounding block for the content agent. Empty if no facts are stored (legacy data → fallback to a source hint).""" blocks = [] for title, subs in subs_raw.items(): lines = [] for s in subs: fk = s.get("facts") if isinstance(s.get("facts"), dict) else None if not fk: continue parts = [] if fk.get("key_points"): parts.append("Core: " + " · ".join(fk["key_points"])) for bf in fk.get("cited_facts", []): parts.append(f"FACT[{bf.get('source', '?')}]: {bf.get('text', '')}") if fk.get("prerequisites"): parts.append("Prerequisite: " + fk["prerequisites"]) if fk.get("hurdles"): parts.append("Hurdle: " + fk["hurdles"]) if fk.get("example_idea"): parts.append("Example: " + fk["example_idea"]) if parts: lines.append(f"- {s['title']}: " + " | ".join(parts)) if lines: blocks.append(f"BLOCK: {title}\n" + "\n".join(lines)) if not blocks: return "" return ("VERIFIED FACTS per subblock — binding basis. Quote cited facts (FACT[Source]) " "VERBATIM, invent nothing extra, do NOT re-research. Use examples as examples, " "never as fact.\n\n" + "\n\n".join(blocks)) async def _outline_from_db(topic: str, sel_entries: dict[int, str]) -> list[dict] | None: """Read the outline from the blocks artifact (DB) and map it onto the selected blocks. Title-based (robust against number drift): blocks outside the selection are ignored (format filter), missing ones are added later by _with_remainder. None → no artifact (legacy).""" raw = await get_outline(topic) if not raw: return None try: data = json.loads(raw) except (ValueError, TypeError): return None chapters = data.get("chapters") if isinstance(data, dict) else None if not isinstance(chapters, list): return None norm_to_num = {_norm_title(_title(t)): num for num, t in sel_entries.items()} plan, seen = [], set() for ch in chapters: if not isinstance(ch, dict): continue nums = [] for bt in ch.get("blocks", []): num = norm_to_num.get(_norm_title(str(bt))) if num is not None and num not in seen: seen.add(num) nums.append(num) if nums: plan.append({"title": str(ch.get("title", "")).strip() or "Chapter", "nums": nums}) return plan or None async def _generate_sections( guide_id: str, topic: str, format_name: str, entries: dict[int, str], facts: str, instructions: str, provider: str, content_path: Path, ) -> list[dict] | None: def is_cancelled() -> bool: return is_guide_cancelled(guide_id) ctx = GenContext(topic=topic, provider=provider, is_cancelled=is_cancelled, guide_id=guide_id) spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8") files = _guide_files(content_path) zweck = FORMAT_PURPOSE[format_name] # Subblocks per block (DB-first) — loaded early: drives selection + sub-filter per format. # Missing → {} (fallback: guide takes everything). subs_raw = await _load_subblocks(topic) # Extract-once grounding: stored, verified facts replace the generic source hint. # The content agent phrases from them instead of reading the source again. if (facts_block := _facts_grounding(subs_raw)): facts = facts_block def _has_relevance(num, kind): return any(isinstance(s, dict) and s.get("relevance") == kind for s in subs_raw.get(_title(entries[num]), [])) # Selection: ONE full document with ALL blocks (incl. peripheral). The views E/M/S/F # filter later per subblock level. (FullGuide/Rest remain as legacy branches.) if format_name == "Rest": selection = [num for num in entries if not _has_relevance(num, "relevant")] else: # Guide / FullGuide → all blocks selection = list(entries) if not selection: await _fail(guide_id, "No matching blocks for this format") return None sel_entries = {num: entries[num] for num in selection} target = len(sel_entries) # Numbered list (ID = block number from entries) — agents/judge order by number. sel_list = "\n".join(f"{num}. {t}" for num, t in sel_entries.items()) # Step 0: outline. Prefers the blocks artifact (DB) — the guide only presents, # no longer structures itself. Missing (legacy) → previous agents/judge logic as fallback. # 0 valid → code fallback, 1 → direct, ≥2 → judge (with proposal as fallback). plan = await _outline_from_db(topic, sel_entries) if plan is not None: _log(topic, f"Outline from blocks artifact ({len(plan)} chapters)") if plan is None: plan = _resolve_outline(_json_file(files["outline"]), sel_entries, target, target) if plan is None: await _set_step(guide_id, 0, "Outline proposals (3 agents)…") files["outline"].unlink(missing_ok=True) proposals: list[list[dict]] = [] pending = [] for i, path in enumerate(files["outline_slots"], 1): res = _resolve_outline(_json_file(path), sel_entries, target, target) if res is not None: proposals.append(res) else: pending.append((i, path)) if len(proposals) < 3 and pending: slots = [ { "key": f"{guide_id}-outline-{i}", "prompt": _prompt( "Guide-Outline", topic=topic, format_name=format_name, blocks=sel_list, out_path=path, extra=_extra(instructions), ), "role": "guide", "capabilities": "files", "payload": (lambda result, p=path: _resolve_outline(_json_file(p), sel_entries, target, target)), } for i, path in pending ] # Quorum 1: take whatever comes — no minimum requirement, no abort. new = await _race( topic, "Outline", slots, 1, _timeout("plan", target), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE, ) if is_cancelled(): return None proposals += new or [] if not proposals: _log(topic, "Outline: no valid proposal — deterministic fallback") plan = _fallback_outline(sel_entries) elif len(proposals) == 1: plan = proposals[0] # one proposal → no judge needed else: await _set_step(guide_id, 0, "Merging outlines…") proposals_text = "\n\n".join( f"### Proposal {i}\n" + "\n".join(f"CHAPTER: {ch['title']}\n Numbers: {', '.join(str(num) for num in ch['nums'])}" for ch in v) for i, v in enumerate(proposals, 1) ) status, plan = await run_single_slot( ctx, "Outline-Judge", key=f"{guide_id}-outline-judge", prompt=_prompt( "Guide-Outline-Judge", topic=topic, format_name=format_name, purpose=zweck, n=len(proposals), blocks=sel_list, outlines=proposals_text, out_path=files["outline"], extra=_extra(instructions), ), role="judge", capabilities="files", payload=lambda result: _resolve_outline(_json_file(files["outline"]), sel_entries, target, target), timeout=_timeout("plan_judge", target), ) if status == CANCELLED: return None if status == FAILED or plan is None: _log(topic, "Outline judge produced no result — best proposal kept") plan = proposals[0] # Guarantee: every selected block is in the plan (against dropping agents/judges). plan = _with_remainder(plan, sel_entries) _set_done(content_path, 0) # outline ready # Coarse chunks (~GUIDE_CHUNK blocks per agent) for content, content-check and reading-exam. # The writer builds per block beneath (its own fine chunks, see below) → variable lengths. total_sections = sum(len(c["nums"]) for c in plan) chunks = _split_chunks(plan, max(1, math.ceil(total_sections / GUIDE_CHUNK))) # Subblocks per block: Guide/FullGuide take ALL (incl. peripheral → level 4 in the view); # only the legacy Rest branch filters to peripheral. So the one document carries all levels. if format_name == "Rest": subs_by_title = {t: [s for s in subs if s.get("relevance") == "peripheral"] for t, subs in subs_raw.items()} else: # Guide / FullGuide subs_by_title = {t: list(subs) for t, subs in subs_raw.items()} subs_by_title = {t: subs for t, subs in subs_by_title.items() if subs} assignments = [_assignment_subs(chunk, entries, subs_by_title) for chunk in chunks] chunk_sizes = [sum(len(c["nums"]) for c in chunk) for chunk in chunks] writer_count = len(chunks) idx = _title_index(entries) # Step 2: identify content per block — one agent per chunk (marker output, resume). content_paths = [content_path.parent / f"{content_path.stem}.content-chunk-{i}.md" for i in range(1, writer_count + 1)] pending = [i for i, p in enumerate(content_paths) if not p.exists()] if pending: async def report(d, t): await _set_step(guide_id, 1, f"Gathering content {d}/{t}…") results = await _gather_progress([ run_agent( f"{guide_id}-content-{i + 1}", _prompt( "Guide-Content", topic=topic, assignment=assignments[i], facts=facts, out_path=content_paths[i], extra=_extra(instructions), ), _timeout("content", chunk_sizes[i]), provider=provider, role="guide", capabilities="full", scope=topic, ) for i in pending ], writer_count, report, start=writer_count - len(pending)) if is_cancelled(): return None if not any(p.exists() for p in content_paths): await _fail(guide_id, _gather_error("Content error", list(results))) return None content_by_num: dict[int, str] = {} for p in content_paths: if not p.exists(): continue for sec in _parse_fragment(p.read_text(encoding="utf-8")): num = _resolve_title(idx, sec["title"]) if num is not None and num not in content_by_num and sec["md"].strip(): content_by_num[num] = sec["md"] if not content_by_num: await _fail(guide_id, "No content identified") return None # Follow-up round: pull missing blocks (chunk failure or lazy output) deliberately — one round. planned_nums = [num for ch in plan for num in ch["nums"]] missing = [num for num in planned_nums if num not in content_by_num] if missing: _log(topic, f"Content: {len(missing)} block(s) missing — follow-up round…") followup_chunks = [[{"title": "Additional", "nums": missing[k:k + GUIDE_CHUNK]}] for k in range(0, len(missing), GUIDE_CHUNK)] followup_paths = [content_path.parent / f"{content_path.stem}.content-nach-{k}.md" for k in range(1, len(followup_chunks) + 1)] followup_pending = [k for k, p in enumerate(followup_paths) if not p.exists()] if followup_pending: async def report_n(d, t): await _set_step(guide_id, 1, f"Gathering missing content {d}/{t}…") await _gather_progress([ run_agent( f"{guide_id}-content-nach-{k + 1}", _prompt( "Guide-Content", topic=topic, assignment=_assignment_subs(followup_chunks[k], entries, subs_by_title), facts=facts, out_path=followup_paths[k], extra=_extra(instructions), ), _timeout("content", len(followup_chunks[k][0]["nums"])), provider=provider, role="guide", capabilities="full", scope=topic, ) for k in followup_pending ], len(followup_chunks), report_n, start=len(followup_chunks) - len(followup_pending)) if is_cancelled(): return None for p in followup_paths: if not p.exists(): continue for sec in _parse_fragment(p.read_text(encoding="utf-8")): num = _resolve_title(idx, sec["title"]) if num is not None and num not in content_by_num and sec["md"].strip(): content_by_num[num] = sec["md"] if all(p.exists() for p in content_paths): _set_done(content_path, 1) # content complete content_chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in content_by_num] for chunk in chunks] # Step 3: check content — CHECK_PANEL judges per chunk, majority flags. # + revise flagged ones once. Resume: only restart missing judge files. check_judge_paths = [ [content_path.parent / f"{content_path.stem}.content-check-{i}-j{j}.json" for j in range(1, CHECK_PANEL + 1)] for i in range(1, writer_count + 1) ] pending_slots = [ (i, j) for i in range(writer_count) if content_chunk_nums[i] for j in range(CHECK_PANEL) if _read_problems_schema(_json_file(check_judge_paths[i][j])) is None ] if pending_slots: await _set_step(guide_id, 2, "Checking content…") sections_per_chunk = { i: "\n\n".join(f"SECTION: {_title(entries[num])}\n{content_by_num[num]}" for num in content_chunk_nums[i]) for i, _ in pending_slots } slots = [{ "key": f"{guide_id}-content-check-{i + 1}-j{j + 1}", "prompt": _prompt( "Guide-Content-Check", topic=topic, format_name=format_name, sections=sections_per_chunk[i], out_path=check_judge_paths[i][j], extra=_extra(instructions), ), "role": "judge", "capabilities": "files", "payload": (lambda result, p=check_judge_paths[i][j]: _read_problems_schema(_json_file(p))), } for i, j in pending_slots] n_checks = len(slots) upd = lambda n: asyncio.create_task(_set_step(guide_id, 2, f"Checking content {n}/{n_checks}…")) await _race(topic, "Content-Exam", slots, len(slots), _timeout("content_check", max(chunk_sizes)), provider, on_update=upd, cancelled=is_cancelled, grace=CONSENSUS_GRACE) if is_cancelled(): return None problems_by_num: dict[int, str] = {} for i in range(writer_count): if content_chunk_nums[i]: problems_by_num.update(_panel_problems(check_judge_paths[i], set(content_chunk_nums[i]), idx)) if problems_by_num: _log(topic, f"Content exam: {len(problems_by_num)} block(s) flagged") await _set_step(guide_id, 2, f"Revising {len(problems_by_num)} content(s)…") fix_chunks = [[num for num in nums if num in problems_by_num] for nums in content_chunk_nums] fix_paths = [content_path.parent / f"{content_path.stem}.content-fix-{i + 1}.md" for i in range(writer_count)] fix_pending = [i for i, nums in enumerate(fix_chunks) if nums and not fix_paths[i].exists()] results = await asyncio.gather(*[ run_agent( f"{guide_id}-content-fix-{i + 1}", _prompt( "Guide-Content-Fix", topic=topic, facts=facts, tasks="\n\n".join( f"SECTION: {_title(entries[num])}\nPROBLEM: {problems_by_num[num]}\nCURRENT:\n{content_by_num[num]}" for num in fix_chunks[i] ), out_path=fix_paths[i], extra=_extra(instructions), ), _timeout("content", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full", scope=topic, ) for i in fix_pending ], return_exceptions=True) if is_cancelled(): return None for p in fix_paths: if not p.exists(): continue for sec in _parse_fragment(p.read_text(encoding="utf-8")): num = _resolve_title(idx, sec["title"]) if num in problems_by_num and sec["md"].strip(): content_by_num[num] = sec["md"] _set_done(content_path, 2) # content check done # Step 4: writing — the writer phrases out the checked content (resume). # FINE chunks: exactly 1 block per writer → variable lengths, no budget rationing. def content_text(chunk) -> str: nums = [num for ch in chunk for num in ch["nums"] if num in content_by_num] return "\n\n".join(f"\n{content_by_num[num]}" for num in nums) w_chunks = [[{"title": ch["title"], "nums": [num]}] for ch in plan for num in ch["nums"]] w_assignments = [_assignment_subs(c, entries, subs_by_title) for c in w_chunks] paths = [content_path.parent / f"{content_path.stem}.chunk-{i}.md" for i in range(1, len(w_chunks) + 1)] pending = [i for i, p in enumerate(paths) if not p.exists()] if pending: async def report(d, t): await _set_step(guide_id, 3, f"Writing sections {d}/{t}…") results = await _gather_progress([ run_agent( f"{guide_id}-w{i + 1}", _prompt( "Guide-Writer", topic=topic, format_name=format_name, assignment=w_assignments[i], contents=content_text(w_chunks[i]), spec=spec, out_path=paths[i], extra=_extra(instructions), ), _timeout("writer", 1), provider=provider, role="guide", capabilities="files", scope=topic, ) for i in pending ], len(w_chunks), report, start=len(w_chunks) - len(pending)) if is_cancelled(): return None for i, r in zip(pending, results): if isinstance(r, BaseException): _log(topic, f"Writer {i + 1}: {type(r).__name__}: {r}") elif r[0] != 0: _log(topic, f"Writer {i + 1}: {_claude_error('Error', *r)}") elif not paths[i].exists(): _log(topic, f"Writer {i + 1}: no output file created") if not any(p.exists() for p in paths): await _fail(guide_id, _gather_error("Writer error", list(results))) return None by_num: dict[int, dict] = {} for p in paths: if not p.exists(): continue for sec in _parse_fragment(p.read_text(encoding="utf-8")): num = _resolve_title(idx, sec["title"]) if num is None: _log(topic, f"Writer produced unknown section '{sec['title'][:40]}' (ignored)") elif num not in by_num: by_num[num] = sec if not by_num: await _fail(guide_id, "No sections found in writer output") return None # Follow-up round: write missing sections (writer failure) deliberately — one round. missing_after = [num for num in planned_nums if num not in by_num] if missing_after: _log(topic, f"Writing: {len(missing_after)} section(s) missing — follow-up round…") nw_chunks = [[{"title": "Additional", "nums": [num]}] for num in missing_after] nw_paths = [content_path.parent / f"{content_path.stem}.chunk-nach-{k}.md" for k in range(1, len(nw_chunks) + 1)] nw_pending = [k for k, p in enumerate(nw_paths) if not p.exists()] if nw_pending: async def report_nw(d, t): await _set_step(guide_id, 3, f"Writing missing sections {d}/{t}…") await _gather_progress([ run_agent( f"{guide_id}-w-nach-{k + 1}", _prompt( "Guide-Writer", topic=topic, format_name=format_name, assignment=_assignment_subs(nw_chunks[k], entries, subs_by_title), contents=content_text(nw_chunks[k]), spec=spec, out_path=nw_paths[k], extra=_extra(instructions), ), _timeout("writer", 1), provider=provider, role="guide", capabilities="files", scope=topic, ) for k in nw_pending ], len(nw_chunks), report_nw, start=len(nw_chunks) - len(nw_pending)) if is_cancelled(): return None for p in nw_paths: if not p.exists(): continue for sec in _parse_fragment(p.read_text(encoding="utf-8")): num = _resolve_title(idx, sec["title"]) if num is not None and num not in by_num and sec["md"].strip(): by_num[num] = sec if all(p.exists() for p in paths): _set_done(content_path, 3) # writing complete # Step 3: reading-exam loop — check per writer packet, fix only for # flagged sections; follow-up rounds check ONLY the replaced sections. # After the round cap, open complaints stand. chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in by_num] for chunk in chunks] def sections_text(nums: list[int]) -> str: return "\n\n".join(f"SECTION: {_title(entries[num])}\n{by_num[num]['md']}" for num in nums) def _sub_list(num: int) -> str: subs = subs_by_title.get(_title(entries[num]), []) return "\n".join(f"- [{_level_label(s)}] {s['title']}" for s in subs) or "(none)" def tasks_text(nums: list[int], problems: dict[int, str]) -> str: return "\n\n".join( f"SECTION: {_title(entries[num])}\n" f"SUBBLOCKS (set one `` marker each, label/order as here):\n{_sub_list(num)}\n" f"PROBLEM: {problems[num]}\nCURRENT CONTENT:\n{by_num[num]['md']}" for num in nums ) scope = chunk_nums for round_no in range(1, READING_ROUNDS + 1): # CHECK_PANEL judges per packet; majority flags. Aggregation robust against a single failure. check_judge_paths = [ [content_path.parent / f"{content_path.stem}.lese-check-r{round_no}-{i}-j{j}.json" for j in range(1, CHECK_PANEL + 1)] for i in range(1, writer_count + 1) ] pending_slots = [ (i, j) for i in range(writer_count) if scope[i] for j in range(CHECK_PANEL) if _read_problems_schema(_json_file(check_judge_paths[i][j])) is None ] if pending_slots: await _set_step(guide_id, 4, "Checking readability…") sections_per_chunk = {i: sections_text(scope[i]) for i, _ in pending_slots} slots = [{ "key": f"{guide_id}-lese-check-r{round_no}-{i + 1}-j{j + 1}", "prompt": _prompt( "Guide-Lese-Check", topic=topic, format_name=format_name, spec=spec, sections=sections_per_chunk[i], out_path=check_judge_paths[i][j], extra=_extra(instructions), ), "role": "judge", "capabilities": "files", "payload": (lambda result, p=check_judge_paths[i][j]: _read_problems_schema(_json_file(p))), } for i, j in pending_slots] n_checks = len(slots) upd = lambda n: asyncio.create_task(_set_step(guide_id, 4, f"Checking readability {n}/{n_checks}…")) res = await _race(topic, f"Reading-Exam r{round_no}", slots, len(slots), _timeout("lese_check", max(chunk_sizes)), provider, on_update=upd, cancelled=is_cancelled, grace=CONSENSUS_GRACE) if is_cancelled(): return None if res is None: _log(topic, f"Reading exam round {round_no}: no full quorum — aggregated available judges") problems_by_num: dict[int, str] = {} for i in range(writer_count): if scope[i]: problems_by_num.update(_panel_problems(check_judge_paths[i], set(scope[i]), idx)) # Deterministic readability gate: queue too-hard sections into the same # revision (LLM complaint takes precedence). Gate off → no-op. if READABILITY_ACTIVE: md_by_num = {num: by_num[num]["md"] for nums in scope for num in nums if num in by_num} hints = await asyncio.to_thread(readability.rate_sections, md_by_num) if hints: _log(topic, f"Readability: {len(hints)} section(s) too hard") for num, hint in hints.items(): problems_by_num.setdefault(num, hint) if not problems_by_num: break _log(topic, f"Reading exam round {round_no}: {len(problems_by_num)} section(s) flagged") await _set_step(guide_id, 4, f"Revising {len(problems_by_num)} section(s) (round {round_no})…") fix_chunks = [[num for num in nums if num in problems_by_num] for nums in chunk_nums] fix_paths = [content_path.parent / f"{content_path.stem}.fix-r{round_no}-{i + 1}.md" for i in range(writer_count)] fix_pending = [i for i, nums in enumerate(fix_chunks) if nums and not fix_paths[i].exists()] results = await asyncio.gather(*[ run_agent( f"{guide_id}-fix-r{round_no}-w{i + 1}", _prompt( "Guide-Sections-Fix", topic=topic, format_name=format_name, facts=facts, spec=spec, tasks=tasks_text(fix_chunks[i], problems_by_num), out_path=fix_paths[i], extra=_extra(instructions), ), _timeout("writer", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full", scope=topic, ) for i in fix_pending ], return_exceptions=True) if is_cancelled(): return None for i, r in zip(fix_pending, results): if isinstance(r, BaseException) or (not isinstance(r, BaseException) and r[0] != 0): _log(topic, f"Sections fix {i + 1} (round {round_no}) failed — original kept") replaced: set[int] = set() for p in fix_paths: if not p.exists(): continue for sec in _parse_fragment(p.read_text(encoding="utf-8")): num = _resolve_title(idx, sec["title"]) if num not in problems_by_num or not sec["md"].strip(): continue # Marker invariant: if the fix loses the sub markers although the original had # some, it is discarded — otherwise the level filter (E/M/S/F) dies silently. if by_num[num].get("subs") and not sec.get("subs"): _log(topic, f"Reading fix for '{sec['title']}' without sub markers — discarded, tagged original kept") continue by_num[num] = sec replaced.add(num) _log(topic, f"Reading exam round {round_no}: {len(replaced)} section(s) revised") if not replaced: break if round_no == READING_ROUNDS: _log(topic, f"Reading exam: 1 round — revision stays unchecked") break scope = [[num for num in nums if num in replaced] for nums in chunk_nums] _set_done(content_path, 4) # reading exam done # Checkable = format has an exam AND the block has ≥1 relevant subblock. # Guide is always checkable (even without relevance data, fallback = everything). def _checkable(num): if format_name == "Guide": return True if format_name == "FullGuide": return any(isinstance(s, dict) and s.get("relevance") == "relevant" for s in subs_raw.get(_title(entries[num]), [])) return False # Rest etc. → pure reading sections await _set_progress(guide_id, "Assembling…") chapters: list[dict] = [] for ch in plan: sections = [ {"num": num, "title": _title(entries[num]), "md": by_num[num]["md"], "compact": by_num[num].get("compact", ""), "anchor": by_num[num].get("anchor", ""), "anker_compact": by_num[num].get("anker_compact", ""), "subs": by_num[num].get("subs", []), "checkable": _checkable(num)} for num in ch["nums"] if num in by_num ] if sections: chapters.append({"title": ch["title"], "sections": sections}) planned = {num for ch in plan for num in ch["nums"]} missing = sorted(planned - set(by_num)) if missing: _log(topic, f"Sections missing from writer output: {[_title(entries[n]) for n in missing]}") if not chapters: await _fail(guide_id, "No sections found in writer output") return None return chapters _LEVEL_RANK = {"beginner": 1, "advanced": 2, "expert": 3, "peripheral": 4, "easy": 1, "medium": 2, "hard": 3} # old values backward-compatible def _section_for_level(sec: dict, level: int) -> dict: """Reconstruct md/compact of a section from subblocks up to the level (anchor stays).""" subs = sec.get("subs") or [] if not subs: return sec # no sub tags (legacy) → unchanged, visible visible = [s for s in subs if _LEVEL_RANK.get(s.get("level"), 1) <= level] md = "\n\n".join(t for t in [sec.get("anchor", ""), *(s.get("md", "") for s in visible)] if t).strip() compact = "\n".join(t for t in [sec.get("anker_compact", ""), *(s.get("compact", "") for s in visible)] if t).strip() return {**sec, "md": md, "compact": compact, "leer": not visible} def content_fuer_level(content: dict, level: int) -> dict: """Filter guide content to a view level (1=A · 2=F · 3=E · 4=V). Level 4 = full version. Sections without visible subs are hidden, empty chapters drop out.""" if not isinstance(content, dict) or level >= 4: return content chapters = [] for ch in content.get("chapters", []): secs = [s for s in (_section_for_level(x, level) for x in ch.get("sections", [])) if not s.get("leer")] if secs: chapters.append({**ch, "sections": secs}) return {**content, "chapters": chapters} async def reconcile_guides() -> None: """Reconcile DB↔filesystem: status=done without content file → error. Runs at server start (after init_db) — catches crashes between file write and status update. """ for g in await list_guides(): if g["status"] == "done" and not guide_content_path(g["topic"], g["format"]).exists(): log.warning("[%s] Guide %s: done without content file — set to error", g["topic"], g["id"]) now = datetime.now(timezone.utc).isoformat() await update_guide(g["id"], status="error", error_msg="Content missing — regenerate", updated_at=now) async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, ab_step: int | None = None) -> None: async with _semaphore: now = datetime.now(timezone.utc).isoformat() await update_guide(guide_id, status="generating", progress="Starting…", updated_at=now) content_path = guide_content_path(topic, format_name) content_path.parent.mkdir(parents=True, exist_ok=True) project = source_folder(topic) # folder source (project/uni/link) → path, else None try: if is_guide_cancelled(guide_id): return if project: await asyncio.to_thread(_convert_pdfs, project) # Re-run from step: delete content + slots from `ab_step`, rest stays → resume rebuilds from there. # Otherwise "recreate": a finished guide → complete fresh start. # Otherwise step files are leftovers of an abort/error → resume. if ab_step is not None: _reset_guide_from_step(content_path, ab_step) elif content_path.exists(): for p_alt in guide_slot_files(content_path): p_alt.unlink(missing_ok=True) bs = await list_blocks(topic, status="consensus") if bs: alle = {i: (f"{b['title']} — {b['description']}" if b["description"] else b["title"]) for i, b in enumerate(bs, 1)} else: # fallback: blocks.md (legacy topics) bp = blocks_path(topic) alle = _load_blocks(bp.read_text(encoding="utf-8")) if bp.exists() else {} if not alle: await _fail(guide_id, "No blocks found") return entries = _unique_title(alle) facts = _prompt("Guide-Facts-Projekt", project=project) if project else _prompt("Guide-Facts-Thema") chapters = await _generate_sections( guide_id, topic, format_name, entries, facts, instructions, provider, content_path, ) if chapters is None or is_guide_cancelled(guide_id): return content = {"topic": topic, "format": format_name, "chapters": chapters} atomic_write_json(content_path, content, indent=1) # bridge (resume/fallback) await set_guide_content(topic, format_name, json.dumps(content, ensure_ascii=False)) now = datetime.now(timezone.utc).isoformat() await update_guide(guide_id, status="done", progress=None, step=None, updated_at=now) except asyncio.TimeoutError: await _fail(guide_id, "Timeout during generation") except FileNotFoundError: await _fail(guide_id, "Blocks missing") except Exception as e: log.exception("[%s] Guide generation failed (%s)", topic, guide_id) await _fail(guide_id, str(e)[:2000]) finally: clear_guide_cancelled(guide_id) # --- On-demand: check / fix / rewrite one section (focus, interactive) --- SECTION_CHECK_TIMEOUT = 300 def _section_spec() -> str: return (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8") def _section_facts(topic: str) -> str: project = source_folder(topic) return _prompt("Guide-Facts-Projekt", project=project) if project else _prompt("Guide-Facts-Thema") def _hint_block(hint: str) -> str: hint = (hint or "").strip() return f"NOTE FROM THE USER (pay special attention):\n{hint}" if hint else "" async def _subs_text(topic: str, block: str) -> str: """Relevant subblocks of a block with level — as a checklist for the agents.""" subs_raw = await _load_subblocks(topic) subs = [s for s in subs_raw.get(_title(block), []) if s.get("relevance") != "peripheral"] if not subs: return "(no subblocks recorded — cover 3–7 concise points)" return "\n".join(f"- [{s['level']}] {s['title']}" for s in subs) async def _load_guide_content(topic: str, format_name: str) -> dict | None: js = await get_guide_content(topic, format_name) if not js: return None try: return json.loads(js) except ValueError: return None def _find_section(content: dict, block: str) -> dict | None: for ch in content.get("chapters", []): for s in ch.get("sections", []): if s.get("title") == block: return s return None async def block_pruefen(topic: str, format_name: str, block: str, spot: str, snippet: str, hint: str = "", provider: str = DEFAULT_PROVIDER) -> str | None: """Check one section (Markdown block) against the guide rules → corrected block version as Markdown. None = error/section missing.""" content = await _load_guide_content(topic, format_name) sec = _find_section(content, block) if content else None if sec is None: return None whole = sec.get("compact", "") if str(spot).startswith("compact") else sec.get("md", "") prompt = _prompt( "Block-Pruefen", topic=topic, spec=_section_spec(), facts=_section_facts(topic), subblocks=await _subs_text(topic, block), context=whole, snippet=snippet, hint=_hint_block(hint), ) rc, stdout, _ = await run_agent( f"block-pruefen-{uuid.uuid4()}", prompt, SECTION_CHECK_TIMEOUT, provider=provider, role="judge", capabilities="none", lane="interactive", ) new = stdout.strip() if rc == 0 else "" return new or None async def block_adopt(topic: str, format_name: str, block: str, spot: str, old: str, new: str) -> dict | None: """Replace one block (old→new) in the compact/detailed field + persist. → {compact, md, found}; None = section missing.""" content = await _load_guide_content(topic, format_name) sec = _find_section(content, block) if content else None if sec is None: return None is_compact = str(spot).startswith("compact") field = "compact" if is_compact else "md" current = sec.get(field, "") or "" found = old in current if found: sec[field] = current.replace(old, new, 1) # Also replace in anchor + subs (sources of the filtered E/M/S view), otherwise the # leveled view keeps showing the old block. anchor_field = "anker_compact" if is_compact else "anchor" if old in (sec.get(anchor_field) or ""): sec[anchor_field] = sec[anchor_field].replace(old, new, 1) for sub in sec.get("subs", []): if old in (sub.get(field, "") or ""): sub[field] = sub[field].replace(old, new, 1) break js = json.dumps(content, ensure_ascii=False) await set_guide_content(topic, format_name, js) atomic_write_json(guide_content_path(topic, format_name), content, indent=1) return {"compact": sec.get("compact", ""), "md": sec.get("md", ""), "found": found}