考试明细单行展示,选中科目后增加 AI 解读与建议。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
+79
-79
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -9,7 +9,7 @@
|
||||
<meta name="author" content="马建军" />
|
||||
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -131,6 +131,10 @@ export const examApi = {
|
||||
}),
|
||||
exportCsv: (studentId: string) =>
|
||||
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 = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { examApi } from '../api/client'
|
||||
import type { Exam, ReviewStatus } from '../types'
|
||||
import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types'
|
||||
import ReviewBarChart from './ReviewBarChart'
|
||||
import ReviewAiInsight from './ReviewAiInsight'
|
||||
import ReviewSubjectDetail from './ReviewSubjectDetail'
|
||||
|
||||
function apiErrorMessage(err: unknown, fallback: string): string {
|
||||
@@ -31,11 +32,12 @@ function firstSubjectWithReview(exams: Exam[]): string | null {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
studentId: string
|
||||
exams: Exam[]
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export default function ExamReviewPanel({ exams, onRefresh }: Props) {
|
||||
export default function ExamReviewPanel({ studentId, exams, onRefresh }: Props) {
|
||||
const [examId, setExamId] = useState<string>()
|
||||
const [statusMap, setStatusMap] = useState<Record<number, ReviewStatus[]>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -175,6 +177,7 @@ export default function ExamReviewPanel({ exams, onRefresh }: Props) {
|
||||
onSubjectSelect={setSelectedSubject}
|
||||
/>
|
||||
<ReviewSubjectDetail exams={exams} subjectName={selectedSubject} />
|
||||
<ReviewAiInsight studentId={studentId} subjectName={selectedSubject} />
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -80,18 +80,23 @@ export default function ReviewSubjectDetail({ exams, subjectName }: Props) {
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 12, fontSize: 12 }}>
|
||||
共 {rows.length} 次,其中 {problemCount} 次存在问题
|
||||
</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) => (
|
||||
<div
|
||||
key={row.key}
|
||||
style={{
|
||||
padding: '12px 14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
flexWrap: 'nowrap',
|
||||
overflowX: 'auto',
|
||||
whiteSpace: 'nowrap',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${row.isProblem ? '#ffccc7' : '#f0f0f0'}`,
|
||||
background: row.isProblem ? '#fff2f0' : '#fafafa',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
<Typography.Text strong style={{ color: row.isProblem ? '#cf1322' : undefined }}>
|
||||
{row.examDate}
|
||||
</Typography.Text>
|
||||
@@ -106,25 +111,25 @@ export default function ReviewSubjectDetail({ exams, subjectName }: Props) {
|
||||
<Typography.Text style={{ color: row.isProblem ? '#cf1322' : undefined }}>
|
||||
得分 {row.scoreText}({row.ratioText})
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
||||
<Typography.Text
|
||||
type={row.isProblem ? 'danger' : 'secondary'}
|
||||
style={{ fontSize: 13 }}
|
||||
>
|
||||
状态:
|
||||
状态
|
||||
</Typography.Text>
|
||||
{row.statuses.map((status) => (
|
||||
<Tag
|
||||
key={status}
|
||||
color={STATUS_TAG_COLOR[status]}
|
||||
style={row.isProblem && status !== 'normal' ? { fontWeight: 600 } : undefined}
|
||||
style={{
|
||||
marginInlineEnd: 0,
|
||||
...(row.isProblem && status !== 'normal' ? { fontWeight: 600 } : {}),
|
||||
}}
|
||||
>
|
||||
{REVIEW_STATUS_LABELS[status]}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -196,7 +196,7 @@ export default function StudentDetailPage() {
|
||||
{
|
||||
key: 'review',
|
||||
label: '成绩复盘',
|
||||
children: <ExamReviewPanel exams={exams} onRefresh={loadExams} />,
|
||||
children: <ExamReviewPanel studentId={id!} exams={exams} onRefresh={loadExams} />,
|
||||
},
|
||||
{
|
||||
key: 'wrong',
|
||||
|
||||
Reference in New Issue
Block a user