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

433 lines
15 KiB
Python

"""Utilities for turning selected book pages into chat context.
The chat runtime consumes this module from two places:
* the main chat page when a user picks pages via ``@book``;
* the book reader side panel, which automatically references the current page.
Keep this module pure and storage-backed only. It should not call an LLM or
depend on FastAPI so it remains cheap to unit test and reuse.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import re
from typing import Any, Iterable, Sequence
from .models import Block, BlockStatus, BlockType, Book, Chapter, Page, Spine
from .storage import BookStorage, get_book_storage
DEFAULT_MAX_CONTEXT_CHARS = 32_000
DEFAULT_MAX_PAGE_CHARS = 12_000
DEFAULT_MAX_BLOCK_CHARS = 4_000
_THINK_RE = re.compile(r"<think\b[^>]*>.*?</think>", re.IGNORECASE | re.DOTALL)
@dataclass(frozen=True)
class NormalizedBookReference:
"""One selected book with the page ids that should be sent to chat."""
book_id: str
page_ids: list[str]
def model_dump(self) -> dict[str, Any]:
return {"book_id": self.book_id, "page_ids": list(self.page_ids)}
@dataclass
class BookContextResult:
"""Serialized book context plus non-fatal issues encountered while loading."""
text: str = ""
references: list[dict[str, Any]] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def normalize_book_references(value: Any) -> list[NormalizedBookReference]:
"""Validate and de-duplicate the public ``book_references`` payload."""
if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)):
return []
refs: list[NormalizedBookReference] = []
seen_books: set[str] = set()
for item in value:
if not isinstance(item, dict):
continue
book_id = str(item.get("book_id") or "").strip()
raw_page_ids = item.get("page_ids") or []
if (
not book_id
or not isinstance(raw_page_ids, Sequence)
or isinstance(raw_page_ids, (str, bytes, bytearray))
):
continue
page_ids: list[str] = []
seen_pages: set[str] = set()
for raw_page_id in raw_page_ids:
page_id = str(raw_page_id or "").strip()
if not page_id or page_id in seen_pages:
continue
seen_pages.add(page_id)
page_ids.append(page_id)
if book_id in seen_books:
existing = next((ref for ref in refs if ref.book_id == book_id), None)
if existing is not None:
for page_id in page_ids:
if page_id not in existing.page_ids:
existing.page_ids.append(page_id)
continue
seen_books.add(book_id)
refs.append(NormalizedBookReference(book_id=book_id, page_ids=page_ids))
return refs
def build_book_context(
book_references: Any,
*,
storage: BookStorage | None = None,
max_chars: int = DEFAULT_MAX_CONTEXT_CHARS,
page_char_limit: int = DEFAULT_MAX_PAGE_CHARS,
block_char_limit: int = DEFAULT_MAX_BLOCK_CHARS,
) -> BookContextResult:
"""Load selected book pages and serialize them into compact LLM context."""
refs = normalize_book_references(book_references)
if not refs:
return BookContextResult()
store = storage or get_book_storage()
warnings: list[str] = []
sections: list[str] = []
for ref in refs:
book = store.load_book(ref.book_id)
if book is None:
warnings.append(f"book_not_found:{ref.book_id}")
continue
spine = store.load_spine(ref.book_id)
page_sections: list[str] = []
for page_id in ref.page_ids:
page = store.load_page(ref.book_id, page_id)
if page is None:
warnings.append(f"page_not_found:{ref.book_id}:{page_id}")
continue
chapter = spine.chapter_by_id(page.chapter_id) if spine else None
page_text = _serialize_page(
book=book,
spine=spine,
chapter=chapter,
page=page,
page_char_limit=page_char_limit,
block_char_limit=block_char_limit,
)
if page_text:
page_sections.append(page_text)
if page_sections:
sections.append(_serialize_book_header(book) + "\n\n" + "\n\n".join(page_sections))
text = _clip("\n\n---\n\n".join(sections), max_chars)
references = [ref.model_dump() for ref in refs]
return BookContextResult(text=text, references=references, warnings=warnings)
def _serialize_book_header(book: Book) -> str:
lines = [f"# Book: {_clean(book.title) or book.id}"]
if book.description:
lines.append(f"Description: {_clip(_clean(book.description), 800)}")
if book.language:
lines.append(f"Language: {_clean(book.language)}")
return "\n".join(lines)
def _serialize_page(
*,
book: Book,
spine: Spine | None,
chapter: Chapter | None,
page: Page,
page_char_limit: int,
block_char_limit: int,
) -> str:
lines: list[str] = []
chapter_title = chapter.title if chapter else ""
lines.append(f"## Page: {_clean(page.title) or page.id}")
if chapter_title:
lines.append(f"Chapter: {_clean(chapter_title)}")
if chapter and chapter.summary:
lines.append(f"Chapter summary: {_clip(_clean(chapter.summary), 1200)}")
objectives = page.learning_objectives or (chapter.learning_objectives if chapter else [])
if objectives:
lines.append("Learning objectives:")
lines.extend(f"- {_clip(_clean(obj), 300)}" for obj in objectives if _clean(obj))
if page.status:
lines.append(f"Page status: {getattr(page.status, 'value', page.status)}")
if spine and spine.exploration_summary:
lines.append(f"Book source summary: {_clip(_clean(spine.exploration_summary), 1000)}")
block_texts = [
serialized
for block in page.blocks
if (serialized := _serialize_block(block, block_char_limit=block_char_limit))
]
if block_texts:
lines.append("Page content:")
lines.extend(block_texts)
if page.error:
lines.append(f"Page error: {_clip(_clean(page.error), 600)}")
return _clip("\n".join(lines), page_char_limit)
def _serialize_block(block: Block, *, block_char_limit: int) -> str:
block_type = _enum_value(block.type)
status = _enum_value(block.status)
if status == BlockStatus.HIDDEN.value:
return ""
heading = f"### Block: {_clean(block.title) or block_type}"
if block_type:
heading += f" ({block_type})"
if status != BlockStatus.READY.value:
details = [heading, f"Status: {status or 'unknown'}"]
if block.error:
details.append(f"Error: {_clip(_clean(block.error), 500)}")
return "\n".join(details)
payload = block.payload if isinstance(block.payload, dict) else {}
body = _payload_for_type(block_type, payload)
if not body and block.error:
body = f"Error: {_clip(_clean(block.error), 500)}"
if not body:
return ""
return _clip(f"{heading}\n{body}", block_char_limit)
def _payload_for_type(block_type: str, payload: dict[str, Any]) -> str:
bridge = _join_text_fields(payload, ("bridge_text", "transition_in"))
main = ""
if block_type in {BlockType.TEXT.value, BlockType.USER_NOTE.value}:
main = _join_text_fields(payload, ("content", "body", "text", "markdown"))
elif block_type == BlockType.CALLOUT.value:
label = _clean(payload.get("label"))
body = _clean(payload.get("body"))
main = "\n".join(part for part in (label, body) if part)
elif block_type == BlockType.SECTION.value:
main = _format_section(payload)
elif block_type == BlockType.QUIZ.value:
main = _format_quiz(payload)
elif block_type == BlockType.FLASH_CARDS.value:
main = _format_flash_cards(payload)
elif block_type == BlockType.TIMELINE.value:
main = _format_timeline(payload)
elif block_type == BlockType.CODE.value:
main = _format_code(payload)
elif block_type == BlockType.DEEP_DIVE.value:
main = _format_deep_dive(payload)
elif block_type in {
BlockType.FIGURE.value,
BlockType.INTERACTIVE.value,
BlockType.ANIMATION.value,
BlockType.CONCEPT_GRAPH.value,
}:
main = _format_visual_summary(payload)
else:
main = _safe_payload_summary(payload)
return "\n".join(part for part in (bridge, main) if part)
def _format_section(payload: dict[str, Any]) -> str:
lines: list[str] = []
if intro := _clean(payload.get("intro")):
lines.append(intro)
subsections = payload.get("subsections")
if isinstance(subsections, list):
for item in subsections:
if not isinstance(item, dict):
continue
heading = _clean(item.get("heading"))
body = _clean(item.get("body"))
focus = _clean(item.get("focus"))
if heading:
lines.append(f"#### {heading}")
if focus:
lines.append(f"Focus: {focus}")
if body:
lines.append(body)
if takeaway := _clean(payload.get("key_takeaway")):
lines.append(f"Key takeaway: {takeaway}")
return "\n".join(lines)
def _format_quiz(payload: dict[str, Any]) -> str:
questions = payload.get("questions")
if not isinstance(questions, list):
return _join_text_fields(payload, ("topic", "question", "explanation"))
lines: list[str] = []
for idx, item in enumerate(questions[:8], start=1):
if not isinstance(item, dict):
continue
question = _clean(item.get("question"))
if not question:
continue
lines.append(f"Q{idx}: {question}")
options = item.get("options")
if isinstance(options, dict) and options:
option_text = "; ".join(
f"{_clean(key)}. {_clean(value)}" for key, value in options.items()
)
if option_text:
lines.append(f"Options: {option_text}")
if answer := _clean(item.get("correct_answer")):
lines.append(f"Answer: {answer}")
if explanation := _clean(item.get("explanation")):
lines.append(f"Explanation: {explanation}")
return "\n".join(lines)
def _format_flash_cards(payload: dict[str, Any]) -> str:
cards = payload.get("cards")
if not isinstance(cards, list):
return ""
lines: list[str] = []
for idx, item in enumerate(cards[:12], start=1):
if not isinstance(item, dict):
continue
front = _clean(item.get("front"))
back = _clean(item.get("back"))
hint = _clean(item.get("hint"))
if front and back:
suffix = f" Hint: {hint}" if hint else ""
lines.append(f"Card {idx}: {front} -> {back}{suffix}")
return "\n".join(lines)
def _format_timeline(payload: dict[str, Any]) -> str:
events = payload.get("events")
if not isinstance(events, list):
return ""
lines: list[str] = []
for item in events[:10]:
if not isinstance(item, dict):
continue
date = _clean(item.get("date"))
title = _clean(item.get("title"))
description = _clean(item.get("description"))
line = " - ".join(part for part in (date, title, description) if part)
if line:
lines.append(line)
return "\n".join(lines)
def _format_code(payload: dict[str, Any]) -> str:
lines: list[str] = []
if intent := _clean(payload.get("intent")):
lines.append(f"Intent: {intent}")
if explanation := _clean(payload.get("explanation")):
lines.append(f"Explanation: {explanation}")
code = _clean(payload.get("code"))
if code:
language = _clean(payload.get("language")) or "code"
lines.append(f"Code ({language}):\n{_clip(code, 1200)}")
return "\n".join(lines)
def _format_deep_dive(payload: dict[str, Any]) -> str:
suggestions = payload.get("suggestions")
if not isinstance(suggestions, list):
return ""
lines: list[str] = []
for item in suggestions[:6]:
if not isinstance(item, dict):
continue
topic = _clean(item.get("topic"))
rationale = _clean(item.get("rationale"))
if topic:
lines.append(f"- {topic}" + (f": {rationale}" if rationale else ""))
return "\n".join(lines)
def _format_visual_summary(payload: dict[str, Any]) -> str:
lines = [_join_text_fields(payload, ("description", "summary", "caption", "chart_type"))]
key_points = payload.get("key_points")
if isinstance(key_points, list):
for point in key_points[:8]:
if text := _clean(point):
lines.append(f"- {text}")
return "\n".join(part for part in lines if part)
def _safe_payload_summary(payload: dict[str, Any]) -> str:
return "\n".join(_iter_safe_strings(payload, max_items=16))
def _iter_safe_strings(value: Any, *, max_items: int) -> Iterable[str]:
emitted = 0
def walk(node: Any, key: str = "") -> Iterable[str]:
nonlocal emitted
if emitted >= max_items:
return
lowered = key.lower()
if lowered in {"code", "html", "svg", "content", "artifact", "artifacts"}:
return
if isinstance(node, str):
text = _clean(node)
if text:
emitted += 1
yield _clip(text, 500)
return
if isinstance(node, dict):
for child_key, child_value in node.items():
yield from walk(child_value, str(child_key))
if emitted >= max_items:
break
elif isinstance(node, list):
for child in node:
yield from walk(child, key)
if emitted >= max_items:
break
yield from walk(value)
def _join_text_fields(payload: dict[str, Any], keys: Sequence[str]) -> str:
return "\n".join(text for key in keys if (text := _clean(payload.get(key))))
def _enum_value(value: Any) -> str:
return str(getattr(value, "value", value) or "")
def _clean(value: Any) -> str:
text = str(value or "")
text = _THINK_RE.sub("", text)
text = text.replace("</think>", "").replace("<think>", "")
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _clip(text: str, limit: int) -> str:
text = _clean(text)
if limit <= 0 or len(text) <= limit:
return text
head = max(0, limit - 40)
return text[:head].rstrip() + "\n...[truncated]"
__all__ = [
"BookContextResult",
"NormalizedBookReference",
"build_book_context",
"normalize_book_references",
]