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

233 lines
7.1 KiB
Python

"""
BlockGenerator base class
=========================
A BlockGenerator turns a ``Block`` (with ``params``) plus some shared context
(book / chapter / page / KB) into a populated ``Block`` (``payload`` filled,
``status`` set to READY/ERROR).
Generators are stateless and are looked up by ``BlockType`` via
``get_block_registry()``.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
from dataclasses import dataclass, field
import logging
from typing import Any
from ..models import (
Block,
BlockStatus,
BlockType,
Chapter,
ExplorationReport,
Page,
SourceAnchor,
)
logger = logging.getLogger(__name__)
class GenerationFailure(Exception):
"""Raised by a generator to mark the block as ERROR."""
def _classify_failure(message: str) -> str:
lower = (message or "").lower()
if "json" in lower or "object found" in lower or "parse" in lower:
return "json_parse"
if "empty" in lower or "did not return" in lower or "returned no" in lower:
return "empty_response"
if "timeout" in lower or "timed out" in lower or "stalled" in lower:
return "timeout"
if "rate limit" in lower or "429" in lower:
return "rate_limit"
if "<think" in lower or "reasoning_content" in lower or "prompt" in lower:
return "prompt_leak"
if "api" in lower or "llm" in lower or "provider" in lower:
return "provider_error"
return "generator_error"
def _failure_metadata(exc: Exception, source: str) -> dict[str, Any]:
message = str(exc)
kind = _classify_failure(message)
return {
"kind": kind,
"message": message,
"retryable": kind
in {
"json_parse",
"empty_response",
"timeout",
"rate_limit",
"provider_error",
"generator_error",
},
"source": source,
}
@dataclass
class BlockContext:
"""Everything a generator might need outside the block itself."""
book_id: str
chapter: Chapter
page: Page
block: Block
language: str = "en"
knowledge_bases: list[str] = field(default_factory=list)
rag_enabled: bool = True
extra: dict[str, Any] = field(default_factory=dict)
# BookEngine v2 — fed in by ``BookCompiler`` so generators can reuse the
# exploration sweep instead of re-issuing RAG calls.
exploration: ExplorationReport | None = None
@property
def primary_kb(self) -> str | None:
return self.knowledge_bases[0] if self.knowledge_bases else None
def relevant_chunks(self, query: str, *, limit: int = 6) -> list:
"""Return ``SourceChunk`` objects from ``self.exploration`` that look
relevant to *query* (cheap keyword overlap heuristic). Empty list when
no exploration is attached."""
if self.exploration is None or not self.exploration.chunks:
return []
tokens = {t for t in (query or "").lower().split() if len(t) > 2}
if not tokens:
return self.exploration.chunks[:limit]
scored: list[tuple[float, Any]] = []
for ch in self.exploration.chunks:
text = (ch.text or "").lower()
q = (ch.query or "").lower()
hits = sum(1 for tok in tokens if tok in text or tok in q)
if hits == 0:
continue
scored.append((hits + 0.01 * (ch.score or 0.0), ch))
scored.sort(key=lambda pair: -pair[0])
return [ch for _, ch in scored[:limit]]
class BlockGenerator(ABC):
"""Base class for all block generators."""
block_type: BlockType # subclasses MUST override
async def generate(self, ctx: BlockContext) -> Block:
"""Public entry point. Wraps ``_generate`` with status book-keeping."""
block = ctx.block
block.status = BlockStatus.GENERATING
block.error = ""
try:
payload, anchors, metadata = await self._generate(ctx)
except GenerationFailure as exc:
block.status = BlockStatus.ERROR
block.error = str(exc)
block.metadata = {
**block.metadata,
"failure": _failure_metadata(exc, self.__class__.__name__),
}
return block
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning(f"Generator {self.__class__.__name__} raised: {exc}", exc_info=True)
block.status = BlockStatus.ERROR
block.error = str(exc)
block.metadata = {
**block.metadata,
"failure": _failure_metadata(exc, self.__class__.__name__),
}
return block
block.payload = payload or {}
if anchors:
block.source_anchors = anchors
if metadata:
block.metadata = {**block.metadata, **metadata}
block.metadata.pop("failure", None)
block.status = BlockStatus.READY
return block
@abstractmethod
async def _generate(
self, ctx: BlockContext
) -> tuple[dict[str, Any], list[SourceAnchor], dict[str, Any]]:
"""Subclasses return (payload, source_anchors, metadata)."""
class BlockGeneratorRegistry:
"""In-memory registry mapping ``BlockType`` → generator instance."""
def __init__(self) -> None:
self._registry: dict[BlockType, BlockGenerator] = {}
def register(self, generator: BlockGenerator) -> None:
self._registry[generator.block_type] = generator
def get(self, block_type: BlockType) -> BlockGenerator | None:
return self._registry.get(block_type)
def types(self) -> list[BlockType]:
return list(self._registry.keys())
_REGISTRY: BlockGeneratorRegistry | None = None
def get_block_registry() -> BlockGeneratorRegistry:
global _REGISTRY
if _REGISTRY is None:
_REGISTRY = _build_default_registry()
return _REGISTRY
def _build_default_registry() -> BlockGeneratorRegistry:
registry = BlockGeneratorRegistry()
# Lazy imports to avoid circular deps
from .animation import AnimationGenerator
from .callout import CalloutGenerator
from .code import CodeGenerator
from .concept_graph import ConceptGraphGenerator
from .deep_dive import DeepDiveGenerator
from .figure import FigureGenerator
from .flash_cards import FlashCardsGenerator
from .interactive import InteractiveGenerator
from .quiz import QuizGenerator
from .section import SectionGenerator
from .text import TextGenerator
from .timeline import TimelineGenerator
from .user_note import UserNoteGenerator
for cls in (
TextGenerator,
CalloutGenerator,
QuizGenerator,
UserNoteGenerator,
FigureGenerator,
InteractiveGenerator,
AnimationGenerator,
CodeGenerator,
TimelineGenerator,
FlashCardsGenerator,
DeepDiveGenerator,
ConceptGraphGenerator,
SectionGenerator,
):
registry.register(cls())
return registry
__all__ = [
"BlockContext",
"BlockGenerator",
"BlockGeneratorRegistry",
"GenerationFailure",
"get_block_registry",
]