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
1250 lines
48 KiB
Python
1250 lines
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"]
|