考试明细单行展示,选中科目后增加 AI 解读与建议。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-28 17:32:28 +08:00
parent 5f00f07dbe
commit 02e7ba055a
10 changed files with 341 additions and 125 deletions
+76
View File
@@ -12,15 +12,53 @@ from app.schemas import (
ExamOut,
ExamReviewUpdate,
ExamUpdate,
ReviewInsightRequest,
ReviewInsightResponse,
ReviewStatusEnum,
ScoreOut,
TrendResponse,
)
from app.services import llm as llm_service
from app.services.score_trend import build_trend
from app.services.student_access import get_student_for_user
router = APIRouter(tags=["exams"])
_EXAM_TYPE_LABELS = {"weekly": "周考", "monthly": "月考", "final": "期末"}
_REVIEW_STATUS_LABELS = {
ReviewStatusEnum.careless: "粗心",
ReviewStatusEnum.unknown: "不会",
ReviewStatusEnum.nervous: "紧张",
ReviewStatusEnum.normal: "正常发挥",
}
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):
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)
return "\n".join(lines)
def _parse_review_statuses(raw: str | None) -> list[ReviewStatusEnum]:
if not raw:
@@ -245,6 +283,44 @@ def update_exam_review(
return _exam_to_out(exam)
@router.post("/students/{student_id}/review-insight", response_model=ReviewInsightResponse)
async def review_insight(
student_id: uuid.UUID,
data: ReviewInsightRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
student = get_student_for_user(db, student_id, current_user.id)
exams = (
db.query(ExamRecord)
.options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject))
.filter(ExamRecord.student_id == student_id)
.all()
)
subject_name = data.subject_name.strip()
records = _build_review_records_text(exams, subject_name)
if not records:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该科目暂无复盘数据",
)
ai_cfg = llm_service.load_ai_config(db)
try:
insight = await llm_service.generate_review_insight(
ai_cfg,
subject_name,
records,
student.school_level,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI 调用失败: {exc}",
) from exc
return ReviewInsightResponse(insight=insight)
@router.delete("/exams/{exam_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_exam(
exam_id: uuid.UUID,
+8
View File
@@ -228,6 +228,14 @@ class ExamReviewUpdate(BaseModel):
reviews: list[ReviewScoreInput] = []
class ReviewInsightRequest(BaseModel):
subject_name: str = Field(..., min_length=1, max_length=32)
class ReviewInsightResponse(BaseModel):
insight: str
class ExamOut(BaseModel):
id: UUID
exam_type: ExamTypeEnum
+47 -10
View File
@@ -57,16 +57,6 @@ SOLUTION_PROMPT = """你是一位耐心的{stage}{subject}老师。请像「作
严禁使用超纲方法;若原题超纲,请给出{stage}课内可理解的解法。
"""
ERROR_DETECT_PROMPT = """你是{stage}{subject}老师。以下是试卷/作业 OCR 识别结果,每行前有编号。
请找出「学生答错的部分」:错误答案、被打叉的作答、明显不正确的计算结果等。
{numbered_lines}
只输出 JSON,不要其他文字:
{{"wrong_line_ids": [行编号整数列表]}}
若整张图就是一道错题,请标注含有错误答案或作答的行;找不到则标注最后作答行。
"""
OLYMPIAD_SOLUTION_PROMPT = """你是一位{stage}奥数教练。请像优秀辅导老师一样,先讲解题思路,再完整解答。
【奥数学段要求 — 严禁超纲】
@@ -89,6 +79,38 @@ OLYMPIAD_SOLUTION_PROMPT = """你是一位{stage}奥数教练。请像优秀辅
严禁超纲;过难题给出{stage}可接受的培优思路。
"""
ERROR_DETECT_PROMPT = """你是{stage}{subject}老师。以下是试卷/作业 OCR 识别结果,每行前有编号。
请找出「学生答错的部分」:错误答案、被打叉的作答、明显不正确的计算结果等。
{numbered_lines}
只输出 JSON,不要其他文字:
{{"wrong_line_ids": [行编号整数列表]}}
若整张图就是一道错题,请标注含有错误答案或作答的行;找不到则标注最后作答行。
"""
REVIEW_INSIGHT_PROMPT = """你是一位{stage}{subject}学习顾问。根据学生历次考试的复盘状态,给出解读与可执行建议。
【学段】{stage},科目:{subject}
历次复盘记录(按时间从新到旧):
{review_records}
状态说明:粗心=审题/计算失误;不会=知识点未掌握;紧张=心态影响发挥;正常发挥=状态良好。
请用 Markdown 输出,结构如下:
## 情况解读
(2-4 句话:从成绩与状态看出什么规律或趋势)
## 改进建议
(3-5 条具体可执行建议,针对出现最多的问题状态,适合{stage}学生)
## 近期重点
1-2 条本周可落实的小目标)
语气亲切、务实,不要空泛鸡汤;不要超纲推荐学习内容。
"""
class AIConfig:
def __init__(
@@ -202,3 +224,18 @@ async def detect_wrong_line_ids(
numbered = "\n".join(f"[{i}] {line.get('text', '')}" for i, line in enumerate(ocr_lines))
prompt = ERROR_DETECT_PROMPT.format(stage=stage, subject=subject, numbered_lines=numbered)
return await generate_text(prompt, cfg)
async def generate_review_insight(
cfg: AIConfig,
subject: str,
review_records: str,
school_level=None,
) -> str:
stage = school_level_label(school_level)
prompt = REVIEW_INSIGHT_PROMPT.format(
stage=stage,
subject=subject,
review_records=review_records,
)
return await generate_text(prompt, cfg)