"""按教材章节调用大模型生成客观题,并做结构化校验后入库。"""
from __future__ import annotations
from models import Chapter, Knowledge, LlmSetting, Question, Textbook
from services import llm_client
SYSTEM_PROMPT = (
"你是一位经验丰富的高中数学教师和命题人,擅长编写规范、严谨、"
"区分度良好的单项选择题。所有题面必须自洽且答案唯一。"
)
def build_user_prompt(
*,
textbook_name: str,
chapter_name: str,
chapter_summary: str,
count: int,
difficulty: int,
knowledge_name: str,
instructions: str,
available_knowledge: list[str],
need_figure: bool = False,
) -> str:
difficulty_text = {1: "基础识记与直接计算", 2: "概念理解与中等计算", 3: "综合推理与易错辨析"}[
difficulty
]
knowledge_hint = (
f"知识点限定为“{knowledge_name}”。"
if knowledge_name
else "知识点由你依据章节内容选择,尽量使用下面给出的知识点名称;"
"若下面列表没有合适名称,可自拟简洁的中文知识点名。"
)
extra = f"额外要求:{instructions}" if instructions else ""
figure_requirement = (
(
"配图要求:每题必须在 image_svg 字段中给出与题意对应的原创 SVG 图像"
"(只画该题需要的函数图像/几何示意图,含坐标轴与必要标注),"
"SVG 用单行字符串输出(内部换行用 \\n),不要用 Markdown 图片或外链;"
"题干中不要再重复插入任何图片标记。"
)
if need_figure
else "配图要求:默认不配图,image_svg 字段填空字符串 \"\"。"
)
figure_hint = f"\n{figure_requirement}\n" if need_figure else f"\n{figure_requirement}"
return f"""请围绕以下教材章节命制 {count} 道高中数学单选题。
教材:{textbook_name}
章节:{chapter_name}
章节内容提示:{chapter_summary or "请结合章节标题自行判断"}
难度:{difficulty_text}
{knowledge_hint}
本系统已有知识点:{", ".join(available_knowledge) or "(暂无)"}
{extra}
{figure_hint}
硬性要求:
1. 每题必须恰好 4 个选项,且只有一个正确选项;
2. 不使用“以上都对”“以上都错”这类选项;
3. 涉及数学符号、公式时必须用 LaTeX:行内公式写 $...$,独立公式写 $$...$$,
不要使用 x²、x³、√、¼ 这类 Unicode 记号,例如 $x^2$、$\\sqrt{{x+1}}$、
$\\frac{{1}}{{x^2-9}}$、$f(-x)=-f(x)$;
4. 图像只通过 JSON 的 image_svg 字段提供:配图要求开启时,每题必须有与题目
语义一致的原创 SVG 图(函数图或几何示意图,含坐标轴/关键标注),
题干中不得再写任何 Markdown 图片;未开启配图时不要输出 image_svg;
5. explanation 用 1-2 句中文解释关键思路,便于学生理解,含公式时同样用 LaTeX;
6. 不要输出多余文字,只输出 JSON:
{{"questions":[
{{"stem":"题干","options":["A选项","B选项","C选项","D选项"],
"correct_index":0,"explanation":"解析","knowledge_name":"知识点","difficulty":{difficulty},
"image_svg":" 或空字符串"}}
]}}"""
def _extract_json(content: str) -> dict:
from services.json_utils import extract_json_lax
return extract_json_lax(content)
def _normalize_item(raw: dict, fallback_knowledge: str, difficulty: int) -> dict:
stem = str(raw.get("stem", "")).strip()
options = raw.get("options") or []
options = [str(option).strip() for option in options]
explanation = str(raw.get("explanation", "")).strip()
knowledge_name = str(raw.get("knowledge_name", "")).strip() or fallback_knowledge
image_svg = str(raw.get("image_svg") or "").strip()
# 部分兼容网关会对 JSON 内的 SVG 引号/换行做双重转义
image_svg = image_svg.replace("\\\\", "\\")
image_svg = (
image_svg.replace('\\"', '"')
.replace("\\n", "\n")
.replace("\\t", "\t")
.replace("\\r", "\r")
)
if not stem:
raise ValueError("模型返回的题干为空")
if len(options) != 4 or any(not option for option in options):
raise ValueError(f"选项必须是 4 个非空选项,实际为 {len(options)} 个")
if len(set(options)) != 4:
raise ValueError("选项存在重复")
try:
correct_index = int(raw.get("correct_index", -1))
except (TypeError, ValueError) as exc:
raise ValueError("correct_index 必须是数字") from exc
if correct_index < 0 or correct_index >= 4:
raise ValueError("correct_index 超出选项范围")
return {
"stem": stem,
"options": options,
"correct_index": correct_index,
"explanation": explanation or "请依据本章定义与性质重新判断。",
"knowledge_name": knowledge_name,
"difficulty": max(1, min(3, difficulty)),
"image_svg": image_svg,
}
def generate_questions(
*,
db,
setting: LlmSetting,
chapter: Chapter,
textbook: Textbook,
count: int,
difficulty: int,
knowledge_name: str,
instructions: str,
need_figure: bool = False,
) -> list[dict]:
if not setting or not setting.api_key:
raise ValueError("尚未配置大模型 API Key,请先到“模型配置”页面保存并测试")
# 带图模式先按无图生成文本题目,再逐题补 SVG,避免长 JSON 超时/截断
if need_figure:
text_items = generate_questions(
db=db,
setting=setting,
chapter=chapter,
textbook=textbook,
count=count,
difficulty=difficulty,
knowledge_name=knowledge_name,
instructions=instructions,
need_figure=False,
)
for item in text_items:
item["image_svg"] = _request_figure_svg(setting, item["stem"])
return text_items
knowledge_names = {name for (name,) in db.query(Knowledge.name).all()}
knowledge_names.update(
name for (name,) in db.query(Question.knowledge_name).all()
)
available_knowledge = sorted(knowledge_names)
prompt = build_user_prompt(
textbook_name=textbook.name,
chapter_name=chapter.name,
chapter_summary=chapter.summary,
count=count,
difficulty=difficulty,
knowledge_name=knowledge_name,
instructions=instructions,
available_knowledge=available_knowledge,
need_figure=need_figure,
)
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=setting.temperature,
max_tokens=_effective_max_tokens(setting, need_figure),
timeout=200.0,
messages=[
{
"role": "system",
"content": (
SYSTEM_PROMPT
+ " 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_items = payload.get("questions")
if not isinstance(raw_items, list) or not raw_items:
raise ValueError("模型返回内容中没有 questions 列表")
generated: list[dict] = []
for raw in raw_items[:count]:
item = _normalize_item(raw, knowledge_name or "章节综合", difficulty)
if need_figure and (
"