考试明细单行展示,选中科目后增加 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, ExamOut,
ExamReviewUpdate, ExamReviewUpdate,
ExamUpdate, ExamUpdate,
ReviewInsightRequest,
ReviewInsightResponse,
ReviewStatusEnum, ReviewStatusEnum,
ScoreOut, ScoreOut,
TrendResponse, TrendResponse,
) )
from app.services import llm as llm_service
from app.services.score_trend import build_trend from app.services.score_trend import build_trend
from app.services.student_access import get_student_for_user from app.services.student_access import get_student_for_user
router = APIRouter(tags=["exams"]) 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]: def _parse_review_statuses(raw: str | None) -> list[ReviewStatusEnum]:
if not raw: if not raw:
@@ -245,6 +283,44 @@ def update_exam_review(
return _exam_to_out(exam) 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) @router.delete("/exams/{exam_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_exam( def delete_exam(
exam_id: uuid.UUID, exam_id: uuid.UUID,
+8
View File
@@ -228,6 +228,14 @@ class ExamReviewUpdate(BaseModel):
reviews: list[ReviewScoreInput] = [] reviews: list[ReviewScoreInput] = []
class ReviewInsightRequest(BaseModel):
subject_name: str = Field(..., min_length=1, max_length=32)
class ReviewInsightResponse(BaseModel):
insight: str
class ExamOut(BaseModel): class ExamOut(BaseModel):
id: UUID id: UUID
exam_type: ExamTypeEnum exam_type: ExamTypeEnum
+47 -10
View File
@@ -57,16 +57,6 @@ SOLUTION_PROMPT = """你是一位耐心的{stage}{subject}老师。请像「作
严禁使用超纲方法;若原题超纲,请给出{stage}课内可理解的解法。 严禁使用超纲方法;若原题超纲,请给出{stage}课内可理解的解法。
""" """
ERROR_DETECT_PROMPT = """你是{stage}{subject}老师。以下是试卷/作业 OCR 识别结果,每行前有编号。
请找出「学生答错的部分」:错误答案、被打叉的作答、明显不正确的计算结果等。
{numbered_lines}
只输出 JSON,不要其他文字:
{{"wrong_line_ids": [行编号整数列表]}}
若整张图就是一道错题,请标注含有错误答案或作答的行;找不到则标注最后作答行。
"""
OLYMPIAD_SOLUTION_PROMPT = """你是一位{stage}奥数教练。请像优秀辅导老师一样,先讲解题思路,再完整解答。 OLYMPIAD_SOLUTION_PROMPT = """你是一位{stage}奥数教练。请像优秀辅导老师一样,先讲解题思路,再完整解答。
【奥数学段要求 — 严禁超纲】 【奥数学段要求 — 严禁超纲】
@@ -89,6 +79,38 @@ OLYMPIAD_SOLUTION_PROMPT = """你是一位{stage}奥数教练。请像优秀辅
严禁超纲;过难题给出{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: class AIConfig:
def __init__( 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)) 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) prompt = ERROR_DETECT_PROMPT.format(stage=stage, subject=subject, numbered_lines=numbered)
return await generate_text(prompt, cfg) 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)
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -9,7 +9,7 @@
<meta name="author" content="马建军" /> <meta name="author" content="马建军" />
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." /> <meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
<title>中学成绩档案</title> <title>中学成绩档案</title>
<script type="module" crossorigin src="/assets/index-DP70ZAE9.js"></script> <script type="module" crossorigin src="/assets/index-CmdQeYPX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css"> <link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css">
</head> </head>
<body> <body>
+4
View File
@@ -131,6 +131,10 @@ export const examApi = {
}), }),
exportCsv: (studentId: string) => exportCsv: (studentId: string) =>
api.get(`/students/${studentId}/scores/export`, { responseType: 'blob' }), api.get(`/students/${studentId}/scores/export`, { responseType: 'blob' }),
reviewInsight: (studentId: string, subjectName: string) =>
api.post<{ insight: string }>(`/students/${studentId}/review-insight`, {
subject_name: subjectName,
}),
} }
export const wrongQuestionApi = { export const wrongQuestionApi = {
+4 -1
View File
@@ -4,6 +4,7 @@ import { examApi } from '../api/client'
import type { Exam, ReviewStatus } from '../types' import type { Exam, ReviewStatus } from '../types'
import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types' import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types'
import ReviewBarChart from './ReviewBarChart' import ReviewBarChart from './ReviewBarChart'
import ReviewAiInsight from './ReviewAiInsight'
import ReviewSubjectDetail from './ReviewSubjectDetail' import ReviewSubjectDetail from './ReviewSubjectDetail'
function apiErrorMessage(err: unknown, fallback: string): string { function apiErrorMessage(err: unknown, fallback: string): string {
@@ -31,11 +32,12 @@ function firstSubjectWithReview(exams: Exam[]): string | null {
} }
interface Props { interface Props {
studentId: string
exams: Exam[] exams: Exam[]
onRefresh: () => void onRefresh: () => void
} }
export default function ExamReviewPanel({ exams, onRefresh }: Props) { export default function ExamReviewPanel({ studentId, exams, onRefresh }: Props) {
const [examId, setExamId] = useState<string>() const [examId, setExamId] = useState<string>()
const [statusMap, setStatusMap] = useState<Record<number, ReviewStatus[]>>({}) const [statusMap, setStatusMap] = useState<Record<number, ReviewStatus[]>>({})
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@@ -175,6 +177,7 @@ export default function ExamReviewPanel({ exams, onRefresh }: Props) {
onSubjectSelect={setSelectedSubject} onSubjectSelect={setSelectedSubject}
/> />
<ReviewSubjectDetail exams={exams} subjectName={selectedSubject} /> <ReviewSubjectDetail exams={exams} subjectName={selectedSubject} />
<ReviewAiInsight studentId={studentId} subjectName={selectedSubject} />
</div> </div>
</Space> </Space>
) )
@@ -0,0 +1,83 @@
import { ReloadOutlined } from '@ant-design/icons'
import { Alert, Button, Spin, Typography } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import { examApi } from '../api/client'
interface Props {
studentId: string
subjectName: string | null
}
export default function ReviewAiInsight({ studentId, subjectName }: Props) {
const [insight, setInsight] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadInsight = useCallback(async () => {
if (!subjectName) {
setInsight(null)
setError(null)
return
}
setLoading(true)
setError(null)
try {
const { data } = await examApi.reviewInsight(studentId, subjectName)
setInsight(data.insight)
} catch (err: unknown) {
const detail =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
: null
setError(typeof detail === 'string' ? detail : 'AI 解读生成失败,请检查模型配置')
setInsight(null)
} finally {
setLoading(false)
}
}, [studentId, subjectName])
useEffect(() => {
loadInsight()
}, [loadInsight])
if (!subjectName) return null
return (
<div style={{ marginTop: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<Typography.Text strong style={{ fontSize: 15 }}>
AI · {subjectName}
</Typography.Text>
<Button
size="small"
icon={<ReloadOutlined />}
loading={loading}
onClick={loadInsight}
>
</Button>
</div>
{loading && !insight && (
<div style={{ textAlign: 'center', padding: 24 }}>
<Spin tip="AI 正在分析复盘数据…" />
</div>
)}
{error && (
<Alert type="error" message={error} showIcon style={{ marginBottom: 12 }} />
)}
{insight && (
<div
style={{
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 8,
padding: 16,
}}
>
<ReactMarkdown>{insight}</ReactMarkdown>
</div>
)}
</div>
)
}
+38 -33
View File
@@ -80,50 +80,55 @@ export default function ReviewSubjectDetail({ exams, subjectName }: Props) {
<Typography.Text type="secondary" style={{ marginLeft: 12, fontSize: 12 }}> <Typography.Text type="secondary" style={{ marginLeft: 12, fontSize: 12 }}>
{rows.length} {problemCount} {rows.length} {problemCount}
</Typography.Text> </Typography.Text>
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
{rows.map((row) => ( {rows.map((row) => (
<div <div
key={row.key} key={row.key}
style={{ style={{
padding: '12px 14px', display: 'flex',
alignItems: 'center',
gap: 10,
flexWrap: 'nowrap',
overflowX: 'auto',
whiteSpace: 'nowrap',
padding: '10px 14px',
borderRadius: 8, borderRadius: 8,
border: `1px solid ${row.isProblem ? '#ffccc7' : '#f0f0f0'}`, border: `1px solid ${row.isProblem ? '#ffccc7' : '#f0f0f0'}`,
background: row.isProblem ? '#fff2f0' : '#fafafa', background: row.isProblem ? '#fff2f0' : '#fafafa',
}} }}
> >
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}> <Typography.Text strong style={{ color: row.isProblem ? '#cf1322' : undefined }}>
<Typography.Text strong style={{ color: row.isProblem ? '#cf1322' : undefined }}> {row.examDate}
{row.examDate} </Typography.Text>
<Typography.Text style={{ color: row.isProblem ? '#cf1322' : '#666' }}>
{row.examType}
</Typography.Text>
{row.title && (
<Typography.Text style={{ color: row.isProblem ? '#cf1322' : '#888' }}>
{row.title}
</Typography.Text> </Typography.Text>
<Typography.Text style={{ color: row.isProblem ? '#cf1322' : '#666' }}> )}
{row.examType} <Typography.Text style={{ color: row.isProblem ? '#cf1322' : undefined }}>
</Typography.Text> {row.scoreText}{row.ratioText}
{row.title && ( </Typography.Text>
<Typography.Text style={{ color: row.isProblem ? '#cf1322' : '#888' }}> <Typography.Text
{row.title} type={row.isProblem ? 'danger' : 'secondary'}
</Typography.Text> style={{ fontSize: 13 }}
)} >
<Typography.Text style={{ color: row.isProblem ? '#cf1322' : undefined }}>
{row.scoreText}{row.ratioText} </Typography.Text>
</Typography.Text> {row.statuses.map((status) => (
</div> <Tag
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}> key={status}
<Typography.Text color={STATUS_TAG_COLOR[status]}
type={row.isProblem ? 'danger' : 'secondary'} style={{
style={{ fontSize: 13 }} marginInlineEnd: 0,
...(row.isProblem && status !== 'normal' ? { fontWeight: 600 } : {}),
}}
> >
{REVIEW_STATUS_LABELS[status]}
</Typography.Text> </Tag>
{row.statuses.map((status) => ( ))}
<Tag
key={status}
color={STATUS_TAG_COLOR[status]}
style={row.isProblem && status !== 'normal' ? { fontWeight: 600 } : undefined}
>
{REVIEW_STATUS_LABELS[status]}
</Tag>
))}
</div>
</div> </div>
))} ))}
</div> </div>
+1 -1
View File
@@ -196,7 +196,7 @@ export default function StudentDetailPage() {
{ {
key: 'review', key: 'review',
label: '成绩复盘', label: '成绩复盘',
children: <ExamReviewPanel exams={exams} onRefresh={loadExams} />, children: <ExamReviewPanel studentId={id!} exams={exams} onRefresh={loadExams} />,
}, },
{ {
key: 'wrong', key: 'wrong',