diff --git a/backend/app/routers/exams.py b/backend/app/routers/exams.py index b6310bb..972ad96 100644 --- a/backend/app/routers/exams.py +++ b/backend/app/routers/exams.py @@ -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, diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py index 080d609..76a6456 100644 --- a/backend/app/services/llm.py +++ b/backend/app/services/llm.py @@ -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) \ No newline at end of file + return await generate_text(prompt, cfg, temperature=0.2) \ No newline at end of file