hkuds--deeptutor
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
670 行
24 KiB
Python
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
|