diff --git a/backend/tests/test_chat_agent.py b/backend/tests/test_chat_agent.py index 36666ea..4724ba9 100644 --- a/backend/tests/test_chat_agent.py +++ b/backend/tests/test_chat_agent.py @@ -93,6 +93,7 @@ def test_get_scores_pages_projects_and_criteria(db, monkeypatch): assert first_page["count"] == 2 and len(first_page["results"]) == 1 assert first_page["results"][0]["project_id"] == first.id assert len(first_page["results"][0]["rows"]) == 6 + assert first_page["results"][0]["rows"][0]["status_label"] == "模型已评分" assert first_page["next_criterion_offset"] == 6 and first_page["next_project_offset"] == 1 assert first_page["citations"][0]["url"] is None assert first_page["citations"][0]["image_url"] is None diff --git a/backend/tests/test_optimization.py b/backend/tests/test_optimization.py index 75cf4e8..7af1d43 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") @@ -261,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..10d843f 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, criterion_status_label, 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.get("status_label") or criterion_status_label(row["status"], row.get("error") or ""), "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/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") 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"] diff --git a/docs/GRADING_FEEDBACK_OPTIMIZATION_2026-10-18.md b/docs/GRADING_FEEDBACK_OPTIMIZATION_2026-10-18.md new file mode 100644 index 0000000..a5f19e6 --- /dev/null +++ b/docs/GRADING_FEEDBACK_OPTIMIZATION_2026-10-18.md @@ -0,0 +1,23 @@ +# 评分反馈与批次列表优化(2026-10-18) + +## 改动 + +- `0006387`:批次列表的状态统计改为按批次和状态一次聚合,避免每个批次额外查询。接口字段、排序和无评分时的零计数保持一致。 +- `ba5a695`:API、聊天和导出反馈共用评分状态名称。先识别具体连接/超时/HTTP错误,再识别通用JSON校验提示,避免把被统一包装的连接错误误标为输出错误;证据不足说明不再断言报告没有正文。 +- `4fec28c`:网页优先显示API给出的状态名称,并兼容缺少该字段的旧结果。 +- `aa82523`:增加“刷新评分”;保存教师复核后同步刷新批次状态与评分。刷新保留当前筛选,切换批次或学生时重置筛选;旧学生迟到的响应不会覆盖当前学生结果,教师零分正常保留。 +- `93bcd2d`:聊天工具对简化评分结果回退到共用分类函数,不要求所有结果提供新增字段。 + +分支为 `improve/grading-feedback`,基于上游合并PR #2后的 `7e2cdbd`。每项改动独立提交,单次涉及1–3个文件。 + +## 验证 + +- 独立SQLite回归:20个批次的列表查询从21次降为2次;空列表为1次。各状态计数与原结果一致。 +- 真实PostgreSQL只读测量:当前8个批次的列表查询从9次降为2次。这里记录查询次数,不将其等同于延迟提升比例。 +- Docker独立PostgreSQL/Redis环境运行完整非`integration`后端测试:154 passed,2 deselected。未运行在线模型测试,临时测试服务已停止。 +- Playwright:22 passed;TypeScript/Vite构建通过。覆盖最后一项复核保存后的批次状态更新、零分、筛选保留、旧学生迟到响应,以及被通用JSON错误包装的连接失败显示。 +- Docker API/Worker/Web更新并健康;更新前确认无排队和执行中的评分任务。 +- 实际网页:批次8为22项已评分、0项待处理,批次7为21项已评分、1项待处理;点击刷新后筛选与统计一致。无页面异常、无业务写请求、无模型调用。 +- 批次8结果仅增加展示用`status_label`字段。排除该新增字段后,更新前后规范化JSON的SHA-256一致,22项评分、评语与证据未改动。 + +本轮不重新评分、不改写原PDF、不修改历史批次结果。网页新增按钮只刷新读取数据;教师复核保存行为只在隔离测试中验证。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8cbb27a..11494a4 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 '模型连接失败'; @@ -206,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(() => { @@ -215,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); @@ -231,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); @@ -244,6 +246,7 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number } options={options(batches, b => `批次 #${b.id} · ${batchStatusLabel(b.status)}`)} onChange={setBatch} />