fix: revalidate review context and serialize index retry

This commit is contained in:
2026-09-26 19:50:57 +08:00
parent b800481902
commit fe851c5f91
4 changed files with 35 additions and 3 deletions
+1
View File
@@ -63,6 +63,7 @@ uv run pytest -q
- 完整 Compose 测试首次运行 **25 通过、1 失败**:真实模型证据匹配导致示例流程首轮批次 `partial`。测试改为按既有重试规则继续未完成项后,单独重跑示例流程 **1 通过**(11 分 48 秒);成功项和教师复核结果由生产评分代码保留。此次未重新运行一次完整的所有测试组合,不应把第一次完整命令写成全部通过。 - 完整 Compose 测试首次运行 **25 通过、1 失败**:真实模型证据匹配导致示例流程首轮批次 `partial`。测试改为按既有重试规则继续未完成项后,单独重跑示例流程 **1 通过**(11 分 48 秒);成功项和教师复核结果由生产评分代码保留。此次未重新运行一次完整的所有测试组合,不应把第一次完整命令写成全部通过。
- 测试 Compose 现增设独立 `test-redis`,与业务队列隔离。隔离前完整测试曾把 5 个测试索引作业送入业务 Redis;旧业务 worker 因缺少新模块执行失败,未写入业务数据库,测试作业已从业务 Redis 失败队列和作业键清理。 - 测试 Compose 现增设独立 `test-redis`,与业务队列隔离。隔离前完整测试曾把 5 个测试索引作业送入业务 Redis;旧业务 worker 因缺少新模块执行失败,未写入业务数据库,测试作业已从业务 Redis 失败队列和作业键清理。
- 当前运行中的 Compose 业务镜像仍是上一版代码,数据库仍为 `0002`;本次源码尚未重建并部署到业务容器。上述 Compose 测试使用独立 `test-db`、独立 `test-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` 并重新启动。接手后应观察是否反复超时。 - RQ worker 曾在空闲期间因 Redis 连接超时退出,日志显示正常退出码 0;已在 `compose.yaml` 设置 `restart: unless-stopped` 并重新启动。接手后应观察是否反复超时。
- 使用 `docs/` 的四份示例报告及生成的汇报 PPTX,经目录扫描、确认、课程资料入库、真实 BGE-M3/pgvector 检索和真实 DeepSeek API 完成批次 `3`:**22/22 项成功**。首轮有 1 项引文不匹配,重试后成功;保留了成功项。项目索引重建后有 316 段、平均 506 字,向量均为 1024 维;77 条评分证据均能匹配对应文档页的原文和页面图片。 - 使用 `docs/` 的四份示例报告及生成的汇报 PPTX,经目录扫描、确认、课程资料入库、真实 BGE-M3/pgvector 检索和真实 DeepSeek API 完成批次 `3`:**22/22 项成功**。首轮有 1 项引文不匹配,重试后成功;保留了成功项。项目索引重建后有 316 段、平均 506 字,向量均为 1024 维;77 条评分证据均能匹配对应文档页的原文和页面图片。
- 对批次 `3` 完成教师复核和修改历史验证;Excel 成绩表及 11 页 PDF 反馈均可下载且可解析,文件位于 `data/exports/`。批次 `2` 保留为首次真实运行的对照记录(16 项成功、6 项证据不匹配)。 - 对批次 `3` 完成教师复核和修改历史验证;Excel 成绩表及 11 页 PDF 反馈均可下载且可解析,文件位于 `data/exports/`。批次 `2` 保留为首次真实运行的对照记录(16 项成功、6 项证据不匹配)。
+11
View File
@@ -175,6 +175,17 @@ def test_fixed_batch_membership_duplicate_queue_and_review(db, monkeypatch, sess
"comment": "人工核定", "reason": "核查纸质材料"}, "给 3 分") "comment": "人工核定", "reason": "核查纸质材料"}, "给 3 分")
action_id = prepared["pending_action"]["id"] action_id = prepared["pending_action"]["id"]
assert score.teacher_score is None 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) confirm(db, conversation, action_id)
db.refresh(score) db.refresh(score)
assert score.teacher_score == 3 and score.model_score is None and score.status == "reviewed" assert score.teacher_score == 3 and score.model_score is None and score.status == "reviewed"
+18 -2
View File
@@ -101,15 +101,27 @@ def _state(db, kind, payload):
"rubric": digest({"criteria": rubric.criteria, "weights": rubric.weights}), "project_ids": projects} "rubric": digest({"criteria": rubric.criteria, "weights": rubric.weights}), "project_ids": projects}
if kind == "start_batch": if kind == "start_batch":
batch = services.found(db, ScoringBatch, payload["batch_id"]) 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, return {"status": batch.status, "project_ids": batch.project_ids,
"snapshot": digest(batch.rubric_snapshot)} "snapshot": digest(batch.rubric_snapshot)}
if kind == "review": if kind == "review":
score = services.found(db, CriterionScore, payload["score_id"]) 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() 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, return {"status": score.status, "model_score": score.model_score,
"teacher_score": score.teacher_score, "teacher_comment": score.teacher_comment, "teacher_score": score.teacher_score, "teacher_comment": score.teacher_comment,
"error": score.error, "batch_id": score.batch_id, "project_id": score.project_id, "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])} "evidence": digest([[item.id, item.page, item.quote] for item in evidence])}
if kind == "export": if kind == "export":
batch = services.found(db, ScoringBatch, payload["batch_id"]) 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"): if action.kind == "import" and action.preview.get("requires_edit"):
raise HTTPException(409, "导入候选存在歧义,请教师修正文件选择并更新预览") raise HTTPException(409, "导入候选存在歧义,请教师修正文件选择并更新预览")
expires = action.expires_at.replace(tzinfo=timezone.utc) if action.expires_at.tzinfo is None else action.expires_at 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" action.status = "stale"
db.commit() db.commit()
raise HTTPException(409, "目标或文件已变化,请重新预览") raise HTTPException(409, "目标或文件已变化,请重新预览")
+5 -1
View File
@@ -137,8 +137,11 @@ def enqueue_index_task(db, task_id: int):
def retry_index_task(db, source_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": if source.status != "failed":
db.rollback()
raise HTTPException(409, "仅失败的索引任务可重试") raise HTTPException(409, "仅失败的索引任务可重试")
existing = db.scalar(select(IndexTask).where(IndexTask.id > source.id, existing = db.scalar(select(IndexTask).where(IndexTask.id > source.id,
IndexTask.course_version_id == source.course_version_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"])) IndexTask.kind == source.kind, IndexTask.status.in_(["queued", "running", "completed"]))
.order_by(IndexTask.id.desc())) .order_by(IndexTask.id.desc()))
if existing: if existing:
db.commit()
return existing return existing
retry = IndexTask(course_version_id=source.course_version_id, submission_file_id=source.submission_file_id, retry = IndexTask(course_version_id=source.course_version_id, submission_file_id=source.submission_file_id,
kind=source.kind, status="queued") kind=source.kind, status="queued")