考试明细单行展示,选中科目后增加 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)
|
||||
Reference in New Issue
Block a user