Files
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

369 lines
12 KiB
Python

"""
BookCompiler
============
Drives the per-page block-generation pipeline.
Responsibilities
----------------
1. Plan a page (call :class:`PagePlanner`) if it has no blocks yet.
2. For each block, look up the right :class:`BlockGenerator` and run it.
3. Persist the page after every block (atomic incremental progress).
4. Emit fine-grained ``BookStream`` events so the frontend can stream
blocks as they become ready.
5. Aggregate page status (READY / PARTIAL / ERROR).
The compiler is intentionally isolated from the orchestrator (``BookEngine``)
so it can be reused by background workers, recovery flows, and ad-hoc
re-generation requests.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import logging
import time
from .agents.page_planner import SectionArchitect
from .blocks.base import BlockContext, get_block_registry
from .blocks.text import generate_bridge_text
from .models import (
Block,
BlockStatus,
Chapter,
ExplorationReport,
Page,
PageStatus,
)
from .storage import BookStorage, get_book_storage
from .streaming import STAGE_BLOCK, STAGE_COMPILATION, STAGE_PAGE_PLAN, BookStream
logger = logging.getLogger(__name__)
@dataclass
class CompilerOptions:
"""Knobs that influence how a page is compiled."""
phase: int = 1
"""1 → only emit text/callout/quiz blocks (Phase 1 substitution)."""
rag_enabled: bool = True
"""Whether block generators may call the RAG tool."""
block_concurrency: int = 1
"""Maximum number of blocks generated in parallel for a single page.
Phase 1 uses sequential (1) for predictable streaming order; later phases
can crank this up for faster compilation.
"""
persist_after_each_block: bool = True
"""Save the page JSON to disk after every block completes."""
architect_llm_enabled: bool = True
"""When True, ``SectionArchitect`` may use the LLM layer to plan blocks."""
class BookCompiler:
"""Compile a single page (or list of pages) end-to-end."""
def __init__(
self,
*,
storage: BookStorage | None = None,
options: CompilerOptions | None = None,
) -> None:
self.storage = storage or get_book_storage()
self.options = options or CompilerOptions()
self.registry = get_block_registry()
self.architect = SectionArchitect(
phase=self.options.phase,
llm_enabled=self.options.architect_llm_enabled,
)
# ── Public API ───────────────────────────────────────────────────────
async def compile_page(
self,
*,
book_id: str,
chapter: Chapter,
page: Page,
stream: BookStream,
knowledge_bases: list[str] | None = None,
language: str = "en",
exploration: ExplorationReport | None = None,
) -> Page:
"""Plan (if needed) and generate every block on *page*."""
await stream.book_event(
"page_compile_started",
{"page_id": page.id, "chapter_id": chapter.id, "title": page.title},
stage=STAGE_COMPILATION,
)
# Lazy-load the exploration report from disk if the caller didn't pass
# one in. This keeps the compiler usable from background workers.
if exploration is None:
try:
exploration = self.storage.load_exploration(book_id)
except Exception as exc: # noqa: BLE001
logger.debug(f"load_exploration({book_id}) skipped: {exc}")
await self._plan_if_needed(
book_id, chapter, page, stream, language=language, exploration=exploration
)
page.status = PageStatus.GENERATING
page.updated_at = time.time()
self.storage.save_page(page)
ctx_factory = lambda block: BlockContext( # noqa: E731
book_id=book_id,
chapter=chapter,
page=page,
block=block,
language=language,
knowledge_bases=knowledge_bases or [],
rag_enabled=self.options.rag_enabled,
exploration=exploration,
)
sem = asyncio.Semaphore(max(1, self.options.block_concurrency))
async def _run(block: Block, prev: Block | None) -> None:
async with sem:
await self._generate_block(
book_id,
page,
block,
ctx_factory(block),
stream,
chapter=chapter,
language=language,
previous_block=prev,
)
# Sequential keeps streaming order; concurrent if requested.
if self.options.block_concurrency <= 1:
prev: Block | None = None
for block in page.blocks:
if block.status == BlockStatus.READY:
prev = block
continue
await _run(block, prev)
prev = block
else:
# Concurrent path uses planning order to derive prev pointers.
pending: list[tuple[Block, Block | None]] = []
prev = None
for block in page.blocks:
if block.status != BlockStatus.READY:
pending.append((block, prev))
prev = block
await asyncio.gather(*(_run(b, p) for b, p in pending))
self._finalize_page_status(page)
page.updated_at = time.time()
self.storage.save_page(page)
self.storage.append_log(
book_id,
f"page {page.id}{page.status.value} "
f"({sum(1 for b in page.blocks if b.status == BlockStatus.READY)}/{len(page.blocks)} blocks ready)",
op="compile_page",
)
await stream.book_event(
"page_compiled",
{
"page_id": page.id,
"chapter_id": chapter.id,
"status": page.status.value,
"blocks": [
{"id": b.id, "type": b.type.value, "status": b.status.value}
for b in page.blocks
],
},
stage=STAGE_COMPILATION,
)
return page
# ── Block-level helpers ──────────────────────────────────────────────
async def _generate_block(
self,
book_id: str,
page: Page,
block: Block,
ctx: BlockContext,
stream: BookStream,
*,
chapter: Chapter,
language: str,
previous_block: Block | None,
) -> None:
await stream.book_event(
"block_started",
{
"page_id": page.id,
"block_id": block.id,
"block_type": block.type.value,
},
stage=STAGE_BLOCK,
)
generator = self.registry.get(block.type)
if generator is None:
block.status = BlockStatus.ERROR
block.error = f"No generator registered for block type {block.type.value}"
logger.warning(block.error)
else:
t0 = time.time()
await generator.generate(ctx)
block.metadata.setdefault("generation_ms", int((time.time() - t0) * 1000))
if block.status == BlockStatus.READY:
await self._attach_bridge_text(
block, chapter=chapter, previous_block=previous_block, language=language
)
block.updated_at = time.time()
if self.options.persist_after_each_block:
self.storage.save_page(page)
kind = "block_ready" if block.status == BlockStatus.READY else "block_error"
await stream.book_event(
kind,
{
"page_id": page.id,
"block_id": block.id,
"block_type": block.type.value,
"status": block.status.value,
"error": block.error,
"failure": block.metadata.get("failure") if block.metadata else None,
"payload_keys": list(block.payload.keys()),
},
stage=STAGE_BLOCK,
)
# ── Bridge text attachment ─────────────────────────────────────────
async def attach_bridge_text(
self,
block: Block,
*,
chapter: Chapter,
previous_block: Block | None,
language: str,
) -> None:
"""If the block carries a ``transition_in`` hint, generate a short
plain-text bridge paragraph and store it on the block's payload as
``bridge_text``. The hint stays on ``metadata`` so the bridge can be
regenerated when the target block is replaced or refreshed.
"""
hint = str((block.metadata or {}).get("transition_in") or "").strip()
if not hint or previous_block is None:
block.payload.pop("bridge_text", None)
return
previous_summary = f"{previous_block.type.value} block on {chapter.title}"
try:
text = await generate_bridge_text(
chapter_title=chapter.title,
previous_block_summary=previous_summary,
next_block_hint=hint,
language=language,
)
except Exception as exc: # noqa: BLE001
logger.warning(f"bridge_text generation failed: {exc}")
text = ""
if text:
block.payload["bridge_text"] = text
else:
block.payload.pop("bridge_text", None)
async def _attach_bridge_text(
self,
block: Block,
*,
chapter: Chapter,
previous_block: Block | None,
language: str,
) -> None:
await self.attach_bridge_text(
block,
chapter=chapter,
previous_block=previous_block,
language=language,
)
# ── Planning ─────────────────────────────────────────────────────────
async def _plan_if_needed(
self,
book_id: str,
chapter: Chapter,
page: Page,
stream: BookStream,
*,
language: str = "en",
exploration: ExplorationReport | None = None,
) -> None:
if page.blocks:
return
page.status = PageStatus.PLANNING
self.storage.save_page(page)
await stream.book_event(
"page_planning",
{"page_id": page.id, "chapter_id": chapter.id, "title": page.title},
stage=STAGE_PAGE_PLAN,
)
planned = await self.architect.plan_blocks_async(
chapter, exploration=exploration, language=language
)
page.blocks = planned
if not page.content_type:
page.content_type = chapter.content_type
if not page.learning_objectives:
page.learning_objectives = list(chapter.learning_objectives)
page.updated_at = time.time()
self.storage.save_page(page)
await stream.book_event(
"page_planned",
{
"page_id": page.id,
"chapter_id": chapter.id,
"block_types": [b.type.value for b in page.blocks],
},
stage=STAGE_PAGE_PLAN,
)
# ── Status aggregation ─────────────────────────────────────────────
@staticmethod
def _finalize_page_status(page: Page) -> None:
if not page.blocks:
page.status = PageStatus.ERROR
page.error = "No blocks were planned for this page."
return
ready = sum(1 for b in page.blocks if b.status == BlockStatus.READY)
errored = sum(1 for b in page.blocks if b.status == BlockStatus.ERROR)
if ready == len(page.blocks):
page.status = PageStatus.READY
page.error = ""
elif ready == 0:
page.status = PageStatus.ERROR
page.error = f"All {errored} blocks failed to generate."
else:
page.status = PageStatus.PARTIAL
page.error = f"{errored}/{len(page.blocks)} blocks failed."
__all__ = ["BookCompiler", "CompilerOptions"]