优化批次统计查询并统一评分反馈与复核刷新 #3

Merged
SkyLiCoder merged 6 commits from aerospaceboy/WiseGrade:improve/grading-feedback into main 2026-10-18 15:30:39 +08:00
8 changed files with 194 additions and 15 deletions
+1
View File
@@ -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["count"] == 2 and len(first_page["results"]) == 1
assert first_page["results"][0]["project_id"] == first.id assert first_page["results"][0]["project_id"] == first.id
assert len(first_page["results"][0]["rows"]) == 6 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["next_criterion_offset"] == 6 and first_page["next_project_offset"] == 1
assert first_page["citations"][0]["url"] is None assert first_page["citations"][0]["url"] is None
assert first_page["citations"][0]["image_url"] is None assert first_page["citations"][0]["image_url"] is None
+56
View File
@@ -249,6 +249,38 @@ def test_last_review_updates_batch_and_keeps_zero(sqlite_db):
assert sheet["D2"].value == 0 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(): def test_pdf_html_preserves_zero_and_none():
batch = SimpleNamespace(id=1, course_version_id=1, rubric_version_id=1, model_config_snapshot={}, batch = SimpleNamespace(id=1, course_version_id=1, rubric_version_id=1, model_config_snapshot={},
embedding_model_name="test", embedding_model_version="test") 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 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): def test_public_reindex_uses_saved_pages(sqlite_db):
db = sqlite_db db = sqlite_db
doc = document(db, None, "课程资料") doc = document(db, None, "课程资料")
+2 -2
View File
@@ -9,7 +9,7 @@ from sqlalchemy import select
from .actions import prepare, require_scope from .actions import prepare, require_scope
from .evidence import CONTENT_ALLOWED, STRUCTURE_ALLOWED 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 .knowledge_pages import page_links
from .models import (Conversation, Course, CourseVersion, IndexTask, KnowledgeChunk, KnowledgeDocument, KnowledgePage, from .models import (Conversation, Course, CourseVersion, IndexTask, KnowledgeChunk, KnowledgeDocument, KnowledgePage,
Project, RubricVersion, ScoringBatch, Student, SubmissionFile) 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"], "max_score": row["criterion"]["max_score"], "score_id": row["score_id"],
"model_score": row["model_score"], "teacher_score": row["teacher_score"], "model_score": row["model_score"], "teacher_score": row["teacher_score"],
"final_score": row["final_score"], "status": row["status"], "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], "error": str(row["error"] or "")[:300],
# Which body targets still lack evidence, so the answer can # Which body targets still lack evidence, so the answer can
# explain a review item instead of reporting a score. # explain a review item instead of reporting a score.
+16 -5
View File
@@ -468,12 +468,15 @@ def create_batch(data: BatchInput, db: Session = Depends(get_db)):
return services.create_batch(data, 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: def batch_status_counts(db, batch_id: int) -> dict:
rows = db.execute(select(CriterionScore.status, func.count(CriterionScore.id)) rows = db.execute(select(CriterionScore.status, func.count(CriterionScore.id))
.where(CriterionScore.batch_id == batch_id).group_by(CriterionScore.status)).all() .where(CriterionScore.batch_id == batch_id).group_by(CriterionScore.status)).all()
counts = {status: count for status, count in rows} return status_count_dict({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)}
def batch_version_metadata(batch: ScoringBatch) -> dict: def batch_version_metadata(batch: ScoringBatch) -> dict:
"""Display the immutable rule/course versions, not their database IDs.""" """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") @router.get("/scoring-batches")
def list_batches(db: Session = Depends(get_db)): 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")}, 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), "status_label": BATCH_STATUS_LABELS.get(batch.status, batch.status),
"project_ids": batch.project_ids, "legacy_membership": batch.project_ids is None, "project_ids": batch.project_ids, "legacy_membership": batch.project_ids is None,
**batch_version_metadata(batch), **batch_version_metadata(batch),
**batch_status_counts(db, batch.id)} **status_count_dict(counts_by_batch.get(batch.id, {}))}
for batch in db.scalars(select(ScoringBatch).order_by(ScoringBatch.id.desc()))] for batch in batches]
@router.get("/scoring-batches/{batch_id}/progress") @router.get("/scoring-batches/{batch_id}/progress")
+21 -4
View File
@@ -1,5 +1,6 @@
from html import escape from html import escape
from pathlib import Path from pathlib import Path
import re
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill from openpyxl.styles import Font, PatternFill
@@ -18,7 +19,7 @@ STATUS_LABELS = {
"reviewed": "教师已复核", "reviewed": "教师已复核",
"needs_review": "待教师复核", "needs_review": "待教师复核",
"unscorable": "材料缺失或解析失败", "unscorable": "材料缺失或解析失败",
"failed": "模型调用失败", "failed": "评分失败",
} }
PAGE_TYPE_LABELS = { PAGE_TYPE_LABELS = {
"cover": "封面", "abstract": "摘要", "toc": "目录", "body": "正文", "cover": "封面", "abstract": "摘要", "toc": "目录", "body": "正文",
@@ -38,6 +39,21 @@ def page_type_label(value) -> str:
return PAGE_TYPE_LABELS.get(str(value or "unknown"), "未分类页面") 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): def project_result(db, batch: ScoringBatch, project: Project):
scores = db.scalars(select(CriterionScore).where(CriterionScore.batch_id == batch.id, scores = db.scalars(select(CriterionScore).where(CriterionScore.batch_id == batch.id,
CriterionScore.project_id == project.id)).all() 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, "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 "", "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": 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_gaps": (score.evidence_gaps or []) if score else [],
"evidence": [evidence_dict(item, evidence_links(db, item)) for item in evidence]}) "evidence": [evidence_dict(item, evidence_links(db, item)) for item in evidence]})
student = db.get(Student, project.student_id) student = db.get(Student, project.student_id)
@@ -152,12 +169,12 @@ def _html_report(result, batch):
missing = "、".join(row["missing_evidence"] or []) 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 '正文'})" 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 []) 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"<section><h3>{esc(criterion['name'])}({esc(row['final_score'])} / {criterion['max_score']})</h3>" rows.append(f"<section><h3>{esc(criterion['name'])}({esc(row['final_score'])} / {criterion['max_score']})</h3>"
f"<p>模型原始分:{esc(row['model_score'])};教师修改分:{esc(row['teacher_score'])};状态:{esc(STATUS_LABELS.get(row['status'], row['status']))}</p>" f"<p>模型原始分:{esc(row['model_score'])};教师修改分:{esc(row['teacher_score'])};状态:{esc(criterion_status_label(row['status'], row.get('error') or ''))}</p>"
f"<p>模型评语:{esc(row['model_comment'])}</p><p>教师评语:{esc(row['teacher_comment'])}</p>" f"<p>模型评语:{esc(row['model_comment'])}</p><p>教师评语:{esc(row['teacher_comment'])}</p>"
f"<p>{esc(review)}</p>" f"<p>{esc(review)}</p>"
f"<p>缺失证据:{esc(missing)}</p><p>缺正文证据的目标:{esc(gaps)}</p>" f"<p>缺失证据:{esc(missing)}</p><p>待核对的证据目标:{esc(gaps)}</p>"
f"<p>建议:{esc(row['suggestion'])}</p><p>异常:{esc(row['error'])}</p>" f"<p>建议:{esc(row['suggestion'])}</p><p>异常:{esc(row['error'])}</p>"
f"<ul>{evidences}</ul></section>") f"<ul>{evidences}</ul></section>")
totals_ = result["totals"] totals_ = result["totals"]
@@ -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、不修改历史批次结果。网页新增按钮只刷新读取数据;教师复核保存行为只在隔离测试中验证。
+6 -3
View File
@@ -34,6 +34,7 @@ const pageTypeLabel = (value: string) => pageTypeLabels[value] || '未分类页
const statusLabels: Record<string, string> = { pending: '待评分', scored: '模型已评分', reviewed: '教师已复核', needs_review: '待教师复核', unscorable: '材料缺失或解析失败', failed: '评分失败' }; const statusLabels: Record<string, string> = { pending: '待评分', scored: '模型已评分', reviewed: '教师已复核', needs_review: '待教师复核', unscorable: '材料缺失或解析失败', failed: '评分失败' };
const statusLabel = (value: string) => statusLabels[value] || value; const statusLabel = (value: string) => statusLabels[value] || value;
const scoreStatusLabel = (row: any) => { const scoreStatusLabel = (row: any) => {
if (row.status_label) return row.status_label;
if (row.status !== 'failed') return statusLabel(row.status); if (row.status !== 'failed') return statusLabel(row.status);
if (/模型证据|target_id|评分项或满分|JSON校验/.test(row.error || '')) return '模型输出校验失败'; if (/模型证据|target_id|评分项或满分|JSON校验/.test(row.error || '')) return '模型输出校验失败';
if (/ConnectError|Timeout|Network|连接|超时|HTTP/i.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 }) { function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }) {
const [batches, setBatches] = useState<any[]>([]), [projects, setProjects] = useState<any[]>([]), [batch, setBatch] = useState<number | undefined>(batchId), [project, setProject] = useState<number | undefined>(projectId), [result, setResult] = useState<any>(), [review, setReview] = useState<any>(), [revisions, setRevisions] = useState<any[]>([]); const [batches, setBatches] = useState<any[]>([]), [projects, setProjects] = useState<any[]>([]), [batch, setBatch] = useState<number | undefined>(batchId), [project, setProject] = useState<number | undefined>(projectId), [result, setResult] = useState<any>(), [review, setReview] = useState<any>(), [revisions, setRevisions] = useState<any[]>([]);
const [scoreFilter, setScoreFilter] = useState('all'); const [scoreFilter, setScoreFilter] = useState('all');
const [refreshVersion, setRefreshVersion] = useState(0);
const [form] = Form.useForm(); const [form] = Form.useForm();
const [sourcesReady, setSourcesReady] = useState(false); const [sourcesReady, setSourcesReady] = useState(false);
useEffect(() => { useEffect(() => {
@@ -215,12 +217,12 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }
setBatches(batchRows); setProjects(projectRows); setSourcesReady(true); setBatches(batchRows); setProjects(projectRows); setSourcesReady(true);
}).catch(error => { if (!cancelled) fail(error); }); }).catch(error => { if (!cancelled) fail(error); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, [refreshVersion]);
const selectedBatch = batches.find(item => item.id === batch); const selectedBatch = batches.find(item => item.id === batch);
const scopedProjects = projectsInBatch(selectedBatch, projects); const scopedProjects = projectsInBatch(selectedBatch, projects);
useEffect(() => { setScoreFilter('all'); }, [batch, project]);
useEffect(() => { useEffect(() => {
setResult(undefined); setReview(undefined); setRevisions([]); setResult(undefined); setReview(undefined); setRevisions([]);
setScoreFilter('all');
if (!sourcesReady || !selectedBatch) return; if (!sourcesReady || !selectedBatch) return;
if (!scopedProjects.some(item => item.id === project)) { if (!scopedProjects.some(item => item.id === project)) {
setProject(scopedProjects.length === 1 ? scopedProjects[0].id : undefined); 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); }); .catch(error => { if (!cancelled) fail(error); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [batch, project, batches, projects, sourcesReady]); }, [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 scoreRows: any[] = result?.rows || [];
const count = (status: string) => scoreRows.filter(row => row.status === status).length; const count = (status: string) => scoreRows.filter(row => row.status === status).length;
const unfinished = (row: any) => !['scored', 'reviewed'].includes(row.status); 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} /> options={options(batches, b => `批次 #${b.id} · ${batchStatusLabel(b.status)}`)} onChange={setBatch} />
<Select aria-label="学生项目" style={{ width: 460 }} placeholder="选择该批次中的学生项目" value={project} <Select aria-label="学生项目" style={{ width: 460 }} placeholder="选择该批次中的学生项目" value={project}
disabled={!sourcesReady || !selectedBatch} options={options(scopedProjects, projectLabel)} onChange={setProject} /> disabled={!sourcesReady || !selectedBatch} options={options(scopedProjects, projectLabel)} onChange={setProject} />
<Button disabled={!sourcesReady || !selectedBatch || !project} onClick={refresh}>刷新评分</Button>
</Space> </Space>
{selectedBatch && <Alert className="top-gap" type="info" showIcon {selectedBatch && <Alert className="top-gap" type="info" showIcon
message={`批次 #${batch} 包含 ${scopedProjects.length} 个项目。下方分数仅对应当前选中的学生项目。`} />} message={`批次 #${batch} 包含 ${scopedProjects.length} 个项目。下方分数仅对应当前选中的学生项目。`} />}
+69 -1
View File
@@ -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: { await page.route('**/api/projects/11/scores?*', route => route.fulfill({ json: {
student_no: '9000000001', name: '学生甲', topic: '项目', totals: { overall: null }, facts: {}, student_no: '9000000001', name: '学生甲', topic: '项目', totals: { overall: null }, facts: {},
rows: [{ criterion: { criterion_id: 'stage1.problem', category: 'stage1', name: '需求分析', max_score: 25 }, 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: [] }], evidence: [], missing_evidence: [], evidence_gaps: [] }],
} })); } }));
await page.goto('/?page=scores&batch_id=71&project_id=11'); await page.goto('/?page=scores&batch_id=71&project_id=11');
await expect(page.getByText('当前项目有 1 项评分失败', { exact: true })).toBeVisible(); await expect(page.getByText('当前项目有 1 项评分失败', { exact: true })).toBeVisible();
await expect(page.getByText(/ConnectError:无法连接模型端点/)).toBeVisible(); await expect(page.getByText(/ConnectError:无法连接模型端点/)).toBeVisible();
await expect(page.getByText('模型连接失败', { exact: true })).toBeVisible();
await expect(page.getByText(/已经成功的评分和教师复核会保留/)).toBeVisible(); await expect(page.getByText(/已经成功的评分和教师复核会保留/)).toBeVisible();
}); });
@@ -215,6 +217,72 @@ test('score filters isolate actionable rows without hiding zero grades or changi
expect(writes).toEqual([]); expect(writes).toEqual([]);
}); });
test('saving the last review refreshes batch status and keeps the active filter', async ({ page }) => {
await setup(page);
let saved = false;
const saves: any[] = [];
await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{
id: 71, course_version_id: 1, project_ids: [11], status: saved ? 'completed' : 'review_required',
}] }));
await page.route('**/api/projects/11/scores?*', route => route.fulfill({ json: {
student_no: '9000000001', name: '学生甲', topic: '项目', totals: { overall: saved ? 0 : null }, facts: {},
rows: [{ criterion: { criterion_id: 'stage3.plan', category: 'stage3', name: '下一阶段计划', max_score: 10 },
score_id: 501, status: saved ? 'reviewed' : 'needs_review', final_score: saved ? 0 : null,
teacher_score: saved ? 0 : null, teacher_comment: saved ? '已核对' : null,
evidence: [], missing_evidence: ['计划'], evidence_gaps: [], model_comment: '需要教师核对' }],
} }));
await page.route('**/api/criterion-scores/501/review', route => {
saves.push(route.request().postDataJSON()); saved = true;
return route.fulfill({ json: { id: 501, status: 'reviewed', teacher_score: 0, final_score: 0 } });
});
await page.goto('/?page=scores&batch_id=71&project_id=11');
await choose(page, '评分项筛选', '仅待教师复核(1)');
await page.keyboard.press('Escape');
await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0);
await page.getByRole('button', { name: '查看证据/复核' }).click();
const modal = page.getByRole('dialog');
await modal.getByRole('spinbutton', { name: '教师评分' }).fill('0');
await modal.getByRole('textbox', { name: '教师评语' }).fill('已核对');
await modal.getByRole('button', { name: '保存教师复核', exact: true }).click();
await expect.poll(() => saves.length).toBe(1);
expect(saves[0].score).toBe(0);
await expect(page.getByText('批次 #71 · 已完成', { exact: true })).toBeVisible();
await expect(page.getByText('仅待教师复核(0)', { exact: true })).toBeVisible();
await expect(page.getByText('当前筛选下没有评分项', { exact: true })).toBeVisible();
await expect(page.getByText('已评分 0 项 · 教师已复核 1 项 · 待处理 0 项', { exact: true })).toBeVisible();
});
test('manual score refresh ignores late responses for a previously selected student', async ({ page }) => {
await setup(page);
let held = false, release: (() => void) | undefined;
let firstReads = 0;
await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{
id: 71, course_version_id: 1, project_ids: [11, 12], status: 'completed',
}] }));
await page.route('**/api/projects/*/scores?*', async route => {
const isFirst = route.request().url().includes('/projects/11/');
if (isFirst && ++firstReads > 1) {
held = true;
await new Promise<void>(resolve => { release = resolve; });
}
await route.fulfill({ json: {
student_no: isFirst ? '9000000001' : '9000000002', name: isFirst ? '学生甲' : '学生乙',
topic: isFirst ? '项目甲' : '项目乙', totals: {}, rows: [], facts: {},
} });
});
await page.goto('/?page=scores&batch_id=71&project_id=11');
await expect(page.getByText('9000000001 学生甲 · 项目甲', { exact: true })).toBeVisible();
await page.getByRole('button', { name: '刷新评分', exact: true }).click();
await expect.poll(() => held).toBe(true);
await choose(page, '学生项目', /学生乙/);
await page.keyboard.press('Escape');
await expect(page.getByText('9000000002 学生乙 · 项目乙', { exact: true })).toBeVisible();
const late = page.waitForResponse(response => response.url().includes('/projects/11/scores'));
release!(); await late;
await expect(page.getByText('9000000002 学生乙 · 项目乙', { exact: true })).toBeVisible();
await expect(page.getByText('9000000001 学生甲 · 项目甲', { exact: true })).toHaveCount(0);
});
test('a slow project list keeps the student specified in a multi-project batch link', async ({ page }) => { test('a slow project list keeps the student specified in a multi-project batch link', async ({ page }) => {
const state = await setup(page); const state = await setup(page);
await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{ await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{