From 000638718c44cb0656d1174c6e74824818655351 Mon Sep 17 00:00:00 2001 From: aerospaceboy <790286734@qq.com> Date: Sun, 18 Oct 2026 12:14:42 +0800 Subject: [PATCH 1/6] perf: aggregate batch status statistics in a single query --- backend/tests/test_optimization.py | 32 ++++++++++++++++++++++++++++++ backend/wisegrade/api.py | 21 +++++++++++++++----- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/backend/tests/test_optimization.py b/backend/tests/test_optimization.py index 75cf4e8..e6a3563 100644 --- a/backend/tests/test_optimization.py +++ b/backend/tests/test_optimization.py @@ -249,6 +249,38 @@ def test_last_review_updates_batch_and_keeps_zero(sqlite_db): assert sheet["D2"].value == 0 +@pytest.mark.parametrize("batch_count", [0, 20]) +def test_batch_list_status_statistics_use_constant_query_count(sqlite_db, batch_count): + from wisegrade.api import list_batches + db = sqlite_db + item = project(db) + expected = {} + states = ["scored", "reviewed", "needs_review", "failed", "unscorable"] + for index in range(batch_count): + 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]["criterion_id"] + state = states[index % len(states)] + db.add(CriterionScore(batch_id=batch.id, project_id=item.id, criterion_id=criterion, status=state)) + expected[batch.id] = {state: 1} + db.commit() + queries = [] + def trace(_conn, _cursor, statement, _parameters, _context, _many): + if statement.lstrip().upper().startswith("SELECT"): + queries.append(statement) + event.listen(db.get_bind(), "before_cursor_execute", trace) + try: + listed = list_batches(db) + finally: + event.remove(db.get_bind(), "before_cursor_execute", trace) + assert len(queries) <= (2 if batch_count else 1), len(queries) + assert [row["id"] for row in listed] == sorted(expected, reverse=True) + for row in listed: + assert row["status_counts"] == expected[row["id"]] + for state in ("needs_review", "failed", "unscorable"): + assert row[state] == expected[row["id"]].get(state, 0) + + def test_pdf_html_preserves_zero_and_none(): batch = SimpleNamespace(id=1, course_version_id=1, rubric_version_id=1, model_config_snapshot={}, embedding_model_name="test", embedding_model_version="test") diff --git a/backend/wisegrade/api.py b/backend/wisegrade/api.py index 7341ba2..03f0fe8 100644 --- a/backend/wisegrade/api.py +++ b/backend/wisegrade/api.py @@ -468,12 +468,15 @@ def create_batch(data: BatchInput, db: Session = Depends(get_db)): return services.create_batch(data, db) +def status_count_dict(counts: dict) -> dict: + return {"status_counts": counts, "needs_review": counts.get("needs_review", 0), + "unscorable": counts.get("unscorable", 0), "failed": counts.get("failed", 0)} + + def batch_status_counts(db, batch_id: int) -> dict: rows = db.execute(select(CriterionScore.status, func.count(CriterionScore.id)) .where(CriterionScore.batch_id == batch_id).group_by(CriterionScore.status)).all() - counts = {status: count for status, count in rows} - return {"status_counts": counts, "needs_review": counts.get("needs_review", 0), - "unscorable": counts.get("unscorable", 0), "failed": counts.get("failed", 0)} + return status_count_dict({status: count for status, count in rows}) def batch_version_metadata(batch: ScoringBatch) -> dict: """Display the immutable rule/course versions, not their database IDs.""" @@ -485,12 +488,20 @@ def batch_version_metadata(batch: ScoringBatch) -> dict: @router.get("/scoring-batches") def list_batches(db: Session = Depends(get_db)): + batches = db.scalars(select(ScoringBatch).order_by(ScoringBatch.id.desc())).all() + counts_by_batch = {} + if batches: + rows = db.execute(select(CriterionScore.batch_id, CriterionScore.status, func.count(CriterionScore.id)) + .where(CriterionScore.batch_id.in_([batch.id for batch in batches])) + .group_by(CriterionScore.batch_id, CriterionScore.status)).all() + for batch_id, status, count in rows: + counts_by_batch.setdefault(batch_id, {})[status] = count return [{**{key: getattr(batch, key) for key in ("id", "course_version_id", "rubric_version_id", "status", "progress_done", "progress_total", "error", "created_at", "finished_at")}, "status_label": BATCH_STATUS_LABELS.get(batch.status, batch.status), "project_ids": batch.project_ids, "legacy_membership": batch.project_ids is None, **batch_version_metadata(batch), - **batch_status_counts(db, batch.id)} - for batch in db.scalars(select(ScoringBatch).order_by(ScoringBatch.id.desc()))] + **status_count_dict(counts_by_batch.get(batch.id, {}))} + for batch in batches] @router.get("/scoring-batches/{batch_id}/progress") From ba5a6952bbeaf2602fd0ad35702d8cda802df30e Mon Sep 17 00:00:00 2001 From: aerospaceboy <790286734@qq.com> Date: Sun, 18 Oct 2026 12:16:04 +0800 Subject: [PATCH 2/6] fix: share precise scoring failure labels across API chat and exports --- backend/tests/test_optimization.py | 24 ++++++++++++++++++++++++ backend/wisegrade/agent_tools.py | 4 ++-- backend/wisegrade/exports.py | 25 +++++++++++++++++++++---- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/backend/tests/test_optimization.py b/backend/tests/test_optimization.py index e6a3563..7af1d43 100644 --- a/backend/tests/test_optimization.py +++ b/backend/tests/test_optimization.py @@ -293,6 +293,30 @@ def test_pdf_html_preserves_zero_and_none(): assert "实验(0 / 10)" in html and "模型原始分:0" in html +@pytest.mark.parametrize("error, label", [ + ("模型证据未关联该片段的检索目标", "模型输出校验失败"), + ("模型请求或JSON校验失败(ConnectError:无法连接模型端点),已重试2次", "模型连接失败"), + ("模型请求或JSON校验失败(模型请求超时,请稍后重试),已重试2次", "模型连接失败"), + ("模型请求或JSON校验失败(HTTP 429:请求限流),已重试2次", "模型请求失败"), + ("模型请求或JSON校验失败(ValidationError:模型返回字段不符合格式),已重试2次", "模型输出校验失败"), + ("后台数据处理异常", "评分失败"), +]) +def test_score_api_and_export_share_precise_failure_labels(sqlite_db, error, label): + from wisegrade.exports import project_result + 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] + db.add(CriterionScore(batch_id=batch.id, project_id=item.id, + criterion_id=criterion["criterion_id"], status="failed", error=error)) + db.commit() + result = project_result(db, batch, item) + assert result["rows"][0]["status_label"] == label + assert f"状态:{label}" in _html_report(result, batch) + assert result["rows"][0]["model_score"] is None + + def test_public_reindex_uses_saved_pages(sqlite_db): db = sqlite_db doc = document(db, None, "课程资料") diff --git a/backend/wisegrade/agent_tools.py b/backend/wisegrade/agent_tools.py index 9475484..72c9af6 100644 --- a/backend/wisegrade/agent_tools.py +++ b/backend/wisegrade/agent_tools.py @@ -9,7 +9,7 @@ from sqlalchemy import select from .actions import prepare, require_scope from .evidence import CONTENT_ALLOWED, STRUCTURE_ALLOWED -from .exports import STATUS_LABELS, batch_projects, page_type_label, project_result +from .exports import batch_projects, page_type_label, project_result from .knowledge_pages import page_links from .models import (Conversation, Course, CourseVersion, IndexTask, KnowledgeChunk, KnowledgeDocument, KnowledgePage, Project, RubricVersion, ScoringBatch, Student, SubmissionFile) @@ -384,7 +384,7 @@ def scores(db, convo, args, _user): "max_score": row["criterion"]["max_score"], "score_id": row["score_id"], "model_score": row["model_score"], "teacher_score": row["teacher_score"], "final_score": row["final_score"], "status": row["status"], - "status_label": STATUS_LABELS.get(row["status"], row["status"]), + "status_label": row["status_label"], "error": str(row["error"] or "")[:300], # Which body targets still lack evidence, so the answer can # explain a review item instead of reporting a score. diff --git a/backend/wisegrade/exports.py b/backend/wisegrade/exports.py index ba2d106..767e985 100644 --- a/backend/wisegrade/exports.py +++ b/backend/wisegrade/exports.py @@ -1,5 +1,6 @@ from html import escape from pathlib import Path +import re from openpyxl import Workbook from openpyxl.styles import Font, PatternFill @@ -18,7 +19,7 @@ STATUS_LABELS = { "reviewed": "教师已复核", "needs_review": "待教师复核", "unscorable": "材料缺失或解析失败", - "failed": "模型调用失败", + "failed": "评分失败", } PAGE_TYPE_LABELS = { "cover": "封面", "abstract": "摘要", "toc": "目录", "body": "正文", @@ -38,6 +39,21 @@ def page_type_label(value) -> str: return PAGE_TYPE_LABELS.get(str(value or "unknown"), "未分类页面") +def criterion_status_label(status: str, error: str = "") -> str: + """One description for API, chat and exported feedback, including old errors.""" + if status != "failed": + return STATUS_LABELS.get(status, status) + # The client wraps transport failures in "模型请求或JSON校验失败" too: + # inspect the concrete cause before that generic wrapper. + if re.search(r"ConnectError|Timeout|Network|无法连接|请求超时", error, re.IGNORECASE): + return "模型连接失败" + if re.search(r"HTTP\s*\d{3}|未配置.*(?:模型|Key)|未配置或无法连接", error, re.IGNORECASE): + return "模型请求失败" + if re.search(r"模型证据|target_id|评分项或满分|JSON校验|ValidationError", error): + return "模型输出校验失败" + return STATUS_LABELS["failed"] + + def project_result(db, batch: ScoringBatch, project: Project): scores = db.scalars(select(CriterionScore).where(CriterionScore.batch_id == batch.id, CriterionScore.project_id == project.id)).all() @@ -81,6 +97,7 @@ def project_result(db, batch: ScoringBatch, project: Project): "model_comment": score.model_comment if score else "", "teacher_comment": score.teacher_comment if score else None, "missing_evidence": score.missing_evidence if score else [], "suggestion": score.suggestion if score else "", "status": score.status if score else "pending", "error": score.error if score else "", + "status_label": criterion_status_label(score.status, score.error or "") if score else STATUS_LABELS["pending"], "evidence_gaps": (score.evidence_gaps or []) if score else [], "evidence": [evidence_dict(item, evidence_links(db, item)) for item in evidence]}) student = db.get(Student, project.student_id) @@ -152,12 +169,12 @@ def _html_report(result, batch): missing = "、".join(row["missing_evidence"] or []) gaps = ";".join(f"{gap.get('target_label')}(检索范围:{'、'.join(page_type_label(t) for t in gap.get('searched_page_types') or []) or '正文'})" for gap in row.get("evidence_gaps") or []) - review = "本项缺少正文证据,已转教师复核。" if row["status"] == "needs_review" else "" + review = "本项证据尚不足以自动评分,已转教师复核。" if row["status"] == "needs_review" else "" rows.append(f"

