项目文件夹

文件
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

1250 行
48 KiB
Python

"""
BookEngine
==========
Top-level orchestrator for the Book Engine. Sits **parallel** to
``ChatOrchestrator`` (i.e. it is **not** a ``BaseCapability``) and is the
single public entry point used by the API router, CLI, and SDK.
Lifecycle
---------
::
create_book(...) → BookProposal (Stage 1, requires user confirm)
confirm_proposal(...) → Spine (Stage 2, requires user confirm)
confirm_spine(...) → page shells + queued compilation
compile_page(book, page) → Page (Stage 3-4, drives BookCompiler)
list_books(), load_book(), delete_book(), resume_book()
Compilation queue
-----------------
For each book a per-book ``asyncio.Queue`` schedules pages with priority:
- Highest priority: page the user just opened (handled inline).
- Background: remaining unfinished pages, processed by a single worker.
The engine emits all progress over a ``StreamBus`` (wrapped by ``BookStream``)
which the WebSocket router fans out to clients.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
import logging
import time
from typing import Any
from deeptutor.core.stream_bus import StreamBus
from .agents.ideation_agent import IdeationAgent
from .agents.source_explorer import SourceExplorer
from .agents.spine_synthesizer import SpineSynthesizer
from .compiler import BookCompiler, CompilerOptions
from .inputs import IdeationContext, build_book_inputs
from .models import (
Block,
BlockStatus,
BlockType,
Book,
BookInputs,
BookProposal,
BookStatus,
Chapter,
ContentType,
ExplorationReport,
Page,
PageLink,
PageStatus,
Progress,
QuizAttempt,
Spine,
)
from .storage import BookStorage, get_book_storage
from .streaming import (
STAGE_COMPILATION,
STAGE_CRITIQUE,
STAGE_EXPLORATION,
STAGE_IDEATION,
STAGE_OVERVIEW,
STAGE_SPINE,
STAGE_SYNTHESIS,
BookStream,
)
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
# Per-book runtime state (queues, workers)
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class _BookRuntime:
"""In-process per-book scheduling state."""
queue: asyncio.Queue[str] = field(default_factory=asyncio.Queue)
queued: set[str] = field(default_factory=set)
worker: asyncio.Task[None] | None = None
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
stream: BookStream | None = None # default stream for background work
# ─────────────────────────────────────────────────────────────────────────────
# Engine
# ─────────────────────────────────────────────────────────────────────────────
class BookEngine:
"""Process-wide orchestrator for the Book Engine."""
def __init__(
self,
*,
storage: BookStorage | None = None,
compiler_options: CompilerOptions | None = None,
) -> None:
self.storage = storage or get_book_storage()
self.compiler = BookCompiler(
storage=self.storage,
options=compiler_options or CompilerOptions(phase=2),
)
self._runtimes: dict[str, _BookRuntime] = {}
self._global_lock = asyncio.Lock()
# ── Discovery / lifecycle ────────────────────────────────────────────
def list_books(self) -> list[Book]:
books: list[Book] = []
for book_id in self.storage.list_book_ids():
book = self.storage.load_book(book_id)
if book is not None:
books.append(book)
books.sort(key=lambda b: b.updated_at, reverse=True)
return books
def load_book(self, book_id: str) -> Book | None:
return self.storage.load_book(book_id)
def load_spine(self, book_id: str) -> Spine | None:
return self.storage.load_spine(book_id)
def list_pages(self, book_id: str) -> list[Page]:
return self.storage.list_pages(book_id)
def load_page(self, book_id: str, page_id: str) -> Page | None:
return self.storage.load_page(book_id, page_id)
def load_progress(self, book_id: str) -> Progress:
progress = self.storage.load_progress(book_id)
if progress is None:
progress = Progress(book_id=book_id)
self.storage.save_progress(progress)
return progress
def delete_book(self, book_id: str) -> bool:
runtime = self._runtimes.pop(book_id, None)
if runtime and runtime.worker and not runtime.worker.done():
runtime.worker.cancel()
return self.storage.delete_book(book_id)
def set_page_chat_session(self, *, book_id: str, page_id: str, session_id: str) -> Book | None:
"""Persist the chat session associated with a specific book page."""
book = self.storage.load_book(book_id)
page = self.storage.load_page(book_id, page_id)
clean_session_id = (session_id or "").strip()
if book is None or page is None or not clean_session_id:
return None
metadata = dict(book.metadata or {})
mapping = metadata.get("page_chat_sessions")
if not isinstance(mapping, dict):
mapping = {}
mapping[str(page_id)] = clean_session_id
metadata["page_chat_sessions"] = mapping
book.metadata = metadata
book.updated_at = time.time()
self.storage.save_book(book)
self.storage.append_log(
book_id,
f"page chat session mapped ({page_id}{clean_session_id})",
op="page_chat",
)
return book
@staticmethod
def _reset_page_for_force_compile(page: Page) -> None:
"""Reset generated block outputs while preserving user-authored notes."""
for block in page.blocks:
if block.type == BlockType.USER_NOTE:
continue
preserved_metadata = {
key: value
for key, value in (block.metadata or {}).items()
if key in {"transition_in", "deep_dive_page_id"}
}
block.status = BlockStatus.PENDING
block.payload = {}
block.error = ""
block.source_anchors = []
block.metadata = preserved_metadata
block.updated_at = time.time()
page.status = PageStatus.PENDING
page.error = ""
page.updated_at = time.time()
# ── Stage 1: Ideation ────────────────────────────────────────────────
async def create_book(
self,
*,
user_intent: str,
chat_session_id: str = "",
chat_selections: list[dict[str, Any]] | None = None,
notebook_refs: list[dict[str, Any]] | None = None,
knowledge_bases: list[str] | None = None,
question_categories: list[int] | None = None,
question_entries: list[int] | None = None,
language: str = "en",
stream: StreamBus | None = None,
) -> tuple[Book, BookProposal]:
"""Capture inputs, run IdeationAgent, persist DRAFT book + proposal."""
bus = stream or StreamBus()
bstream = BookStream(bus)
async with bstream.stage(STAGE_IDEATION):
await bstream.progress("Capturing inputs…", stage=STAGE_IDEATION)
book_inputs, ideation_ctx = await build_book_inputs(
user_intent=user_intent,
chat_session_id=chat_session_id,
chat_selections=chat_selections,
notebook_refs=notebook_refs,
knowledge_bases=knowledge_bases,
question_categories=question_categories,
question_entries=question_entries,
language=language,
)
await bstream.progress(
"Generating book proposal…",
stage=STAGE_IDEATION,
)
proposal = await self._run_ideation(ideation_ctx, language)
book = Book(
title=proposal.title,
description=proposal.description,
status=BookStatus.DRAFT,
proposal=proposal,
knowledge_bases=book_inputs.knowledge_bases,
language=language,
chapter_count=proposal.estimated_chapters,
)
# Capture baseline KB fingerprints immediately so subsequent drift
# checks have something to compare against. Without this the very
# first health-check run treats every selected KB as "newly added"
# and surfaces a spurious drift warning.
try:
from .kb_health import fingerprint_kbs
if book.knowledge_bases:
book.kb_fingerprints = fingerprint_kbs(book.knowledge_bases)
except Exception as exc: # noqa: BLE001
logger.debug(f"baseline fingerprint capture skipped: {exc}")
self.storage.save_book(book)
self.storage.save_inputs(book.id, book_inputs)
self.storage.save_progress(Progress(book_id=book.id))
self.storage.append_log(
book.id, f"created (status=draft, title='{book.title}')", op="create"
)
await bstream.book_event(
"proposal_ready",
{
"book_id": book.id,
"title": book.title,
"proposal": proposal.model_dump(),
},
stage=STAGE_IDEATION,
)
return book, proposal
async def _run_ideation(self, ctx: IdeationContext, language: str) -> BookProposal:
agent = IdeationAgent(language=language)
return await agent.process(ideation_context=ctx)
# ── Stage 2: Spine ───────────────────────────────────────────────────
async def confirm_proposal(
self,
*,
book_id: str,
edited_proposal: BookProposal | None = None,
stream: StreamBus | None = None,
) -> tuple[Book, Spine]:
"""User confirms (and optionally edits) the proposal → run SpineAgent."""
book = self.storage.load_book(book_id)
if book is None:
raise ValueError(f"Book {book_id} not found")
if edited_proposal is not None:
book.proposal = edited_proposal
book.title = edited_proposal.title or book.title
book.description = edited_proposal.description or book.description
bus = stream or StreamBus()
bstream = BookStream(bus)
proposal = book.proposal or BookProposal(title=book.title)
inputs = self.storage.load_inputs(book.id) or BookInputs(
user_intent=book.title or "",
knowledge_bases=list(book.knowledge_bases),
language=book.language,
)
async with bstream.stage(STAGE_SPINE):
# ── Sub-stage 1: Source exploration ──────────────────────
exploration: ExplorationReport | None = None
try:
async with bstream.stage(STAGE_EXPLORATION):
await bstream.progress(
"Exploring your sources in parallel…",
stage=STAGE_EXPLORATION,
)
explorer = SourceExplorer(language=book.language)
exploration = await explorer.explore(
book_id=book.id,
proposal=proposal,
inputs=inputs,
)
self.storage.save_exploration(book.id, exploration)
await bstream.book_event(
"exploration_ready",
{
"book_id": book.id,
"queries": exploration.queries,
"coverage": exploration.coverage,
"candidate_concepts": exploration.candidate_concepts,
"summary": exploration.summary,
},
stage=STAGE_EXPLORATION,
)
except Exception as exc:
logger.warning(f"SourceExplorer failed for {book.id}: {exc}")
exploration = None
await bstream.progress(
"Source exploration unavailable — falling back to proposal-only spine.",
stage=STAGE_EXPLORATION,
)
# ── Sub-stage 2: Synthesise spine + concept graph ────────
synthesizer = SpineSynthesizer(language=book.language)
async def _on_round(label: str, payload: dict[str, Any]) -> None:
stage = STAGE_CRITIQUE if label.startswith("critique") else STAGE_SYNTHESIS
# Don't push the full payload; just enough to drive the timeline.
summary = {
"round": label,
"chapter_count": len(payload.get("chapters") or [])
if isinstance(payload.get("chapters"), list)
else 0,
"issue_count": len(payload.get("issues") or [])
if isinstance(payload.get("issues"), list)
else 0,
"verdict": payload.get("verdict") or "",
}
await bstream.book_event(
"spine_round", {"book_id": book.id, **summary}, stage=stage
)
async with bstream.stage(STAGE_SYNTHESIS):
await bstream.progress("Synthesising spine + concept graph…", stage=STAGE_SYNTHESIS)
spine = await synthesizer.synthesize(
book_id=book.id,
proposal=proposal,
exploration=exploration,
on_round=_on_round,
)
book.chapter_count = len(spine.chapters)
book.status = BookStatus.SPINE_READY
self.storage.save_book(book)
self.storage.save_spine(spine)
self.storage.append_log(
book.id,
f"spine generated ({len(spine.chapters)} chapters, "
f"{len(spine.concept_graph.nodes)} concepts)",
op="spine",
)
await bstream.book_event(
"spine_ready",
{
"book_id": book.id,
"chapter_count": len(spine.chapters),
"concept_node_count": len(spine.concept_graph.nodes),
"concept_edge_count": len(spine.concept_graph.edges),
"spine": spine.model_dump(),
},
stage=STAGE_SPINE,
)
return book, spine
# ── Stage 2.5: Overview chapter injection ───────────────────────────
async def _ensure_overview_chapter(
self,
spine: Spine,
book: Book,
*,
stream: StreamBus | None,
) -> Spine:
"""Insert an Overview chapter at position 0 (idempotent)."""
# Idempotent guard — already injected?
first = spine.chapters[0] if spine.chapters else None
already = bool(
first
and (
first.content_type == ContentType.OVERVIEW
or (first.__pydantic_extra__ or {}).get("auto_overview") is True
)
)
if already:
return spine
overview_title = "本书导览" if book.language == "zh" else "How to read this book"
objectives = (
[
"了解整本书的章节脉络",
"掌握各章之间的概念依赖关系",
"选择最合适的阅读顺序",
]
if book.language == "zh"
else [
"See the full chapter map at a glance",
"Understand how concepts depend on each other",
"Pick the reading path that fits your goals",
]
)
overview = Chapter(
title=overview_title,
learning_objectives=objectives,
content_type=ContentType.OVERVIEW,
summary=(
"Auto-generated overview of the book's concept graph and chapter index."
if book.language != "zh"
else "自动生成的概念图与章节索引,作为本书的入口。"
),
order=0,
)
# Mark for idempotency on subsequent runs.
overview.__pydantic_extra__ = overview.__pydantic_extra__ or {}
overview.__pydantic_extra__["auto_overview"] = True
# Re-number existing chapters down by one.
for ch in spine.chapters:
ch.order = ch.order + 1
spine.chapters = [overview, *spine.chapters]
return spine
async def _materialize_overview_page(
self,
spine: Spine,
pages: list[Page],
book: Book,
*,
stream: StreamBus | None,
) -> None:
"""Build the Overview page's blocks deterministically."""
if not spine.chapters:
return
overview_chapter = spine.chapters[0]
if overview_chapter.content_type != ContentType.OVERVIEW:
return
overview_page = next((p for p in pages if p.chapter_id == overview_chapter.id), None)
if overview_page is None or overview_page.status == PageStatus.READY:
return
zh = book.language == "zh"
chapter_index = [
{
"id": ch.id,
"title": ch.title,
"summary": ch.summary,
"objectives": list(ch.learning_objectives),
"order": ch.order,
"content_type": ch.content_type.value,
"page_id": (ch.page_ids[0] if ch.page_ids else ""),
}
for ch in spine.chapters
if ch.content_type != ContentType.OVERVIEW
]
# 1) Intro text block (pre-rendered, status=READY)
intro_md = (
(
f"# {book.title or '本书'}\n\n"
f"{(book.proposal.description if book.proposal else '') or ''}\n\n"
f"下方的概念图展示了本书 {len(spine.concept_graph.nodes)} 个核心概念以及"
f"它们之间的依赖关系;再下方是 {len(chapter_index)} 个章节的入口。"
f"你可以按从上到下的顺序阅读,也可以根据自己的兴趣或先验知识选择切入点。"
)
if zh
else (
f"# {book.title or 'This book'}\n\n"
f"{(book.proposal.description if book.proposal else '') or ''}\n\n"
f"The diagram below maps the {len(spine.concept_graph.nodes)} core "
f"concepts in this book and how they depend on each other. The "
f"chapter index that follows lists all {len(chapter_index)} "
f"chapters — read top-to-bottom for the recommended path, or jump "
f"straight to whatever you're most curious about."
)
)
intro_block = Block(
type=BlockType.TEXT,
status=BlockStatus.READY,
title=("如何阅读这本书" if zh else "How to read this book"),
params={"role": "overview_intro"},
payload={"content": intro_md, "format": "markdown"},
)
# 2) Concept graph block — render deterministically
from .blocks.concept_graph import render_mermaid
graph_block = Block(
type=BlockType.CONCEPT_GRAPH,
status=BlockStatus.READY,
title=("概念图" if zh else "Concept map"),
params={
"concept_graph": spine.concept_graph.model_dump(),
"chapter_index": chapter_index,
},
payload={
"render_type": "concept_graph",
"code": {
"language": "mermaid",
"content": render_mermaid(spine.concept_graph),
},
"graph": spine.concept_graph.model_dump(),
"index": {
"chapters": chapter_index,
"node_to_chapter": {
n.id: n.chapter_id for n in spine.concept_graph.nodes if n.chapter_id
},
},
},
metadata={
"node_count": len(spine.concept_graph.nodes),
"edge_count": len(spine.concept_graph.edges),
},
)
# 3) Chapter index callout — also rendered deterministically
index_lines = []
for entry in chapter_index:
line = f"- **{entry['title']}**"
if entry.get("summary"):
line += f" — {entry['summary']}"
index_lines.append(line)
index_md = ("## 章节索引\n\n" if zh else "## Chapter index\n\n") + "\n".join(index_lines)
index_block = Block(
type=BlockType.TEXT,
status=BlockStatus.READY,
title=("章节索引" if zh else "Chapter index"),
params={"role": "chapter_index"},
payload={"content": index_md, "format": "markdown"},
)
overview_page.blocks = [intro_block, graph_block, index_block]
overview_page.status = PageStatus.READY
overview_page.content_type = ContentType.OVERVIEW
self.storage.save_page(overview_page)
if stream is not None:
bstream = BookStream(stream)
await bstream.book_event(
"overview_ready",
{
"book_id": book.id,
"page_id": overview_page.id,
"node_count": len(spine.concept_graph.nodes),
"chapter_count": len(chapter_index),
},
stage=STAGE_OVERVIEW,
)
# ── Stage 3: confirm spine + create page shells ─────────────────────
async def confirm_spine(
self,
*,
book_id: str,
edited_spine: Spine | None = None,
stream: StreamBus | None = None,
auto_compile: bool = True,
) -> list[Page]:
"""User confirms (or edits) the spine → create pending page shells.
BookEngine v2: automatically injects an **Overview** chapter at order 0
whose page is fully pre-built (deterministic concept-graph render +
intro text + chapter index). The rest of the chapters are queued for
normal compilation.
"""
book = self.storage.load_book(book_id)
if book is None:
raise ValueError(f"Book {book_id} not found")
spine = edited_spine or self.storage.load_spine(book_id)
if spine is None:
raise ValueError(f"No spine for book {book_id}")
if edited_spine is not None:
spine.book_id = book_id
self.storage.save_spine(spine)
# ── Inject Overview chapter (idempotent) ─────────────────────
spine = await self._ensure_overview_chapter(spine, book, stream=stream)
self.storage.save_spine(spine)
existing = {p.chapter_id: p for p in self.storage.list_pages(book_id)}
pages: list[Page] = []
for chapter in spine.chapters:
page = existing.get(chapter.id)
if page is None:
page = Page(
book_id=book_id,
chapter_id=chapter.id,
title=chapter.title,
learning_objectives=list(chapter.learning_objectives),
content_type=chapter.content_type,
order=chapter.order,
status=PageStatus.PENDING,
)
self.storage.save_page(page)
chapter.page_ids = [page.id]
self.storage.save_spine(spine)
pages.append(page)
# Build the Overview page eagerly (no LLM, no queue).
await self._materialize_overview_page(spine, pages, book, stream=stream)
book.page_count = len(pages)
book.status = BookStatus.COMPILING if auto_compile else BookStatus.SPINE_READY
self.storage.save_book(book)
self.storage.append_log(
book_id,
f"spine confirmed ({len(pages)} page shells, auto_compile={auto_compile})",
op="confirm_spine",
)
if auto_compile:
await self._enqueue_pending_pages(book_id, pages, stream=stream)
return pages
async def rebuild_book(
self,
*,
book_id: str,
stream: StreamBus | None = None,
auto_compile: bool = True,
) -> list[Page]:
"""Regenerate all pages while preserving the confirmed proposal/spine."""
book = self.storage.load_book(book_id)
spine = self.storage.load_spine(book_id)
if book is None or spine is None:
raise ValueError(f"Cannot rebuild book – missing book/spine ({book_id})")
runtime = self._runtimes.get(book_id)
if runtime is not None:
async with runtime.lock:
if runtime.worker is not None and not runtime.worker.done():
runtime.worker.cancel()
runtime.worker = None
runtime.queued.clear()
while not runtime.queue.empty():
try:
runtime.queue.get_nowait()
except asyncio.QueueEmpty:
break
for page in self.storage.list_pages(book_id):
self.storage.delete_page(book_id, page.id)
for chapter in spine.chapters:
chapter.page_ids = []
self.storage.save_spine(spine)
self.storage.save_progress(Progress(book_id=book_id))
book.status = BookStatus.SPINE_READY
book.page_count = 0
book.updated_at = time.time()
self.storage.save_book(book)
self.storage.append_log(
book_id,
f"rebuild requested (preserve_spine=true, auto_compile={auto_compile})",
op="rebuild",
)
return await self.confirm_spine(
book_id=book_id,
edited_spine=spine,
stream=stream,
auto_compile=auto_compile,
)
# ── Stage 3-4: compile a single page (current page) ──────────────────
async def compile_page(
self,
*,
book_id: str,
page_id: str,
stream: StreamBus | None = None,
force: bool = False,
) -> Page:
"""Drive the compiler for one page (used when a user opens it)."""
book = self.storage.load_book(book_id)
spine = self.storage.load_spine(book_id)
page = self.storage.load_page(book_id, page_id)
if book is None or spine is None or page is None:
raise ValueError(f"Cannot compile page – missing book/spine/page ({book_id}/{page_id})")
if page.status == PageStatus.READY and not force:
return page
# Overview pages are built deterministically from the spine (intro,
# concept graph, chapter index). Never run the generic LLM compiler over
# their hand-built blocks — that would overwrite the deterministic
# "How to read this book" intro and chapter index with hallucinated
# prose. Reaching here means force=True or the page is not yet READY, so
# rebuild deterministically (this also refreshes a changed spine).
if page.content_type == ContentType.OVERVIEW:
page.status = PageStatus.PENDING
self.storage.save_page(page)
await self._materialize_overview_page(spine, [page], book, stream=stream)
page = self.storage.load_page(book_id, page_id) or page
await self._maybe_finalize_book(book_id)
return page
if force:
self._reset_page_for_force_compile(page)
self.storage.save_page(page)
chapter = spine.chapter_by_id(page.chapter_id)
if chapter is None:
raise ValueError(f"Page {page_id} references unknown chapter {page.chapter_id}")
bus = stream or StreamBus()
bstream = BookStream(bus)
async with bstream.stage(STAGE_COMPILATION):
page = await self.compiler.compile_page(
book_id=book_id,
chapter=chapter,
page=page,
stream=bstream,
knowledge_bases=book.knowledge_bases,
language=book.language,
)
# Refresh KB fingerprints once we successfully ship a READY page.
# We capture them lazily so a brand-new book gets its baseline as
# soon as the very first page is compiled.
if page.status == PageStatus.READY:
try:
from .kb_health import refresh_book_fingerprints
refreshed = self.storage.load_book(book_id)
if refreshed is not None and not refreshed.kb_fingerprints:
refresh_book_fingerprints(book_id, storage=self.storage)
except Exception as exc: # noqa: BLE001
logger.debug(f"fingerprint refresh skipped: {exc}")
await self._maybe_finalize_book(book_id)
return page
# ── Background compilation queue ─────────────────────────────────────
async def _enqueue_pending_pages(
self,
book_id: str,
pages: list[Page],
*,
stream: StreamBus | None = None,
) -> None:
runtime = await self._get_or_create_runtime(book_id, stream)
async with runtime.lock:
for page in pages:
if page.status == PageStatus.READY:
continue
if page.id in runtime.queued:
continue
runtime.queued.add(page.id)
await runtime.queue.put(page.id)
self._ensure_worker(book_id)
async def _get_or_create_runtime(self, book_id: str, stream: StreamBus | None) -> _BookRuntime:
async with self._global_lock:
runtime = self._runtimes.get(book_id)
if runtime is None:
runtime = _BookRuntime()
self._runtimes[book_id] = runtime
if stream is not None and runtime.stream is None:
runtime.stream = BookStream(stream)
elif runtime.stream is None:
runtime.stream = BookStream(StreamBus())
return runtime
def _ensure_worker(self, book_id: str) -> None:
runtime = self._runtimes.get(book_id)
if runtime is None:
return
if runtime.worker is not None and not runtime.worker.done():
return
runtime.worker = asyncio.create_task(self._worker_loop(book_id))
async def _worker_loop(self, book_id: str) -> None:
runtime = self._runtimes.get(book_id)
if runtime is None:
return
bstream = runtime.stream or BookStream(StreamBus())
while True:
try:
page_id = await asyncio.wait_for(runtime.queue.get(), timeout=2.0)
except asyncio.TimeoutError:
async with runtime.lock:
if runtime.queue.empty():
runtime.worker = None
return
continue
try:
book = self.storage.load_book(book_id)
spine = self.storage.load_spine(book_id)
page = self.storage.load_page(book_id, page_id)
if book is None or spine is None or page is None:
continue
if page.status == PageStatus.READY:
continue
chapter = spine.chapter_by_id(page.chapter_id)
if chapter is None:
continue
await self.compiler.compile_page(
book_id=book_id,
chapter=chapter,
page=page,
stream=bstream,
knowledge_bases=book.knowledge_bases,
language=book.language,
)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning(
f"Background compilation failed for {book_id}/{page_id}: {exc}",
exc_info=True,
)
self.storage.append_log(
book_id,
f"background compile failed for page {page_id}: {exc}",
op="compile_error",
)
finally:
runtime.queued.discard(page_id)
await self._maybe_finalize_book(book_id)
async def _maybe_finalize_book(self, book_id: str) -> None:
book = self.storage.load_book(book_id)
if book is None:
return
pages = self.storage.list_pages(book_id)
if not pages:
return
if all(p.status == PageStatus.READY for p in pages):
if book.status != BookStatus.READY:
book.status = BookStatus.READY
self.storage.save_book(book)
self.storage.append_log(book_id, "all pages ready → status=READY", op="finalize")
# ── Block-level controls (Phase 1: regenerate single block) ─────────
async def regenerate_block(
self,
*,
book_id: str,
page_id: str,
block_id: str,
params_override: dict[str, Any] | None = None,
stream: StreamBus | None = None,
) -> Block | None:
"""Re-run a single block generator (e.g. user clicked 'regenerate')."""
book = self.storage.load_book(book_id)
spine = self.storage.load_spine(book_id)
page = self.storage.load_page(book_id, page_id)
if book is None or spine is None or page is None:
return None
chapter = spine.chapter_by_id(page.chapter_id)
if chapter is None:
return None
block = page.block_by_id(block_id)
if block is None:
return None
if params_override:
block.params = {**block.params, **params_override}
bus = stream or StreamBus()
bstream = BookStream(bus)
from .blocks.base import BlockContext, get_block_registry
registry = get_block_registry()
generator = registry.get(block.type)
if generator is None:
block.error = f"No generator for {block.type.value}"
self.storage.save_page(page)
return block
ctx = BlockContext(
book_id=book_id,
chapter=chapter,
page=page,
block=block,
language=book.language,
knowledge_bases=book.knowledge_bases,
)
# Resolve previous block by planning order so the bridge text can be
# refreshed alongside the block itself.
prev_block: Block | None = None
for candidate in page.blocks:
if candidate.id == block.id:
break
prev_block = candidate
async with bstream.stage(STAGE_COMPILATION):
await generator.generate(ctx)
if block.status.value == "ready":
await self.compiler.attach_bridge_text(
block,
chapter=chapter,
previous_block=prev_block,
language=book.language,
)
self.storage.save_page(page)
self.compiler._finalize_page_status(page)
self.storage.save_page(page)
await self._maybe_finalize_book(book_id)
return block
# ── Maintenance / health (Phase 4) ─────────────────────────────────
def kb_drift_report(self, book_id: str) -> dict[str, Any]:
"""Compute and persist the current KB drift report for *book_id*."""
from .kb_health import mark_drift_on_book
report = mark_drift_on_book(book_id, storage=self.storage)
if report is None:
return {"book_id": book_id, "has_drift": False, "missing": True}
return report.to_dict()
def refresh_kb_fingerprints(self, book_id: str) -> dict[str, Any] | None:
from .kb_health import refresh_book_fingerprints
book = refresh_book_fingerprints(book_id, storage=self.storage)
if book is None:
return None
return {
"book_id": book.id,
"kb_fingerprints": book.kb_fingerprints,
"stale_page_ids": book.stale_page_ids,
}
def log_health(self, book_id: str) -> dict[str, Any]:
from .kb_health import scan_log_health
return scan_log_health(book_id, storage=self.storage).to_dict()
# ── Block CRUD operations (Phase 3) ────────────────────────────────
async def insert_block(
self,
*,
book_id: str,
page_id: str,
block_type: BlockType,
params: dict[str, Any] | None = None,
position: int | None = None,
stream: StreamBus | None = None,
compile_now: bool = True,
) -> Block | None:
"""Insert a fresh PENDING block at *position* (default: end)."""
spine = self.storage.load_spine(book_id)
page = self.storage.load_page(book_id, page_id)
if spine is None or page is None:
return None
chapter = spine.chapter_by_id(page.chapter_id)
if chapter is None:
return None
merged_params: dict[str, Any] = {
"chapter_title": chapter.title,
"chapter_summary": chapter.summary,
"objectives": chapter.learning_objectives,
**(params or {}),
}
block = Block(type=block_type, status=BlockStatus.PENDING, params=merged_params)
if position is None or position >= len(page.blocks) or position < 0:
page.blocks.append(block)
else:
page.blocks.insert(position, block)
self.storage.save_page(page)
if compile_now and block_type != BlockType.USER_NOTE:
from .blocks.base import BlockContext, get_block_registry
generator = get_block_registry().get(block_type)
if generator is not None:
ctx = BlockContext(
book_id=book_id,
chapter=chapter,
page=page,
block=block,
language=self.storage.load_book(book_id).language
if self.storage.load_book(book_id)
else "en",
knowledge_bases=self.storage.load_book(book_id).knowledge_bases
if self.storage.load_book(book_id)
else [],
)
bus = stream or StreamBus()
bstream = BookStream(bus)
async with bstream.stage(STAGE_COMPILATION):
await generator.generate(ctx)
self.compiler._finalize_page_status(page)
self.storage.save_page(page)
elif block_type == BlockType.USER_NOTE:
block.status = BlockStatus.READY
block.payload = {
"format": "markdown",
"body": str(merged_params.get("body") or ""),
"author": "user",
}
self.storage.save_page(page)
self.storage.append_log(
book_id,
f"inserted {block_type.value} block on page {page_id} (pos={position})",
op="insert_block",
)
return block
async def delete_block(self, *, book_id: str, page_id: str, block_id: str) -> bool:
page = self.storage.load_page(book_id, page_id)
if page is None:
return False
before = len(page.blocks)
page.blocks = [b for b in page.blocks if b.id != block_id]
if len(page.blocks) == before:
return False
self.compiler._finalize_page_status(page)
self.storage.save_page(page)
self.storage.append_log(
book_id, f"deleted block {block_id} from page {page_id}", op="delete_block"
)
return True
async def move_block(
self, *, book_id: str, page_id: str, block_id: str, new_position: int
) -> bool:
page = self.storage.load_page(book_id, page_id)
if page is None:
return False
idx = next((i for i, b in enumerate(page.blocks) if b.id == block_id), -1)
if idx < 0:
return False
new_position = max(0, min(len(page.blocks) - 1, new_position))
block = page.blocks.pop(idx)
page.blocks.insert(new_position, block)
self.storage.save_page(page)
self.storage.append_log(
book_id,
f"moved block {block_id} on page {page_id} → pos {new_position}",
op="move_block",
)
return True
async def change_block_type(
self,
*,
book_id: str,
page_id: str,
block_id: str,
new_type: BlockType,
params_override: dict[str, Any] | None = None,
stream: StreamBus | None = None,
) -> Block | None:
spine = self.storage.load_spine(book_id)
page = self.storage.load_page(book_id, page_id)
if spine is None or page is None:
return None
chapter = spine.chapter_by_id(page.chapter_id)
if chapter is None:
return None
block = page.block_by_id(block_id)
if block is None:
return None
block.type = new_type
block.status = BlockStatus.PENDING
block.payload = {}
block.error = ""
if params_override:
block.params = {**block.params, **params_override}
self.storage.save_page(page)
# Re-run generator immediately
return await self.regenerate_block(
book_id=book_id, page_id=page_id, block_id=block_id, stream=stream
)
# ── Deep-dive sub-page (Phase 3) ──────────────────────────────────
async def create_deep_dive_subpage(
self,
*,
book_id: str,
parent_page_id: str,
topic: str,
block_id: str | None = None,
content_type: ContentType = ContentType.CONCEPT,
stream: StreamBus | None = None,
) -> Page | None:
"""Spawn a child Page that deepens *topic* and link it from the parent."""
book = self.storage.load_book(book_id)
spine = self.storage.load_spine(book_id)
parent = self.storage.load_page(book_id, parent_page_id)
if book is None or spine is None or parent is None:
return None
# Add a synthetic chapter so the planner has a target
chapter = Chapter(
title=f"{topic} (deep dive)",
learning_objectives=[f"Go deeper into {topic}"],
content_type=content_type,
summary=f"Sub-chapter spawned from {parent.title}.",
order=len(spine.chapters),
)
spine.chapters.append(chapter)
self.storage.save_spine(spine)
sub = Page(
book_id=book_id,
chapter_id=chapter.id,
title=topic,
learning_objectives=list(chapter.learning_objectives),
content_type=content_type,
status=PageStatus.PENDING,
order=len(self.storage.list_pages(book_id)),
parent_page_id=parent.id,
)
self.storage.save_page(sub)
chapter.page_ids = [sub.id]
self.storage.save_spine(spine)
# Add link from parent → sub
parent.links.append(PageLink(target_page_id=sub.id, relation="deepens", label=topic))
if block_id:
block = parent.block_by_id(block_id)
if block is not None:
block.metadata = {**block.metadata, "deep_dive_page_id": sub.id}
self.storage.save_page(parent)
# Compile the new page now (blocking, so caller gets ready content)
await self.compile_page(book_id=book_id, page_id=sub.id, stream=stream)
self.storage.append_log(
book_id,
f"deep-dive page {sub.id} spawned from {parent_page_id}: {topic}",
op="deep_dive",
)
return self.storage.load_page(book_id, sub.id)
# ── Quiz attempts (Phase 3) ────────────────────────────────────────
async def record_quiz_attempt(
self,
*,
book_id: str,
page_id: str,
block_id: str,
question_id: str,
user_answer: str,
is_correct: bool,
) -> Progress:
progress = self.load_progress(book_id)
progress.quiz_attempts.append(
QuizAttempt(
block_id=block_id,
page_id=page_id,
question_id=question_id,
user_answer=user_answer,
is_correct=is_correct,
)
)
if not is_correct:
page = self.storage.load_page(book_id, page_id)
if page and page.chapter_id and page.chapter_id not in progress.weak_chapters:
progress.weak_chapters.append(page.chapter_id)
else:
progress.score += 1
self.storage.save_progress(progress)
return progress
async def supplement_for_weakness(
self,
*,
book_id: str,
page_id: str,
topic: str,
stream: StreamBus | None = None,
) -> Block | None:
"""Append an extra TEXT + QUIZ block when the user struggles."""
await self.insert_block(
book_id=book_id,
page_id=page_id,
block_type=BlockType.CALLOUT,
params={"variant": "common_pitfall", "topic": topic},
stream=stream,
)
await self.insert_block(
book_id=book_id,
page_id=page_id,
block_type=BlockType.TEXT,
params={"role": "remediation", "topic": topic},
stream=stream,
)
return await self.insert_block(
book_id=book_id,
page_id=page_id,
block_type=BlockType.QUIZ,
params={"num_questions": 2, "difficulty": "easy", "topic": topic},
stream=stream,
)
# ─────────────────────────────────────────────────────────────────────────────
# Singleton accessor
# ─────────────────────────────────────────────────────────────────────────────
_engines: dict[str, BookEngine] = {}
def get_book_engine() -> BookEngine:
from deeptutor.services.path_service import get_path_service
key = str(get_path_service().workspace_root.resolve())
if key not in _engines:
_engines[key] = BookEngine()
return _engines[key]
__all__ = ["BookEngine", "get_book_engine"]