Files
creator/backend/guide.py
2026-06-30 00:14:18 +02:00

1033 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 (13)."""
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",
)
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",
)
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",
)
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"<!-- section: {_title(entries[num])} -->\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",
)
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",
)
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 `<!-- sub: LABEL | title -->` 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",
)
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 37 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}