项目文件夹

文件
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

670 行
24 KiB
Python

"""
Book Engine API Router
======================
REST + WebSocket endpoints for the ``BookEngine``. Phase 1 surface:
create / confirm / compile / read / delete + a per-book event stream.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, Field
from deeptutor.book import (
BlockType,
BookProposal,
Spine,
get_book_engine,
)
from deeptutor.book.models import ContentType
from deeptutor.book.streaming import SOURCE as BOOK_SOURCE
from deeptutor.core.stream import StreamEventType
from deeptutor.core.stream_bus import StreamBus
router = APIRouter()
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
# Request / response models
# ─────────────────────────────────────────────────────────────────────────────
class CreateBookRequest(BaseModel):
user_intent: str = Field(default="")
chat_session_id: str = Field(default="")
chat_selections: list[dict[str, Any]] = Field(default_factory=list)
notebook_refs: list[dict[str, Any]] = 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 = Field(default="en")
class ConfirmProposalRequest(BaseModel):
book_id: str
proposal: dict[str, Any] | None = None # full edited BookProposal payload
class ConfirmSpineRequest(BaseModel):
book_id: str
spine: dict[str, Any] | None = None
auto_compile: bool = True
class CompilePageRequest(BaseModel):
book_id: str
page_id: str
force: bool = False
class RegenerateBlockRequest(BaseModel):
book_id: str
page_id: str
block_id: str
params_override: dict[str, Any] | None = None
class InsertBlockRequest(BaseModel):
book_id: str
page_id: str
block_type: str
params: dict[str, Any] | None = None
position: int | None = None
compile_now: bool = True
class DeleteBlockRequest(BaseModel):
book_id: str
page_id: str
block_id: str
class MoveBlockRequest(BaseModel):
book_id: str
page_id: str
block_id: str
new_position: int
class ChangeBlockTypeRequest(BaseModel):
book_id: str
page_id: str
block_id: str
new_type: str
params_override: dict[str, Any] | None = None
class DeepDiveRequest(BaseModel):
book_id: str
parent_page_id: str
topic: str
block_id: str | None = None
content_type: str = "concept"
class QuizAttemptRequest(BaseModel):
book_id: str
page_id: str
block_id: str
question_id: str = ""
user_answer: str = ""
is_correct: bool = False
request_remediation: bool = False
class SupplementRequest(BaseModel):
book_id: str
page_id: str
topic: str
class PageChatSessionRequest(BaseModel):
book_id: str
page_id: str
session_id: str
class RebuildBookRequest(BaseModel):
book_id: str
auto_compile: bool = True
# ─────────────────────────────────────────────────────────────────────────────
# REST endpoints
# ─────────────────────────────────────────────────────────────────────────────
@router.get("/health")
async def health_check() -> dict[str, str]:
return {"status": "healthy", "service": "book"}
@router.get("/books")
async def list_books() -> dict[str, Any]:
engine = get_book_engine()
return {"books": [b.model_dump(mode="json") for b in engine.list_books()]}
@router.get("/books/{book_id}")
async def get_book(book_id: str) -> dict[str, Any]:
engine = get_book_engine()
book = engine.load_book(book_id)
if book is None:
raise HTTPException(status_code=404, detail="Book not found")
spine = engine.load_spine(book_id)
pages = engine.list_pages(book_id)
progress = engine.load_progress(book_id)
return {
"book": book.model_dump(mode="json"),
"spine": spine.model_dump(mode="json") if spine else None,
"pages": [p.model_dump(mode="json") for p in pages],
"progress": progress.model_dump(mode="json"),
}
@router.get("/books/{book_id}/spine")
async def get_spine(book_id: str) -> dict[str, Any]:
engine = get_book_engine()
spine = engine.load_spine(book_id)
if spine is None:
raise HTTPException(status_code=404, detail="Spine not found")
return {"spine": spine.model_dump(mode="json")}
@router.get("/books/{book_id}/pages/{page_id}")
async def get_page(book_id: str, page_id: str) -> dict[str, Any]:
engine = get_book_engine()
page = engine.load_page(book_id, page_id)
if page is None:
raise HTTPException(status_code=404, detail="Page not found")
return {"page": page.model_dump(mode="json")}
@router.delete("/books/{book_id}")
async def delete_book(book_id: str) -> dict[str, Any]:
engine = get_book_engine()
ok = engine.delete_book(book_id)
if not ok:
raise HTTPException(status_code=404, detail="Book not found")
return {"deleted": True, "book_id": book_id}
@router.post("/books")
async def create_book(req: CreateBookRequest) -> dict[str, Any]:
"""Stage 1: capture inputs + run IdeationAgent."""
if not req.user_intent.strip():
raise HTTPException(status_code=400, detail="user_intent is required")
engine = get_book_engine()
try:
book, proposal = await engine.create_book(
user_intent=req.user_intent,
chat_session_id=req.chat_session_id,
chat_selections=req.chat_selections,
notebook_refs=req.notebook_refs,
knowledge_bases=req.knowledge_bases,
question_categories=req.question_categories,
question_entries=req.question_entries,
language=req.language,
)
except Exception as exc: # noqa: BLE001
logger.error(f"create_book failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
return {
"book": book.model_dump(mode="json"),
"proposal": proposal.model_dump(mode="json"),
}
@router.post("/books/confirm-proposal")
async def confirm_proposal(req: ConfirmProposalRequest) -> dict[str, Any]:
"""Stage 2: user confirms (and possibly edits) the proposal → SpineAgent."""
engine = get_book_engine()
edited: BookProposal | None = None
if req.proposal:
try:
edited = BookProposal.model_validate(req.proposal)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Invalid proposal: {exc}")
try:
book, spine = await engine.confirm_proposal(book_id=req.book_id, edited_proposal=edited)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc: # noqa: BLE001
logger.error(f"confirm_proposal failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
return {
"book": book.model_dump(mode="json"),
"spine": spine.model_dump(mode="json"),
}
@router.post("/books/confirm-spine")
async def confirm_spine(req: ConfirmSpineRequest) -> dict[str, Any]:
"""Stage 3: user confirms the spine → create pending page shells."""
engine = get_book_engine()
edited: Spine | None = None
if req.spine:
try:
edited = Spine.model_validate(req.spine)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Invalid spine: {exc}")
try:
pages = await engine.confirm_spine(
book_id=req.book_id,
edited_spine=edited,
auto_compile=req.auto_compile,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc: # noqa: BLE001
logger.error(f"confirm_spine failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
return {"pages": [p.model_dump(mode="json") for p in pages]}
@router.post("/books/compile-page")
async def compile_page(req: CompilePageRequest) -> dict[str, Any]:
"""Drive the compiler for the page the user just opened (current-page priority)."""
engine = get_book_engine()
try:
page = await engine.compile_page(book_id=req.book_id, page_id=req.page_id, force=req.force)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc: # noqa: BLE001
logger.error(f"compile_page failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
return {"page": page.model_dump(mode="json")}
@router.post("/books/regenerate-block")
async def regenerate_block(req: RegenerateBlockRequest) -> dict[str, Any]:
engine = get_book_engine()
try:
block = await engine.regenerate_block(
book_id=req.book_id,
page_id=req.page_id,
block_id=req.block_id,
params_override=req.params_override,
)
except Exception as exc: # noqa: BLE001
logger.error(f"regenerate_block failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
if block is None:
raise HTTPException(status_code=404, detail="Block not found")
return {"block": block.model_dump(mode="json")}
def _coerce_block_type(name: str) -> BlockType:
try:
return BlockType(name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Unknown block type: {name}") from exc
def _coerce_content_type(name: str) -> ContentType:
try:
return ContentType(name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Unknown content type: {name}") from exc
@router.post("/books/insert-block")
async def insert_block(req: InsertBlockRequest) -> dict[str, Any]:
engine = get_book_engine()
block_type = _coerce_block_type(req.block_type)
try:
block = await engine.insert_block(
book_id=req.book_id,
page_id=req.page_id,
block_type=block_type,
params=req.params,
position=req.position,
compile_now=req.compile_now,
)
except Exception as exc: # noqa: BLE001
logger.error(f"insert_block failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
if block is None:
raise HTTPException(status_code=404, detail="Page or chapter not found")
return {"block": block.model_dump(mode="json")}
@router.post("/books/delete-block")
async def delete_block(req: DeleteBlockRequest) -> dict[str, Any]:
engine = get_book_engine()
ok = await engine.delete_block(book_id=req.book_id, page_id=req.page_id, block_id=req.block_id)
if not ok:
raise HTTPException(status_code=404, detail="Block not found")
return {"ok": True}
@router.post("/books/move-block")
async def move_block(req: MoveBlockRequest) -> dict[str, Any]:
engine = get_book_engine()
ok = await engine.move_block(
book_id=req.book_id,
page_id=req.page_id,
block_id=req.block_id,
new_position=req.new_position,
)
if not ok:
raise HTTPException(status_code=404, detail="Block not found")
return {"ok": True}
@router.post("/books/change-block-type")
async def change_block_type(req: ChangeBlockTypeRequest) -> dict[str, Any]:
engine = get_book_engine()
new_type = _coerce_block_type(req.new_type)
try:
block = await engine.change_block_type(
book_id=req.book_id,
page_id=req.page_id,
block_id=req.block_id,
new_type=new_type,
params_override=req.params_override,
)
except Exception as exc: # noqa: BLE001
logger.error(f"change_block_type failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
if block is None:
raise HTTPException(status_code=404, detail="Block not found")
return {"block": block.model_dump(mode="json")}
@router.post("/books/deep-dive")
async def deep_dive(req: DeepDiveRequest) -> dict[str, Any]:
engine = get_book_engine()
content_type = _coerce_content_type(req.content_type)
try:
page = await engine.create_deep_dive_subpage(
book_id=req.book_id,
parent_page_id=req.parent_page_id,
topic=req.topic,
block_id=req.block_id,
content_type=content_type,
)
except Exception as exc: # noqa: BLE001
logger.error(f"deep_dive failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
if page is None:
raise HTTPException(status_code=404, detail="Parent page not found")
return {"page": page.model_dump(mode="json")}
@router.post("/books/quiz-attempt")
async def quiz_attempt(req: QuizAttemptRequest) -> dict[str, Any]:
engine = get_book_engine()
progress = await engine.record_quiz_attempt(
book_id=req.book_id,
page_id=req.page_id,
block_id=req.block_id,
question_id=req.question_id,
user_answer=req.user_answer,
is_correct=req.is_correct,
)
return {"progress": progress.model_dump(mode="json")}
@router.get("/books/{book_id}/health")
async def book_health(book_id: str) -> dict[str, Any]:
engine = get_book_engine()
drift = engine.kb_drift_report(book_id)
log = engine.log_health(book_id)
return {"kb_drift": drift, "log_health": log}
@router.post("/books/{book_id}/refresh-fingerprints")
async def refresh_fingerprints(book_id: str) -> dict[str, Any]:
engine = get_book_engine()
result = engine.refresh_kb_fingerprints(book_id)
if result is None:
raise HTTPException(status_code=404, detail="Book not found")
return result
@router.post("/books/supplement")
async def supplement(req: SupplementRequest) -> dict[str, Any]:
engine = get_book_engine()
try:
block = await engine.supplement_for_weakness(
book_id=req.book_id,
page_id=req.page_id,
topic=req.topic,
)
except Exception as exc: # noqa: BLE001
logger.error(f"supplement failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
if block is None:
raise HTTPException(status_code=404, detail="Page not found")
return {"block": block.model_dump(mode="json")}
@router.post("/books/page-chat-session")
async def set_page_chat_session(req: PageChatSessionRequest) -> dict[str, Any]:
engine = get_book_engine()
book = engine.set_page_chat_session(
book_id=req.book_id,
page_id=req.page_id,
session_id=req.session_id,
)
if book is None:
raise HTTPException(status_code=404, detail="Book or page not found")
return {"book": book.model_dump(mode="json")}
@router.post("/books/rebuild")
async def rebuild_book(req: RebuildBookRequest) -> dict[str, Any]:
engine = get_book_engine()
try:
pages = await engine.rebuild_book(book_id=req.book_id, auto_compile=req.auto_compile)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc: # noqa: BLE001
logger.error(f"rebuild_book failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
return {"pages": [p.model_dump(mode="json") for p in pages]}
# ─────────────────────────────────────────────────────────────────────────────
# WebSocket – streamed Book events
# ─────────────────────────────────────────────────────────────────────────────
def _serialize_event(event) -> dict[str, Any]:
return {
"type": event.type.value if hasattr(event.type, "value") else str(event.type),
"source": event.source,
"stage": event.stage,
"content": event.content,
"metadata": event.metadata or {},
}
@router.websocket("/ws")
async def book_websocket(ws: WebSocket) -> None:
"""Streaming endpoint.
Client message protocol::
{"type": "create", ...CreateBookRequest fields}
{"type": "confirm_proposal", "book_id": "...", "proposal": {...}}
{"type": "confirm_spine", "book_id": "...", "spine": {...}, "auto_compile": true}
{"type": "compile_page", "book_id": "...", "page_id": "..."}
{"type": "regenerate_block", "book_id": "...", "page_id": "...", "block_id": "...", "params_override": {}}
"""
from deeptutor.api.routers.auth import ws_auth_failed, ws_require_auth
from deeptutor.multi_user.context import reset_current_user
user_token = await ws_require_auth(ws)
if user_token is ws_auth_failed:
return
await ws.accept()
closed = False
async def send(data: dict[str, Any]) -> None:
nonlocal closed
if closed:
return
try:
await ws.send_json(data)
except Exception:
closed = True
async def stream_into_socket(bus: StreamBus) -> asyncio.Task:
async def _forward() -> None:
async for event in bus.subscribe():
if event.source != BOOK_SOURCE:
continue
await send(_serialize_event(event))
if event.type == StreamEventType.STAGE_END and event.stage in {
"ideation",
"spine",
"compilation",
}:
pass # keep streaming – multiple stages per task
return asyncio.create_task(_forward())
try:
engine = get_book_engine()
while not closed:
try:
data = await ws.receive_json()
except WebSocketDisconnect:
break
except Exception as exc:
await send({"type": "error", "content": f"Bad message: {exc}"})
continue
msg_type = str(data.get("type") or "").strip()
if not msg_type:
await send({"type": "error", "content": "Missing 'type' field"})
continue
bus = StreamBus()
forward_task = await stream_into_socket(bus)
try:
if msg_type == "create":
book, proposal = await engine.create_book(
user_intent=str(data.get("user_intent") or ""),
chat_session_id=str(data.get("chat_session_id") or ""),
chat_selections=data.get("chat_selections") or [],
notebook_refs=data.get("notebook_refs") or [],
knowledge_bases=data.get("knowledge_bases") or [],
question_categories=[
int(c) for c in (data.get("question_categories") or [])
],
question_entries=[int(e) for e in (data.get("question_entries") or [])],
language=str(data.get("language") or "en"),
stream=bus,
)
await send(
{
"type": "create_result",
"book": book.model_dump(mode="json"),
"proposal": proposal.model_dump(mode="json"),
}
)
elif msg_type == "confirm_proposal":
edited: BookProposal | None = None
if data.get("proposal"):
edited = BookProposal.model_validate(data["proposal"])
book, spine = await engine.confirm_proposal(
book_id=str(data.get("book_id") or ""),
edited_proposal=edited,
stream=bus,
)
await send(
{
"type": "confirm_proposal_result",
"book": book.model_dump(mode="json"),
"spine": spine.model_dump(mode="json"),
}
)
elif msg_type == "confirm_spine":
edited_spine: Spine | None = None
if data.get("spine"):
edited_spine = Spine.model_validate(data["spine"])
pages = await engine.confirm_spine(
book_id=str(data.get("book_id") or ""),
edited_spine=edited_spine,
auto_compile=bool(data.get("auto_compile", True)),
stream=bus,
)
await send(
{
"type": "confirm_spine_result",
"pages": [p.model_dump(mode="json") for p in pages],
}
)
elif msg_type == "compile_page":
page = await engine.compile_page(
book_id=str(data.get("book_id") or ""),
page_id=str(data.get("page_id") or ""),
stream=bus,
force=bool(data.get("force", False)),
)
await send(
{
"type": "compile_page_result",
"page": page.model_dump(mode="json"),
}
)
elif msg_type == "regenerate_block":
block = await engine.regenerate_block(
book_id=str(data.get("book_id") or ""),
page_id=str(data.get("page_id") or ""),
block_id=str(data.get("block_id") or ""),
params_override=data.get("params_override"),
stream=bus,
)
await send(
{
"type": "regenerate_block_result",
"block": block.model_dump(mode="json") if block else None,
}
)
else:
await send({"type": "error", "content": f"Unknown message type: {msg_type}"})
except Exception as exc:
logger.error(f"book ws action {msg_type} failed: {exc}", exc_info=True)
await send({"type": "error", "content": str(exc)})
finally:
await bus.close()
forward_task.cancel()
try:
await forward_task
except (asyncio.CancelledError, Exception):
pass
except WebSocketDisconnect:
pass
except Exception as exc:
logger.error(f"Book WS connection error: {exc}", exc_info=True)
finally:
closed = True
try:
await ws.close()
except Exception:
pass
if user_token is not None:
try:
reset_current_user(user_token)
except Exception:
pass