From fe851c5f91dcd8d0f01a83510c66505a2d850e03 Mon Sep 17 00:00:00 2001 From: SkyLi Date: Sat, 26 Sep 2026 19:50:57 +0800 Subject: [PATCH] fix: revalidate review context and serialize index retry --- HANDOFF.md | 1 + backend/tests/test_chat_agent.py | 11 +++++++++++ backend/wisegrade/actions.py | 20 ++++++++++++++++++-- backend/wisegrade/services.py | 6 +++++- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 671760f..c5dacd8 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -63,6 +63,7 @@ uv run pytest -q - 完整 Compose 测试首次运行 **25 通过、1 失败**:真实模型证据匹配导致示例流程首轮批次 `partial`。测试改为按既有重试规则继续未完成项后,单独重跑示例流程 **1 通过**(11 分 48 秒);成功项和教师复核结果由生产评分代码保留。此次未重新运行一次完整的所有测试组合,不应把第一次完整命令写成全部通过。 - 测试 Compose 现增设独立 `test-redis`,与业务队列隔离。隔离前完整测试曾把 5 个测试索引作业送入业务 Redis;旧业务 worker 因缺少新模块执行失败,未写入业务数据库,测试作业已从业务 Redis 失败队列和作业键清理。 - 当前运行中的 Compose 业务镜像仍是上一版代码,数据库仍为 `0002`;本次源码尚未重建并部署到业务容器。上述 Compose 测试使用独立 `test-db`、独立 `test-redis` 和只读源码挂载。 +- `docker compose --profile test run --build --rm test pytest -q -m 'not integration'` 曾启动镜像重建,但 Debian 软件源下载 LibreOffice 依赖在约 24 分钟后仍未完成,故中止;随后用已有测试镜像加最新源码只读挂载执行相同测试。尚未在浏览器中实际点击新对话主页、刷新恢复、确认卡片及深链接;目前这些路径由代码检查、后端 API 测试和前端 TypeScript/生产构建覆盖。重新部署业务容器及浏览器实机验收仍需完成。 - RQ worker 曾在空闲期间因 Redis 连接超时退出,日志显示正常退出码 0;已在 `compose.yaml` 设置 `restart: unless-stopped` 并重新启动。接手后应观察是否反复超时。 - 使用 `docs/` 的四份示例报告及生成的汇报 PPTX,经目录扫描、确认、课程资料入库、真实 BGE-M3/pgvector 检索和真实 DeepSeek API 完成批次 `3`:**22/22 项成功**。首轮有 1 项引文不匹配,重试后成功;保留了成功项。项目索引重建后有 316 段、平均 506 字,向量均为 1024 维;77 条评分证据均能匹配对应文档页的原文和页面图片。 - 对批次 `3` 完成教师复核和修改历史验证;Excel 成绩表及 11 页 PDF 反馈均可下载且可解析,文件位于 `data/exports/`。批次 `2` 保留为首次真实运行的对照记录(16 项成功、6 项证据不匹配)。 diff --git a/backend/tests/test_chat_agent.py b/backend/tests/test_chat_agent.py index d4e0db5..de24753 100644 --- a/backend/tests/test_chat_agent.py +++ b/backend/tests/test_chat_agent.py @@ -175,6 +175,17 @@ def test_fixed_batch_membership_duplicate_queue_and_review(db, monkeypatch, sess "comment": "人工核定", "reason": "核查纸质材料"}, "给 3 分") action_id = prepared["pending_action"]["id"] assert score.teacher_score is None + student = db.get(Student, first.student_id) + student.name = "身份变化" + db.commit() + with pytest.raises(HTTPException): + confirm(db, conversation, action_id) + assert score.teacher_score is None + student.name = "学生4" + db.commit() + prepared = execute(db, conversation, "prepare_review", {"score_id": score.id, "score": 3, + "comment": "人工核定", "reason": "核查纸质材料"}, "给 3 分") + action_id = prepared["pending_action"]["id"] confirm(db, conversation, action_id) db.refresh(score) assert score.teacher_score == 3 and score.model_score is None and score.status == "reviewed" diff --git a/backend/wisegrade/actions.py b/backend/wisegrade/actions.py index 9a799f0..fcdf193 100644 --- a/backend/wisegrade/actions.py +++ b/backend/wisegrade/actions.py @@ -101,15 +101,27 @@ def _state(db, kind, payload): "rubric": digest({"criteria": rubric.criteria, "weights": rubric.weights}), "project_ids": projects} if kind == "start_batch": batch = services.found(db, ScoringBatch, payload["batch_id"]) + version = services.found(db, CourseVersion, batch.course_version_id) + rubric = services.found(db, RubricVersion, batch.rubric_version_id) + if not version.published or not rubric.published or rubric.course_version_id != version.id: + raise HTTPException(409, "课程或规则发布状态已变化,请重新预览") return {"status": batch.status, "project_ids": batch.project_ids, "snapshot": digest(batch.rubric_snapshot)} if kind == "review": score = services.found(db, CriterionScore, payload["score_id"]) + batch = services.found(db, ScoringBatch, score.batch_id) + project = services.found(db, Project, score.project_id) + if project.course_version_id != batch.course_version_id or batch.project_ids is not None and project.id not in batch.project_ids: + raise HTTPException(409, "评分项与批次项目关系已变化,请重新预览") + if not any(item["criterion_id"] == score.criterion_id for item in batch.rubric_snapshot["criteria"]): + raise HTTPException(409, "评分项不属于批次规则快照") + student = services.found(db, Student, project.student_id) evidence = db.scalars(select(ScoreEvidence).where(ScoreEvidence.score_id == score.id).order_by(ScoreEvidence.id)).all() return {"status": score.status, "model_score": score.model_score, "teacher_score": score.teacher_score, "teacher_comment": score.teacher_comment, "error": score.error, "batch_id": score.batch_id, "project_id": score.project_id, - "criterion_id": score.criterion_id, + "student_id": student.id, "student_no": student.student_no, "student_name": student.name, + "criterion_id": score.criterion_id, "rubric_snapshot": digest(batch.rubric_snapshot), "evidence": digest([[item.id, item.page, item.quote] for item in evidence])} if kind == "export": batch = services.found(db, ScoringBatch, payload["batch_id"]) @@ -235,7 +247,11 @@ def confirm(db, conversation: Conversation, action_id: int): if action.kind == "import" and action.preview.get("requires_edit"): raise HTTPException(409, "导入候选存在歧义,请教师修正文件选择并更新预览") expires = action.expires_at.replace(tzinfo=timezone.utc) if action.expires_at.tzinfo is None else action.expires_at - if expires <= now() or _state(db, action.kind, action.payload) != action.expected: + try: + current = _state(db, action.kind, action.payload) if expires > now() else None + except (HTTPException, ValueError): + current = None + if current != action.expected: action.status = "stale" db.commit() raise HTTPException(409, "目标或文件已变化,请重新预览") diff --git a/backend/wisegrade/services.py b/backend/wisegrade/services.py index 3fec236..70d5c6e 100644 --- a/backend/wisegrade/services.py +++ b/backend/wisegrade/services.py @@ -137,8 +137,11 @@ def enqueue_index_task(db, task_id: int): def retry_index_task(db, source_id: int): - source = found(db, IndexTask, source_id) + source = db.scalar(select(IndexTask).where(IndexTask.id == source_id).with_for_update()) + if not source: + raise HTTPException(404, "索引任务不存在") if source.status != "failed": + db.rollback() raise HTTPException(409, "仅失败的索引任务可重试") existing = db.scalar(select(IndexTask).where(IndexTask.id > source.id, IndexTask.course_version_id == source.course_version_id, @@ -146,6 +149,7 @@ def retry_index_task(db, source_id: int): IndexTask.kind == source.kind, IndexTask.status.in_(["queued", "running", "completed"])) .order_by(IndexTask.id.desc())) if existing: + db.commit() return existing retry = IndexTask(course_version_id=source.course_version_id, submission_file_id=source.submission_file_id, kind=source.kind, status="queued")