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

475 lines
18 KiB
Python

"""
Book Engine data models
=======================
Pydantic models that describe the persistent state of a Book:
- ``BookInputs`` snapshot of the four sources captured at creation.
- ``BookProposal`` LLM-generated proposal (Stage 1 output).
- ``Spine`` / ``Chapter`` chapter tree (Stage 2 output).
- ``Page`` / ``Block`` content units (Stage 3-4 output).
- ``Progress`` user progress + quiz stats (Stage 5).
- ``Book`` aggregate metadata persisted in ``manifest.json``.
"""
from __future__ import annotations
from enum import Enum
import time
from typing import Any
import uuid
from pydantic import BaseModel, ConfigDict, Field
# ─────────────────────────────────────────────────────────────────────────────
# Enums
# ─────────────────────────────────────────────────────────────────────────────
class BookStatus(str, Enum):
DRAFT = "draft" # ideation only, no spine yet
SPINE_READY = "spine_ready" # spine confirmed, compilation pending
COMPILING = "compiling"
READY = "ready"
ERROR = "error"
ARCHIVED = "archived"
class PageStatus(str, Enum):
PENDING = "pending"
PLANNING = "planning"
GENERATING = "generating"
READY = "ready"
PARTIAL = "partial" # some blocks ok, some failed
ERROR = "error"
class BlockStatus(str, Enum):
PENDING = "pending"
GENERATING = "generating"
READY = "ready"
ERROR = "error"
HIDDEN = "hidden"
class BlockType(str, Enum):
# Phase 1
TEXT = "text"
CALLOUT = "callout"
QUIZ = "quiz"
USER_NOTE = "user_note"
# Phase 2 — visual taxonomy: figure (svg/chartjs/mermaid) | interactive (html) | animation (video)
FIGURE = "figure"
INTERACTIVE = "interactive"
ANIMATION = "animation"
CODE = "code"
TIMELINE = "timeline"
FLASH_CARDS = "flash_cards"
# Phase 3
DEEP_DIVE = "deep_dive"
# Phase 4 (BookEngine v2)
SECTION = "section" # long-form chapter section (multi-subsection)
CONCEPT_GRAPH = "concept_graph" # rendered overview / TOC graph
# Guided Learning
DIAGNOSTIC = "diagnostic"
PRETEST = "pretest"
RETRIEVAL_PRACTICE = "retrieval_practice"
ERROR_DIAGNOSIS = "error_diagnosis"
MODULE_TEST = "module_test"
PROGRESS_DASHBOARD = "progress_dashboard"
class ContentType(str, Enum):
"""Hint that drives Page Planner template selection."""
THEORY = "theory" # text + figure + quiz + flash_cards
DERIVATION = "derivation" # text + animation + code + quiz
HISTORY = "history" # text + timeline + figure + quiz
PRACTICE = "practice" # quiz + code + text(explanation)
CONCEPT = "concept" # text + figure + flash_cards + quiz
OVERVIEW = "overview" # auto-generated TOC / concept-graph chapter
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
def _new_id(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex[:10]}"
def _now() -> float:
return time.time()
# ─────────────────────────────────────────────────────────────────────────────
# Inputs (Stage 0)
# ─────────────────────────────────────────────────────────────────────────────
class NotebookRef(BaseModel):
"""Reference to one notebook with selected record ids."""
model_config = ConfigDict(extra="ignore")
notebook_id: str
record_ids: list[str] = Field(default_factory=list)
class ChatSelection(BaseModel):
"""Reference to one chat session with optional message-id filter.
``message_ids`` empty → use all (recent) messages of that session.
"""
model_config = ConfigDict(extra="ignore")
session_id: str
message_ids: list[int] = Field(default_factory=list)
class ChatMessageSnapshot(BaseModel):
"""Lightweight snapshot of a chat message captured at book creation."""
model_config = ConfigDict(extra="ignore")
role: str = ""
content: str = ""
capability: str = ""
created_at: float = 0.0
class BookInputs(BaseModel):
"""Four-source input snapshot captured when the book is created."""
model_config = ConfigDict(extra="ignore")
user_intent: str = ""
chat_session_id: str = "" # legacy single-session shorthand
chat_selections: list[ChatSelection] = Field(default_factory=list)
chat_history: list[ChatMessageSnapshot] = Field(default_factory=list)
notebook_refs: list[NotebookRef] = Field(default_factory=list)
knowledge_bases: list[str] = Field(default_factory=list)
question_categories: list[int] = Field(default_factory=list)
question_entries: list[int] = Field(default_factory=list)
language: str = "en"
captured_at: float = Field(default_factory=_now)
# ─────────────────────────────────────────────────────────────────────────────
# Stage 1: Proposal
# ─────────────────────────────────────────────────────────────────────────────
class BookProposal(BaseModel):
"""LLM-generated proposal returned at the end of Stage 1 (Ideation)."""
model_config = ConfigDict(extra="allow")
title: str = ""
description: str = ""
scope: str = ""
target_level: str = ""
estimated_chapters: int = 0
rationale: str = ""
# ─────────────────────────────────────────────────────────────────────────────
# Stage 2: Spine
# ─────────────────────────────────────────────────────────────────────────────
class SourceAnchor(BaseModel):
"""Pointer back to the raw source(s) that anchor a chapter or block."""
model_config = ConfigDict(extra="ignore")
kind: str = "" # 'kb' | 'notebook' | 'chat' | 'web' | 'manual'
ref: str = "" # KB doc id, notebook record id, message id…
snippet: str = "" # short preview (≤300 chars)
class Chapter(BaseModel):
"""A chapter in the spine. May be expanded into one or more pages."""
model_config = ConfigDict(extra="allow")
id: str = Field(default_factory=lambda: _new_id("ch"))
title: str = ""
learning_objectives: list[str] = Field(default_factory=list)
content_type: ContentType = ContentType.THEORY
source_anchors: list[SourceAnchor] = Field(default_factory=list)
prerequisites: list[str] = Field(default_factory=list) # other chapter ids
page_ids: list[str] = Field(default_factory=list)
summary: str = ""
order: int = 0
# ─────────────────────────────────────────────────────────────────────────────
# Concept graph (Stage 2 — Spine companion)
# ─────────────────────────────────────────────────────────────────────────────
class ConceptNode(BaseModel):
"""One concept in the directed concept graph behind the spine."""
model_config = ConfigDict(extra="ignore")
id: str = "" # short slug, e.g. "fourier_basis"
label: str = "" # human-readable concept name
chapter_id: str = "" # chapter that primarily covers this concept
description: str = "" # 1-sentence description (optional)
weight: float = 1.0 # importance / centrality hint
class ConceptEdge(BaseModel):
"""Directed edge ``from`` → ``to`` in the concept graph."""
model_config = ConfigDict(extra="ignore")
src: str = "" # ConceptNode.id
dst: str = "" # ConceptNode.id
relation: str = "depends_on" # 'depends_on' | 'extends' | 'related'
rationale: str = ""
class ConceptGraph(BaseModel):
"""Directed graph of concepts that grounds the spine."""
model_config = ConfigDict(extra="ignore")
nodes: list[ConceptNode] = Field(default_factory=list)
edges: list[ConceptEdge] = Field(default_factory=list)
def node_by_id(self, node_id: str) -> ConceptNode | None:
for n in self.nodes:
if n.id == node_id:
return n
return None
def has_edge(self, src: str, dst: str) -> bool:
return any(e.src == src and e.dst == dst for e in self.edges)
class Spine(BaseModel):
"""Full chapter tree of a book."""
model_config = ConfigDict(extra="allow")
book_id: str
chapters: list[Chapter] = Field(default_factory=list)
version: int = 1
updated_at: float = Field(default_factory=_now)
# New (BookEngine v2): structural / source-grounding companions
concept_graph: ConceptGraph = Field(default_factory=ConceptGraph)
exploration_summary: str = ""
def chapter_by_id(self, chapter_id: str) -> Chapter | None:
for chapter in self.chapters:
if chapter.id == chapter_id:
return chapter
return None
# ─────────────────────────────────────────────────────────────────────────────
# Source exploration (Stage 2 prep — fed into SpineSynthesizer & compiler)
# ─────────────────────────────────────────────────────────────────────────────
class SourceChunk(BaseModel):
"""A retrieved chunk that grounds spine / block generation.
Persisted alongside the book so subsequent stages can reuse retrievals
without re-hitting the RAG pipeline.
"""
model_config = ConfigDict(extra="ignore")
chunk_id: str = ""
kb_name: str = "" # empty for non-KB sources (chat / notebook…)
source: str = "" # 'kb' | 'notebook' | 'chat' | 'questions' | 'web'
ref: str = "" # doc id / record id / message id …
text: str = ""
score: float = 0.0
query: str = "" # the query that surfaced this chunk
metadata: dict[str, Any] = Field(default_factory=dict)
class ExplorationReport(BaseModel):
"""Structured artefact produced by ``SourceExplorer``.
Captures the multi-query parallel sweep across the user-provided sources
(KBs, notebooks, chat selections, question entries…). Down-stream stages
(SpineSynthesizer, SectionArchitect, BlockGenerators) read from this report
instead of re-issuing retrievals — making generation deterministic and far
cheaper after the first sweep.
"""
model_config = ConfigDict(extra="ignore")
book_id: str = ""
queries: list[str] = Field(default_factory=list)
chunks: list[SourceChunk] = Field(default_factory=list)
summary: str = "" # short LLM summary of recurring themes
coverage: dict[str, int] = Field(default_factory=dict) # source → chunk count
candidate_concepts: list[str] = Field(default_factory=list)
notes: list[str] = Field(default_factory=list)
created_at: float = Field(default_factory=_now)
# ─────────────────────────────────────────────────────────────────────────────
# Blocks
# ─────────────────────────────────────────────────────────────────────────────
class Block(BaseModel):
"""Atomic content unit on a page. Rendered natively by the frontend."""
model_config = ConfigDict(extra="allow")
id: str = Field(default_factory=lambda: _new_id("blk"))
type: BlockType
status: BlockStatus = BlockStatus.PENDING
title: str = ""
# Generator inputs (kept after generation for retry / regenerate)
params: dict[str, Any] = Field(default_factory=dict)
# Generated payload (shape depends on `type`)
payload: dict[str, Any] = Field(default_factory=dict)
# Source anchors that grounded the block
source_anchors: list[SourceAnchor] = Field(default_factory=list)
# Free-form metadata (timing, model, retries…)
metadata: dict[str, Any] = Field(default_factory=dict)
error: str = ""
created_at: float = Field(default_factory=_now)
updated_at: float = Field(default_factory=_now)
# ─────────────────────────────────────────────────────────────────────────────
# Pages
# ─────────────────────────────────────────────────────────────────────────────
class PageLink(BaseModel):
"""Cross-page relationship (deepens, references, prereq…)."""
model_config = ConfigDict(extra="ignore")
target_page_id: str
relation: str = "references" # 'deepens' | 'prereq' | 'references'
label: str = ""
class Page(BaseModel):
"""A single page = ordered sequence of Blocks + state."""
model_config = ConfigDict(extra="allow")
id: str = Field(default_factory=lambda: _new_id("pg"))
book_id: str = ""
chapter_id: str = ""
title: str = ""
learning_objectives: list[str] = Field(default_factory=list)
content_type: ContentType = ContentType.THEORY
status: PageStatus = PageStatus.PENDING
order: int = 0
blocks: list[Block] = Field(default_factory=list)
links: list[PageLink] = Field(default_factory=list)
parent_page_id: str = "" # for deep_dive sub-pages
error: str = ""
created_at: float = Field(default_factory=_now)
updated_at: float = Field(default_factory=_now)
def block_by_id(self, block_id: str) -> Block | None:
for block in self.blocks:
if block.id == block_id:
return block
return None
# ─────────────────────────────────────────────────────────────────────────────
# Stage 5: Progress
# ─────────────────────────────────────────────────────────────────────────────
class QuizAttempt(BaseModel):
model_config = ConfigDict(extra="ignore")
block_id: str
page_id: str
question_id: str = ""
user_answer: str = ""
is_correct: bool = False
timestamp: float = Field(default_factory=_now)
class Progress(BaseModel):
"""Per-user progress through the book."""
model_config = ConfigDict(extra="ignore")
book_id: str
current_page_id: str = ""
visited_page_ids: list[str] = Field(default_factory=list)
bookmarked_page_ids: list[str] = Field(default_factory=list)
quiz_attempts: list[QuizAttempt] = Field(default_factory=list)
weak_chapters: list[str] = Field(default_factory=list)
score: int = 0
updated_at: float = Field(default_factory=_now)
# ─────────────────────────────────────────────────────────────────────────────
# Aggregate manifest
# ─────────────────────────────────────────────────────────────────────────────
class Book(BaseModel):
"""Top-level book metadata persisted in ``manifest.json``."""
model_config = ConfigDict(extra="allow")
id: str = Field(default_factory=lambda: _new_id("bk"))
title: str = ""
description: str = ""
status: BookStatus = BookStatus.DRAFT
proposal: BookProposal | None = None
knowledge_bases: list[str] = Field(default_factory=list)
language: str = "en"
page_count: int = 0
chapter_count: int = 0
created_at: float = Field(default_factory=_now)
updated_at: float = Field(default_factory=_now)
metadata: dict[str, Any] = Field(default_factory=dict)
# KB fingerprints captured at compile-time. Used to detect KB drift.
kb_fingerprints: dict[str, str] = Field(default_factory=dict)
# Pages whose KB content has changed since they were last compiled.
stale_page_ids: list[str] = Field(default_factory=list)
__all__ = [
"BookStatus",
"PageStatus",
"BlockStatus",
"BlockType",
"ContentType",
"NotebookRef",
"ChatSelection",
"ChatMessageSnapshot",
"BookInputs",
"BookProposal",
"SourceAnchor",
"Chapter",
"ConceptNode",
"ConceptEdge",
"ConceptGraph",
"Spine",
"SourceChunk",
"ExplorationReport",
"Block",
"Page",
"PageLink",
"QuizAttempt",
"Progress",
"Book",
]