270 lines
10 KiB
Python
270 lines
10 KiB
Python
"""通过大模型按教材/课程生成章节目录。"""
|
||
from __future__ import annotations
|
||
|
||
from models import LlmSetting, Textbook
|
||
from services import llm_client
|
||
|
||
|
||
SYSTEM_PROMPT = (
|
||
"你是一位资深数学教材编审与课程设计专家,熟悉盖尔范德"
|
||
"《函数和图像》《代数》《三角函数》等中学生数学思维丛书。"
|
||
)
|
||
|
||
|
||
def build_chapters_prompt(
|
||
*,
|
||
textbook: Textbook,
|
||
count: int,
|
||
existing_names: list[str],
|
||
instructions: str,
|
||
fixed_titles: list[str] | None = None,
|
||
external_context: dict | None = None,
|
||
graph_context: list[str] | None = None,
|
||
) -> str:
|
||
extra = f"\n额外要求:{instructions}" if instructions else ""
|
||
existing = "、".join(existing_names) if existing_names else "(暂无章节)"
|
||
isbn = textbook.isbn or ""
|
||
external = external_context or {}
|
||
external_title = external.get("title") or ""
|
||
external_description = external.get("description") or ""
|
||
lookup_status = (
|
||
"ISBN 公开目录未收录,请结合书名 + ISBN + 作者/出版社"
|
||
"确认正式出版物后作答"
|
||
if (isbn and not external)
|
||
else "已通过 ISBN 查询到公开书目信息"
|
||
)
|
||
knowledge_hint = (
|
||
"、".join(graph_context) if graph_context else "(按该书内容从知识图谱选择)"
|
||
)
|
||
graph_line = f"\n知识图谱可用节点:{knowledge_hint}"
|
||
|
||
if fixed_titles:
|
||
numbered = "\n".join(
|
||
f"{index}. {title}" for index, title in enumerate(fixed_titles, start=1)
|
||
)
|
||
lookup_note = "已根据 ISBN 查询到该书正式目录"
|
||
if external_title:
|
||
lookup_note += f"({external_title})"
|
||
knowledge_hint = (
|
||
"、".join(graph_context) if graph_context else "(按该书内容选择)"
|
||
)
|
||
return f"""通过 ISBN 查询到《{textbook.name}》的正式章节目录。
|
||
{lookup_note}。请为下列章节编写符合数学教材风格的内容摘要。
|
||
|
||
正式目录章节:
|
||
{numbered}
|
||
系统知识图谱可用节点:{knowledge_hint}
|
||
|
||
要求:
|
||
1. 章节名称必须与上面给定名称完全一致,不得改名、不得新增或删减;
|
||
2. 每章 summary 用 1-2 句中文说明该章核心主题、知识能力与学习任务;
|
||
3. 每章从系统知识图谱可用节点中按语义选择 1-5 个作为 knowledge_names;
|
||
4. 不要输出多余文字,只输出 JSON:
|
||
{{"chapters":[{{"name":"给定章节名","summary":"摘要","knowledge_names":["节点名"]}}]}}"""
|
||
|
||
return f"""请像检索真实书目一样,根据以下信息还原《{textbook.name}》中文正式版的完整章节目录。
|
||
|
||
书目信息:
|
||
- 书名:{textbook.name}
|
||
- ISBN:{isbn or "(未填写)"}
|
||
- 作者/翻译/出版:{textbook.author} {textbook.publisher}
|
||
- 版本/学段:{textbook.edition} {textbook.grade}
|
||
- 简介:{textbook.description or "请结合标题判断"}
|
||
- 本书目前已存在章节:{existing or "(暂无)"}
|
||
{f"- 外部书目补充:{external_description}" if external_description else ""}
|
||
{graph_line}
|
||
|
||
要求:
|
||
1. 只做“目录还原”,不要编撰新章节,不要套用通用课程大纲;
|
||
2. 请检索/回忆该 ISBN 对应版本的中文正式目录,原样输出目录条目
|
||
(引言、第 X 章、附录/习题解答等都要保留原名,例如“第 8 章 独立求解的问题”);
|
||
3. 输出全部正式目录;若确有把握有限,也可只输出能确认的部分,不要补造;
|
||
4. 为每个目录条目写 1 句 summary(前言/解答等可写其用途);
|
||
5. 每个教学章节从“知识图谱可用节点”中选择 1-5 个作为 knowledge_names;
|
||
前言、习题解答等非教学条目 knowledge_names 输出空数组;
|
||
6. 不要输出多余文字,只输出 JSON:
|
||
{{"chapters":[{{"name":"目录原样条目","summary":"摘要","knowledge_names":["节点名"]}}]}}
|
||
"""
|
||
|
||
|
||
def _extract_json(content: str) -> dict:
|
||
from services.json_utils import extract_json_lax
|
||
|
||
return extract_json_lax(content)
|
||
|
||
|
||
def generate_chapters(
|
||
*,
|
||
setting: LlmSetting,
|
||
textbook: Textbook,
|
||
count: int,
|
||
existing_names: list[str],
|
||
instructions: str,
|
||
fixed_titles: list[str] | None = None,
|
||
external_context: dict | None = None,
|
||
graph_context: list[str] | None = None,
|
||
) -> list[dict]:
|
||
use_titles = fixed_titles or []
|
||
if use_titles:
|
||
count = len(use_titles)
|
||
prompt = build_chapters_prompt(
|
||
textbook=textbook,
|
||
count=count,
|
||
existing_names=existing_names,
|
||
instructions=instructions,
|
||
fixed_titles=use_titles or None,
|
||
external_context=external_context,
|
||
graph_context=graph_context,
|
||
)
|
||
payload = {}
|
||
last_error = ""
|
||
for attempt in range(3):
|
||
try:
|
||
content = llm_client.chat_completion(
|
||
base_url=setting.base_url,
|
||
api_key=setting.api_key,
|
||
model=setting.model,
|
||
temperature=0.3,
|
||
max_tokens=setting.max_tokens or 5000,
|
||
timeout=200.0,
|
||
messages=[
|
||
{"role": "system", "content": SYSTEM_PROMPT},
|
||
{"role": "user", "content": prompt},
|
||
],
|
||
)
|
||
except ValueError as exc:
|
||
last_error = str(exc)
|
||
continue
|
||
try:
|
||
payload = _extract_json(content)
|
||
break
|
||
except ValueError as exc:
|
||
last_error = str(exc)
|
||
else:
|
||
raise ValueError(
|
||
f"模型连续 3 次未返回合法 JSON,最后一次:{last_error}"
|
||
)
|
||
raw_chapters = payload.get("chapters")
|
||
if not isinstance(raw_chapters, list) or not raw_chapters:
|
||
raise ValueError("模型返回内容中没有 chapters 列表")
|
||
|
||
result: list[dict] = []
|
||
fixed_set = set(use_titles)
|
||
for raw in raw_chapters[:count]:
|
||
if not isinstance(raw, dict):
|
||
raise ValueError("模型返回的章节格式不正确")
|
||
name = str(raw.get("name", "")).strip()
|
||
summary = str(raw.get("summary", "")).strip()
|
||
if not name:
|
||
raise ValueError("模型返回的章节名称为空")
|
||
if fixed_set and name not in fixed_set:
|
||
raise ValueError(
|
||
f"模型返回的章节“{name}”不在 ISBN 查询到的正式目录中,请重试"
|
||
)
|
||
raw_knowledge = raw.get("knowledge_names") or []
|
||
if not isinstance(raw_knowledge, list):
|
||
raw_knowledge = []
|
||
result.append(
|
||
{
|
||
"name": name[:128],
|
||
"summary": summary[:1000],
|
||
"knowledge_names": [
|
||
str(tag).strip()[:64] for tag in raw_knowledge
|
||
],
|
||
}
|
||
)
|
||
return result
|
||
|
||
|
||
def organize_reference_chapters(
|
||
*,
|
||
setting: LlmSetting,
|
||
textbook: Textbook,
|
||
reference_text: str,
|
||
existing_names: list[str],
|
||
graph_context: list[str],
|
||
) -> list[dict]:
|
||
"""根据用户粘贴的目录素材整理出章节,并为每个章节从知识图谱选节点。"""
|
||
prompt = f"""你是一名教材目录整理助手。下面是从用户或其他 AI 处得到的
|
||
《{textbook.name}》目录原始资料,请整理成结构化章节列表。
|
||
|
||
教材信息:
|
||
- ISBN:{textbook.isbn or "未填写"}
|
||
- 作者/出版:{textbook.author} {textbook.publisher}
|
||
- 已存在章节:{"、".join(existing_names) if existing_names else "(暂无)"}
|
||
|
||
知识图谱可用节点(只能从这里选 knowledge_names):
|
||
{"、".join(graph_context) if graph_context else "(暂无)"}
|
||
|
||
用户提供的目录素材:
|
||
==========================
|
||
{reference_text}
|
||
==========================
|
||
|
||
要求:
|
||
1. 只整理素材中真实出现的章节目录,不得新增、不得凭印象补造;
|
||
2. 保留正式名称(含“第 X 章”、引言、前言、习题解答等),去除多余格式符号;
|
||
3. 无法判断为目录项的文字不要输出;
|
||
4. 每章 summary 用 1 句中文说明;
|
||
5. 教学章节从“知识图谱可用节点”中按语义选择 1-5 个 knowledge_names,
|
||
非教学条目为空数组;不得自造图谱节点;
|
||
6. 只输出 JSON:{{"chapters":[{{"name":"目录项","summary":"摘要","knowledge_names":["节点"]}}]}}
|
||
"""
|
||
payload = {}
|
||
last_error = ""
|
||
for attempt in range(3):
|
||
try:
|
||
content = llm_client.chat_completion(
|
||
base_url=setting.base_url,
|
||
api_key=setting.api_key,
|
||
model=setting.model,
|
||
temperature=0.1,
|
||
max_tokens=setting.max_tokens or 5000,
|
||
timeout=200.0,
|
||
messages=[
|
||
{
|
||
"role": "system",
|
||
"content": (
|
||
"你只负责整理用户提供的真实目录素材,不编造内容。"
|
||
"JSON 必须完整闭合。"
|
||
),
|
||
},
|
||
{"role": "user", "content": prompt},
|
||
],
|
||
)
|
||
except ValueError as exc:
|
||
last_error = str(exc)
|
||
continue
|
||
try:
|
||
payload = _extract_json(content)
|
||
break
|
||
except ValueError as exc:
|
||
last_error = str(exc)
|
||
else:
|
||
raise ValueError(
|
||
f"模型连续 3 次未返回合法 JSON,最后一次:{last_error}"
|
||
)
|
||
raw_chapters = payload.get("chapters")
|
||
if not isinstance(raw_chapters, list) or not raw_chapters:
|
||
raise ValueError("整理结果中没有 chapters 列表")
|
||
result: list[dict] = []
|
||
for raw in raw_chapters:
|
||
if not isinstance(raw, dict):
|
||
continue
|
||
name = str(raw.get("name", "")).strip()
|
||
if not name:
|
||
continue
|
||
raw_knowledge = raw.get("knowledge_names") or []
|
||
if not isinstance(raw_knowledge, list):
|
||
raw_knowledge = []
|
||
result.append(
|
||
{
|
||
"name": name[:128],
|
||
"summary": str(raw.get("summary", "")).strip()[:1000],
|
||
"knowledge_names": [
|
||
str(tag).strip()[:64] for tag in raw_knowledge
|
||
],
|
||
}
|
||
)
|
||
return result
|