{esc(criterion['name'])}({esc(row['final_score'])} / {criterion['max_score']})

" - f"

模型原始分:{esc(row['model_score'])};教师修改分:{esc(row['teacher_score'])};状态:{esc(STATUS_LABELS.get(row['status'], row['status']))}

" + f"

模型原始分:{esc(row['model_score'])};教师修改分:{esc(row['teacher_score'])};状态:{esc(criterion_status_label(row['status'], row.get('error') or ''))}

" f"

模型评语:{esc(row['model_comment'])}

教师评语:{esc(row['teacher_comment'])}

" f"

{esc(review)}

" - f"

缺失证据:{esc(missing)}

缺正文证据的目标:{esc(gaps)}

" + f"

缺失证据:{esc(missing)}

待核对的证据目标:{esc(gaps)}

" f"

建议:{esc(row['suggestion'])}

异常:{esc(row['error'])}

" f"
") totals_ = result["totals"] From 4fec28ce8da110e92ae9275447f1fec798f321d2 Mon Sep 17 00:00:00 2001 From: aerospaceboy <790286734@qq.com> Date: Sun, 18 Oct 2026 12:17:11 +0800 Subject: [PATCH 3/6] fix: display canonical API failure labels in the grading page --- frontend/src/App.tsx | 1 + frontend/tests/batches.spec.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8cbb27a..8b358dc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -34,6 +34,7 @@ const pageTypeLabel = (value: string) => pageTypeLabels[value] || '未分类页 const statusLabels: Record = { pending: '待评分', scored: '模型已评分', reviewed: '教师已复核', needs_review: '待教师复核', unscorable: '材料缺失或解析失败', failed: '评分失败' }; const statusLabel = (value: string) => statusLabels[value] || value; const scoreStatusLabel = (row: any) => { + if (row.status_label) return row.status_label; if (row.status !== 'failed') return statusLabel(row.status); if (/模型证据|target_id|评分项或满分|JSON校验/.test(row.error || '')) return '模型输出校验失败'; if (/ConnectError|Timeout|Network|连接|超时|HTTP/i.test(row.error || '')) return '模型连接失败'; diff --git a/frontend/tests/batches.spec.ts b/frontend/tests/batches.spec.ts index 18f404a..aa0da22 100644 --- a/frontend/tests/batches.spec.ts +++ b/frontend/tests/batches.spec.ts @@ -140,12 +140,14 @@ test('failed scoring shows the network error and retry guidance on the page', as await page.route('**/api/projects/11/scores?*', route => route.fulfill({ json: { student_no: '9000000001', name: '学生甲', topic: '项目', totals: { overall: null }, facts: {}, rows: [{ criterion: { criterion_id: 'stage1.problem', category: 'stage1', name: '需求分析', max_score: 25 }, - status: 'failed', error: 'ConnectError:无法连接模型端点,请检查 Docker 网络、DNS 或代理', + status: 'failed', status_label: '模型连接失败', + error: '模型请求或JSON校验失败(ConnectError:无法连接模型端点,请检查 Docker 网络、DNS 或代理),已重试2次', evidence: [], missing_evidence: [], evidence_gaps: [] }], } })); await page.goto('/?page=scores&batch_id=71&project_id=11'); await expect(page.getByText('当前项目有 1 项评分失败', { exact: true })).toBeVisible(); await expect(page.getByText(/ConnectError:无法连接模型端点/)).toBeVisible(); + await expect(page.getByText('模型连接失败', { exact: true })).toBeVisible(); await expect(page.getByText(/已经成功的评分和教师复核会保留/)).toBeVisible(); }); From aa825231ba9de65930f32733c1859b02c28eebc1 Mon Sep 17 00:00:00 2001 From: aerospaceboy <790286734@qq.com> Date: Sun, 18 Oct 2026 12:18:49 +0800 Subject: [PATCH 4/6] fix: refresh grading status after review and ignore stale student responses --- frontend/src/App.tsx | 8 +++-- frontend/tests/batches.spec.ts | 66 ++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8b358dc..11494a4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -207,6 +207,7 @@ function Projects({ openScores }: { openScores: (id: number) => void }) { function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }) { const [batches, setBatches] = useState([]), [projects, setProjects] = useState([]), [batch, setBatch] = useState(batchId), [project, setProject] = useState(projectId), [result, setResult] = useState(), [review, setReview] = useState(), [revisions, setRevisions] = useState([]); const [scoreFilter, setScoreFilter] = useState('all'); + const [refreshVersion, setRefreshVersion] = useState(0); const [form] = Form.useForm(); const [sourcesReady, setSourcesReady] = useState(false); useEffect(() => { @@ -216,12 +217,12 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number } setBatches(batchRows); setProjects(projectRows); setSourcesReady(true); }).catch(error => { if (!cancelled) fail(error); }); return () => { cancelled = true; }; - }, []); + }, [refreshVersion]); const selectedBatch = batches.find(item => item.id === batch); const scopedProjects = projectsInBatch(selectedBatch, projects); + useEffect(() => { setScoreFilter('all'); }, [batch, project]); useEffect(() => { setResult(undefined); setReview(undefined); setRevisions([]); - setScoreFilter('all'); if (!sourcesReady || !selectedBatch) return; if (!scopedProjects.some(item => item.id === project)) { setProject(scopedProjects.length === 1 ? scopedProjects[0].id : undefined); @@ -232,7 +233,7 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number } .catch(error => { if (!cancelled) fail(error); }); return () => { cancelled = true; }; }, [batch, project, batches, projects, sourcesReady]); - const refresh = () => batch && project && load(`/projects/${project}/scores?batch_id=${batch}`, setResult); + const refresh = () => setRefreshVersion(value => value + 1); const scoreRows: any[] = result?.rows || []; const count = (status: string) => scoreRows.filter(row => row.status === status).length; const unfinished = (row: any) => !['scored', 'reviewed'].includes(row.status); @@ -245,6 +246,7 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number } options={options(batches, b => `批次 #${b.id} · ${batchStatusLabel(b.status)}`)} onChange={setBatch} />