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

185 lines
6.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
SpineAgent
==========
Stage 2 of the BookEngine pipeline. Given an approved ``BookProposal`` and
optional source material from the learner's knowledge bases, produce a
``Spine`` of chapters that the user can review and edit before compilation.
"""
from __future__ import annotations
from typing import Any
from deeptutor.agents.base_agent import BaseAgent
from deeptutor.utils.json_parser import parse_json_response
from ..models import BookProposal, Chapter, ContentType, SourceAnchor, Spine
def _clip(text: str, limit: int) -> str:
text = (text or "").strip()
if len(text) <= limit:
return text
return text[:limit].rstrip() + "…"
class SpineAgent(BaseAgent):
"""LLM call that designs the chapter tree of a book."""
def __init__(
self,
api_key: str | None = None,
base_url: str | None = None,
api_version: str | None = None,
language: str = "en",
binding: str = "openai",
) -> None:
super().__init__(
module_name="book",
agent_name="spine_agent",
api_key=api_key,
base_url=base_url,
api_version=api_version,
language=language,
binding=binding,
)
async def process(
self,
*,
book_id: str,
proposal: BookProposal,
source_material: str = "",
) -> Spine:
system_prompt = self.get_prompt("system") or _FALLBACK_SYSTEM
user_template = self.get_prompt("user_template") or _FALLBACK_USER
proposal_block = (
f"title: {proposal.title}\n"
f"description: {proposal.description}\n"
f"scope: {proposal.scope}\n"
f"target_level: {proposal.target_level}\n"
f"estimated_chapters: {proposal.estimated_chapters}\n"
f"rationale: {proposal.rationale}"
)
user_prompt = user_template.format(
proposal_block=proposal_block,
source_material=source_material.strip() or "(no extra material provided)",
)
chunks: list[str] = []
async for chunk in self.stream_llm(
user_prompt=user_prompt,
system_prompt=system_prompt,
response_format={"type": "json_object"},
stage="spine",
):
chunks.append(chunk)
raw = "".join(chunks)
payload = parse_json_response(raw, logger_instance=self.logger, fallback={})
if not isinstance(payload, dict):
payload = {}
chapters = self._coerce_chapters(payload.get("chapters"))
if not chapters:
# Fallback: fabricate a minimal spine so the pipeline can keep going
chapters = [
Chapter(
title=f"{proposal.title} Overview",
learning_objectives=[
"Understand the scope of this book",
"Identify the key topics it will cover",
],
content_type=ContentType.THEORY,
summary=proposal.description or "Overview chapter.",
order=0,
)
]
# Guarantee deterministic order field
for idx, chapter in enumerate(chapters):
chapter.order = idx
return Spine(book_id=book_id, chapters=chapters)
# ------------------------------------------------------------------ #
# JSON → models
# ------------------------------------------------------------------ #
def _coerce_chapters(self, raw: Any) -> list[Chapter]:
if not isinstance(raw, list):
return []
chapters: list[Chapter] = []
seen_titles: set[str] = set()
for item in raw:
if not isinstance(item, dict):
continue
title = _clip(str(item.get("title") or ""), 160)
if not title or title.lower() in seen_titles:
continue
seen_titles.add(title.lower())
objectives_raw = item.get("learning_objectives") or []
if not isinstance(objectives_raw, list):
objectives_raw = []
objectives = [_clip(str(o), 200) for o in objectives_raw if str(o or "").strip()][:6]
anchors = self._coerce_anchors(item.get("source_anchors"))
content_type = self._coerce_content_type(item.get("content_type"))
prereq_raw = item.get("prerequisites") or []
if not isinstance(prereq_raw, list):
prereq_raw = []
prerequisites = [_clip(str(p), 160) for p in prereq_raw if str(p or "").strip()][:4]
chapters.append(
Chapter(
title=title,
learning_objectives=objectives,
content_type=content_type,
source_anchors=anchors,
prerequisites=prerequisites,
summary=_clip(str(item.get("summary") or ""), 400),
)
)
return chapters
@staticmethod
def _coerce_content_type(raw: Any) -> ContentType:
try:
return ContentType(str(raw or "theory").strip().lower())
except ValueError:
return ContentType.THEORY
@staticmethod
def _coerce_anchors(raw: Any) -> list[SourceAnchor]:
if not isinstance(raw, list):
return []
anchors: list[SourceAnchor] = []
for item in raw:
if not isinstance(item, dict):
continue
anchors.append(
SourceAnchor(
kind=_clip(str(item.get("kind") or "manual"), 32),
ref=_clip(str(item.get("ref") or ""), 200),
snippet=_clip(str(item.get("snippet") or ""), 300),
)
)
return anchors[:6]
_FALLBACK_SYSTEM = (
"Design a chapter tree for the approved BookProposal. "
'Output JSON: {"chapters": [{"title", "learning_objectives", "content_type", '
'"source_anchors", "prerequisites", "summary"}]}.'
)
_FALLBACK_USER = (
"Proposal:\n{proposal_block}\n\n"
"Material:\n{source_material}\n\nRespond with the JSON object only."
)
__all__ = ["SpineAgent"]