From 050aa4e02ac3e18b4be9b813ad0549316989a44a Mon Sep 17 00:00:00 2001 From: aerospaceboy <790286734@qq.com> Date: Sat, 17 Oct 2026 20:43:21 +0800 Subject: [PATCH] fix: preserve concurrent teacher decisions when model scoring fails --- backend/tests/test_optimization.py | 29 +++++++++++++++++++++++++++++ backend/wisegrade/scoring.py | 12 ++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_optimization.py b/backend/tests/test_optimization.py index 8471aa8..1c73346 100644 --- a/backend/tests/test_optimization.py +++ b/backend/tests/test_optimization.py @@ -373,6 +373,35 @@ def test_model_declining_score_records_original_gap(sqlite_db): assert score.evidence_gaps[0]["reason"] == "只有装置介绍" +def test_failed_model_call_cannot_overwrite_a_concurrent_teacher_review(sqlite_db, monkeypatch): + from wisegrade import scoring + db = sqlite_db + item = project(db) + created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db) + batch = db.get(ScoringBatch, created["id"]) + criterion = batch.rubric_snapshot["criteria"][0] + batch.rubric_snapshot = {**batch.rubric_snapshot, "criteria": [criterion]} + batch.status, batch.progress_total, batch.progress_done = "running", 1, 0 + score = CriterionScore(batch_id=batch.id, project_id=item.id, criterion_id=criterion["criterion_id"], status="pending") + db.add(score) + db.commit() + factory = sessionmaker(bind=db.get_bind(), expire_on_commit=False) + def teacher_then_failure(*args, **kwargs): + with factory() as teacher_db: + services.review_score(score.id, SimpleNamespace(score=0, comment="教师核定零分", reason="已核对"), teacher_db) + raise ModelError("模型响应超时") + monkeypatch.setattr(scoring, "_project_facts", lambda *args, **kwargs: {}) + monkeypatch.setattr(scoring, "_score_criterion", teacher_then_failure) + failures, reviews = scoring._run_project(batch.id, item.id, factory, lambda *args: None, FLAT, lambda *a, **k: []) + db.expire_all() + saved = db.get(CriterionScore, score.id) + assert saved.status == "reviewed" + assert saved.teacher_score == 0 and saved.teacher_comment == "教师核定零分" + assert saved.error == "" + assert failures == reviews == 0 + assert db.get(ScoringBatch, batch.id).progress_done == 1 + + def test_unrebuilt_course_document_does_not_complete_task(sqlite_db): db = sqlite_db doc = KnowledgeDocument(course_version_id=1, title="无页面资料", document_type="course", diff --git a/backend/wisegrade/scoring.py b/backend/wisegrade/scoring.py index af66298..52f3e09 100644 --- a/backend/wisegrade/scoring.py +++ b/backend/wisegrade/scoring.py @@ -387,13 +387,17 @@ def _run_project(batch_id, project_id, session_factory, client_factory, embedder except Exception as exc: db.rollback() score = db.scalar(select(CriterionScore).where(CriterionScore.batch_id == batch.id, - CriterionScore.project_id == project.id, CriterionScore.criterion_id == criterion["criterion_id"])) + CriterionScore.project_id == project.id, CriterionScore.criterion_id == criterion["criterion_id"]) + .with_for_update().execution_options(populate_existing=True)) if not score: score = CriterionScore(batch_id=batch.id, project_id=project.id, criterion_id=criterion["criterion_id"]) db.add(score) - score.status, score.error = STATUS_FAILED, str(exc)[:500] - db.commit() - failures += 1 + # A teacher may have committed a decision while the request was in + # flight. The exception path must honour it just like success does. + if score.status != STATUS_REVIEWED and score.teacher_score is None: + score.status, score.error = STATUS_FAILED, str(exc)[:500] + db.commit() + failures += 1 db.execute(update(ScoringBatch).where(ScoringBatch.id == batch_id).values(progress_done=ScoringBatch.progress_done + 1)) db.commit() return failures, reviews