修正 AI 复盘解读:按日期顺序、得分率与科目专属建议。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-28 17:36:08 +08:00
parent 02e7ba055a
commit aaa08cdf38
2 changed files with 130 additions and 31 deletions
+46 -15
View File
@@ -37,26 +37,57 @@ def _subject_display_name(score: SubjectScore) -> str:
return score.subject.name if score.subject else f"科目{score.subject_id}"
def _build_review_records_text(exams: list[ExamRecord], subject_name: str) -> str:
lines: list[str] = []
for exam in sorted(exams, key=lambda item: item.exam_date, reverse=True):
def _build_review_insight_context(exams: list[ExamRecord], subject_name: str) -> str:
entries: list[tuple[ExamRecord, SubjectScore, list[ReviewStatusEnum]]] = []
status_counts = {status: 0 for status in ReviewStatusEnum}
for exam in exams:
for score in exam.scores:
if _subject_display_name(score) != subject_name:
continue
statuses = _parse_review_statuses(score.review_statuses_json)
if not statuses:
continue
type_label = _EXAM_TYPE_LABELS.get(exam.exam_type.value, exam.exam_type.value)
status_text = "".join(_REVIEW_STATUS_LABELS.get(s, s.value) for s in statuses)
ratio = float(score.ratio) * 100
line = (
f"- {exam.exam_date} {type_label} "
f"得分{float(score.obtained_score):g}/{float(score.total_score):g}({ratio:.1f}%) "
f"状态:{status_text}"
)
if exam.title:
line += f" 备注:{exam.title}"
lines.append(line)
entries.append((exam, score, statuses))
for status in statuses:
status_counts[status] += 1
if not entries:
return ""
entries.sort(key=lambda item: (item[0].exam_date, item[0].created_at))
total = len(entries)
summary_parts = [
f"{_REVIEW_STATUS_LABELS[s]}{status_counts[s]}"
for s in ReviewStatusEnum
if status_counts[s] > 0
]
lines = [
f"科目:{subject_name}",
f"{total} 次有复盘记录的考试",
f"状态统计:{''.join(summary_parts)}",
"",
"【按考试日期从早到晚,请严格按此顺序解读,不得颠倒】",
]
for index, (exam, score, statuses) in enumerate(entries, start=1):
type_label = _EXAM_TYPE_LABELS.get(exam.exam_type.value, exam.exam_type.value)
status_text = "".join(_REVIEW_STATUS_LABELS.get(s, s.value) for s in statuses)
obtained = float(score.obtained_score)
total_score = float(score.total_score)
ratio = float(score.ratio) * 100
lost = total_score - obtained
when = "(最近一次)" if index == total else ""
line = (
f"{index}{when} | 日期 {exam.exam_date} | {type_label} | "
f"得分 {obtained:g}/{total_score:g}(得分率 {ratio:.1f}%,失分 {lost:g} 分)| "
f"复盘状态:{status_text}"
)
if exam.title:
line += f" | 备注:{exam.title}"
lines.append(line)
return "\n".join(lines)
@@ -298,7 +329,7 @@ async def review_insight(
.all()
)
subject_name = data.subject_name.strip()
records = _build_review_records_text(exams, subject_name)
records = _build_review_insight_context(exams, subject_name)
if not records:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
+84 -16
View File
@@ -89,29 +89,89 @@ ERROR_DETECT_PROMPT = """你是{stage}{subject}老师。以下是试卷/作业 O
若整张图就是一道错题,请标注含有错误答案或作答的行;找不到则标注最后作答行。
"""
REVIEW_INSIGHT_PROMPT = """你是一位{stage}{subject}学习顾问。根据学生历次考试的复盘状态,给出解读与可执行建议
REVIEW_INSIGHT_PROMPT = """你是一位{stage}{subject}学习顾问。请仅根据下方「复盘数据」做分析,不得编造未出现的考试或状态
【学段】{stage},科目:{subject}
【学段】{stage}
【科目】{subject}(所有建议必须贴合本科目,禁止套用其他科目的说法)
历次复盘记录(按时间从新到旧):
【复盘数据】
{review_records}
状态说明:粗心=审题/计算失误;不会=知识点未掌握;紧张=心态影响发挥;正常发挥=状态良好。
状态含义(结合本科目理解)】
- 粗心:{careless_hint}
- 不会:该科知识点/题型尚未掌握
- 紧张:心态影响,发挥低于平时水平
- 正常发挥:状态稳定
请用 Markdown 输出,结构如下:
【科目建议方向】
{subject_hints}
【必须遵守】
1. 解读时必须写清具体考试日期(如 2026-06-21),按时间从早到晚分析,不得把「第1次」说成最近一场
2. 得分率 = 得分÷总分;95% 以上才可称「接近满分」,85% 左右应如实描述为「良好但仍有失分空间」,禁止夸大
3. 改进建议必须针对 {subject},禁止出现与本科目无关的表述(如英语科禁止写「计算验算」)
4. 只分析数据中列出的复盘状态,不要臆测未勾选的原因
请用 Markdown 输出:
## 情况解读
2-4 句话:从成绩与状态看出什么规律或趋势
2-4 句:按时间顺序说明每次考试得分率、失分与复盘状态的关系,以及是否有改善或反复
## 改进建议
3-5 条具体可执行建议,针对出现最多的问题状态,适合{stage}学生
3-5 条,针对出现最多的问题状态,具体可操作,仅限 {stage}{subject} 范围
## 近期重点
1-2 条本周可落实的小目标)
语气亲切、务实,不要空泛鸡汤;不要超纲推荐学习内容
语气务实,不要空泛鸡汤。
"""
SUBJECT_REVIEW_HINTS: dict[str, dict[str, str]] = {
"语文": {
"careless": "看错题干、漏读要求、作文偏题或漏写要点",
"hints": "阅读审题、文言文/语言运用、作文结构与素材积累",
},
"数学": {
"careless": "审题不清、计算或抄错、步骤跳步",
"hints": "错题归类、计算验算、典型题型归纳与限时练习",
},
"英语": {
"careless": "看错词义/时态、漏读题干、拼写与语法笔误",
"hints": "词汇语法、阅读完形、听力与写作模板,禁止建议计算类训练",
},
"物理": {
"careless": "审题漏条件、公式代错、单位换算失误",
"hints": "概念理解、建模分析、实验题与计算规范",
},
"化学": {
"careless": "方程式配平/条件遗漏、计算失误",
"hints": "方程式、物质性质、实验与推断题",
},
"生物": {
"careless": "概念混淆、漏答得分点",
"hints": "教材概念、图表分析、实验设计表述",
},
"历史": {
"careless": "材料题漏读、时间/人物混淆",
"hints": "时间线、材料分析、论述题答题模板",
},
"地理": {
"careless": "读图漏信息、术语使用不当",
"hints": "地图判读、区域分析、综合题答题条理",
},
"政治": {
"careless": "漏答采分点、概念表述不准",
"hints": "时政结合、材料分析、观点表述规范",
},
}
def _subject_review_hints(subject: str) -> tuple[str, str]:
block = SUBJECT_REVIEW_HINTS.get(subject)
if block:
return block["careless"], block["hints"]
return "审题或作答细节失误", f"针对{subject}常见失分点制定练习与错题巩固"
class AIConfig:
def __init__(
self,
@@ -151,16 +211,21 @@ def load_ai_config(db: Session) -> AIConfig:
)
async def _ollama_generate(prompt: str, cfg: AIConfig) -> str:
async def _ollama_generate(prompt: str, cfg: AIConfig, *, temperature: float = 0.3) -> str:
url = f"{cfg.ollama_base_url.rstrip('/')}/api/generate"
payload = {"model": cfg.ollama_model, "prompt": prompt, "stream": False}
payload = {
"model": cfg.ollama_model,
"prompt": prompt,
"stream": False,
"options": {"temperature": temperature},
}
async with httpx.AsyncClient(timeout=180.0) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
return (response.json().get("response") or "").strip()
async def _openai_generate(prompt: str, cfg: AIConfig) -> str:
async def _openai_generate(prompt: str, cfg: AIConfig, *, temperature: float = 0.3) -> str:
if not cfg.openai_api_key:
raise ValueError("未配置 OpenAI API Key")
url = f"{cfg.openai_base_url.rstrip('/')}/chat/completions"
@@ -168,7 +233,7 @@ async def _openai_generate(prompt: str, cfg: AIConfig) -> str:
payload = {
"model": cfg.openai_model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3,
"temperature": temperature,
}
async with httpx.AsyncClient(timeout=180.0) as client:
response = await client.post(url, json=payload, headers=headers)
@@ -177,10 +242,10 @@ async def _openai_generate(prompt: str, cfg: AIConfig) -> str:
return (data["choices"][0]["message"]["content"] or "").strip()
async def generate_text(prompt: str, cfg: AIConfig) -> str:
async def generate_text(prompt: str, cfg: AIConfig, *, temperature: float = 0.3) -> str:
if cfg.provider == "openai":
return await _openai_generate(prompt, cfg)
return await _ollama_generate(prompt, cfg)
return await _openai_generate(prompt, cfg, temperature=temperature)
return await _ollama_generate(prompt, cfg, temperature=temperature)
async def format_question(
@@ -233,9 +298,12 @@ async def generate_review_insight(
school_level=None,
) -> str:
stage = school_level_label(school_level)
careless_hint, subject_hints = _subject_review_hints(subject)
prompt = REVIEW_INSIGHT_PROMPT.format(
stage=stage,
subject=subject,
review_records=review_records,
careless_hint=careless_hint,
subject_hints=subject_hints,
)
return await generate_text(prompt, cfg)
return await generate_text(prompt, cfg, temperature=0.2)