From 0d6dc4b3428fc817410aca58926304174c8c97ed Mon Sep 17 00:00:00 2001
From: aerospaceboy <790286734@qq.com>
Date: Wed, 14 Oct 2026 14:48:16 +0800
Subject: [PATCH 01/17] fix: expose per-file cover identities in import scans
---
backend/tests/test_import_and_model_errors.py | 22 +++++++++++++++++++
backend/wisegrade/storage.py | 9 ++++++--
2 files changed, 29 insertions(+), 2 deletions(-)
diff --git a/backend/tests/test_import_and_model_errors.py b/backend/tests/test_import_and_model_errors.py
index d0b1e1a..1e63df6 100644
--- a/backend/tests/test_import_and_model_errors.py
+++ b/backend/tests/test_import_and_model_errors.py
@@ -24,6 +24,8 @@ def test_scan_selected_student_folder_prefills_cover_identity(tmp_path, monkeypa
assert project["status"] == "needs_confirmation"
assert len(project["files"]) == 5
assert project["missing"] == []
+ assert project["folder_identity"] == {"student_no": "", "name": "", "topic": ""}
+ assert all(file["detected_identity"]["student_no"] == "9000000001" for file in project["files"])
assert scan("test", root=tmp_path)["projects"] == result["projects"]
@@ -39,6 +41,26 @@ def test_scan_conflicting_covers_need_confirmation(tmp_path, monkeypatch):
assert project["student_no"] == ""
assert project["warnings"]
assert project["status"] == "needs_confirmation"
+ assert "学号" in project["warnings"][0]
+ assert "student_no" not in project["warnings"][0]
+ assert {file["detected_identity"]["student_no"] for file in project["files"]} == {"9000000001", "9000000002"}
+
+
+def test_scan_unreadable_cover_has_no_stale_identity(tmp_path, monkeypatch):
+ from wisegrade import storage
+ folder = tmp_path / "9000000001_学生甲_机器人系统"
+ folder.mkdir()
+ (folder / "阶段报告1.pdf").write_bytes(b"fixture")
+ (folder / "阶段报告2.pdf").write_bytes(b"broken")
+ def read(path):
+ if path.name.endswith("2.pdf"):
+ raise ValueError("损坏PDF")
+ return "学号:9000000001\n姓名:学生甲\n题目:机器人系统"
+ monkeypatch.setattr(storage, "first_text", read)
+ project = scan(".", root=tmp_path)["projects"][0]
+ assert project["folder_identity"]["student_no"] == "9000000001"
+ assert project["files"][0]["detected_identity"]["student_no"] == "9000000001"
+ assert project["files"][1]["detected_identity"] == {}
def test_scan_parent_skips_empty_folders(tmp_path, monkeypatch):
diff --git a/backend/wisegrade/storage.py b/backend/wisegrade/storage.py
index 2fc9611..ed92641 100644
--- a/backend/wisegrade/storage.py
+++ b/backend/wisegrade/storage.py
@@ -11,6 +11,7 @@ from .config import get_settings
CATEGORIES = ("stage1", "stage2", "stage3", "final_report", "presentation")
SUPPORTED = {".pdf", ".docx", ".pptx"}
NAMES = {"stage1": "阶段报告1", "stage2": "阶段报告2", "stage3": "阶段报告3", "final_report": "最终报告", "presentation": "汇报PPT"}
+IDENTITY_LABELS = {"student_no": "学号", "name": "姓名", "topic": "选题"}
def secure_path(relative: str, *, root: Path | None = None, must_exist=True) -> Path:
@@ -114,6 +115,7 @@ def scan(relative: str, *, root: Path | None = None):
if item.is_dir() and not item.is_symlink() and has_materials(item)]
for folder in folders:
info = parse_student_folder(folder.name)
+ folder_identity = {key: info[key] for key in IDENTITY_LABELS}
files = []
extracted = {}
identity_values = {}
@@ -124,6 +126,7 @@ def scan(relative: str, *, root: Path | None = None):
if candidate.suffix.lower() not in SUPPORTED:
continue
error = ""
+ identity = {}
try:
text = first_text(candidate)
category, status = classify(candidate, text)
@@ -137,6 +140,7 @@ def scan(relative: str, *, root: Path | None = None):
files.append({"relative_path": candidate.relative_to(base).as_posix(), "category": category, "status": status,
"size": stat.st_size, "mtime": stat.st_mtime, "sha256": sha256(candidate),
"mime_type": mimetypes.guess_type(candidate.name)[0] or "application/octet-stream", "error": error,
+ "detected_identity": identity,
"first_text": text[:500] if not error else ""})
for category in CATEGORIES:
matches = [file for file in files if file["category"] == category]
@@ -145,17 +149,18 @@ def scan(relative: str, *, root: Path | None = None):
file["status"] = "duplicate"
for key, values in identity_values.items():
if len(values) > 1:
- warnings.append(f"不同材料封面的{key}不一致,请核对后填写")
+ warnings.append(f"不同材料封面的{IDENTITY_LABELS[key]}不一致,请核对后填写")
else:
extracted[key] = next(iter(values))
for key, value in extracted.items():
if info.get(key) and value != info[key]:
- warnings.append(f"封面{key}“{value}”与目录名“{info[key]}”不一致")
+ warnings.append(f"封面{IDENTITY_LABELS[key]}“{value}”与目录名“{info[key]}”不一致")
elif not info.get(key):
info[key] = value
if warnings:
info["status"] = "needs_confirmation"
info.update({"relative_dir": folder.relative_to(base).as_posix(), "files": files,
+ "folder_identity": folder_identity,
"detected_identity": extracted, "warnings": warnings,
"missing": [category for category in CATEGORIES if not any(file["category"] == category for file in files)]})
projects.append(info)
From 1776b1b50cc0e5cc01f8d65142c5e03b65bada2b Mon Sep 17 00:00:00 2001
From: aerospaceboy <790286734@qq.com>
Date: Wed, 14 Oct 2026 14:57:02 +0800
Subject: [PATCH 02/17] fix: explain import blockers and recompute selected
identities
---
frontend/src/App.tsx | 79 ++++++++++++++++++++++++----
frontend/src/importReview.ts | 66 +++++++++++++++++++++++
frontend/tests/import.spec.ts | 98 +++++++++++++++++++++++++++++++++++
3 files changed, 233 insertions(+), 10 deletions(-)
create mode 100644 frontend/src/importReview.ts
create mode 100644 frontend/tests/import.spec.ts
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0bc2a96..aa99e50 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -5,6 +5,7 @@ import { DashboardOutlined, BookOutlined, FileSearchOutlined, FolderOpenOutlined
import { api, post, put } from './api';
import { Batches } from './Batches';
import { projectLabel, projectsInBatch } from './batchProjects';
+import { fillImportIdentity, importErrors, reviewImport } from './importReview';
const Chat = lazy(() => import('./Chat'));
const { Header, Sider, Content } = Layout;
@@ -103,14 +104,26 @@ function Import({ openImported, scoreImported }: { openImported: (ids: number[])
useEffect(() => { load('/courses', setCourses); }, []);
useEffect(() => { setScanData(undefined); setImported(undefined); load(`/storage/directories?relative=${encodeURIComponent(path)}`, setDirs); }, [path]);
useEffect(() => { if (courseId) load(`/courses/${courseId}/versions`, setVersions); }, [courseId]);
- const editProject = (index: number, key: string, value: any) => { const next = structuredClone(scanData); next.projects[index][key] = value; setScanData(next); };
- const editFile = (projectIndex: number, fileIndex: number, key: string, value: any) => { const next = structuredClone(scanData); next.projects[projectIndex].files[fileIndex][key] = value; setScanData(next); };
+ const errors = importErrors(scanData?.projects, versionId);
+ const editProject = (index: number, key: string, value: any) => {
+ const next = structuredClone(scanData), project = next.projects[index];
+ project[key] = value;
+ if (['student_no', 'name', 'topic'].includes(key)) project.identity_edits = { ...project.identity_edits, [key]: true };
+ setScanData(next);
+ };
+ const editFile = (projectIndex: number, fileIndex: number, key: string, value: any) => {
+ const next = structuredClone(scanData), project = next.projects[projectIndex];
+ project.files[fileIndex][key] = value; fillImportIdentity(project); setScanData(next);
+ };
+ const useCover = (index: number) => {
+ const next = structuredClone(scanData); fillImportIdentity(next.projects[index], true); setScanData(next);
+ };
const confirm = async () => {
- if (!versionId || !scanData?.projects?.length) return;
+ if (errors.length || importing) { message.error(errors[0] || '正在导入,请稍候'); return; }
setImporting(true);
try {
- const projects = scanData.projects.map((p: any) => ({ relative_dir: p.relative_dir, student_no: p.student_no,
- name: p.name, topic: p.topic, direction: p.direction || '',
+ const projects = scanData.projects.map((p: any) => ({ relative_dir: p.relative_dir, student_no: p.student_no.trim(),
+ name: p.name.trim(), topic: p.topic.trim(), direction: (p.direction || '').trim(),
files: p.files.map((f: any) => ({ relative_path: f.relative_path, category: f.category,
status: f.status === 'ignored' ? 'ignored' : 'recognized', sha256: f.sha256 })) }));
const result = await post('/import/confirm', { course_version_id: versionId, projects });
@@ -119,14 +132,60 @@ function Import({ openImported, scoreImported }: { openImported: (ids: number[])
} catch (error) { fail(error); }
finally { setImporting(false); }
};
- return <>
挂载目录扫描与导入确认浏览 /data/inbox 下的目录,可选择包含学生子文件夹的总目录,也可直接进入放有 PDF、DOCX、PPTX 的资料目录扫描。封面信息会预填,请核对学号、姓名和选题后确认导入。{path}{dirs.map(dir => )}
+ const projectIndex = (project: any) => scanData.projects.findIndex((item: any) => item.relative_dir === project.relative_dir);
+ const identityInput = (project: any, key: string, label: string) => editProject(projectIndex(project), key, event.target.value)} />;
+ return <>挂载目录扫描与导入确认
+ 浏览 /data/inbox 下的目录,可选择学生总目录或直接存放资料的目录。请核对封面身份;不属于当前项目的文件可设为“忽略”。
+ {path}
+
+ {dirs.map(dir => )}
{imported && imported.projectIds.length > 0 &&
} />}
- {scanData && <>{!scanData.projects.length && } editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'student_no', e.target.value)} /> }, { title: '姓名', render: (_, p, index) => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'name', e.target.value)} /> }, { title: '选题', render: (_, p, index) => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'topic', e.target.value)} /> }, { title: '选题方向', render: (_, p) => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'direction', e.target.value)} /> }, { title: '待处理', render: (_, p) => {p.status !== 'recognized' && 身份信息需确认}{p.missing.map((c: string) => 缺{categories[c]})}{p.warnings?.map((w: string) => {w})} }]}
- expandable={{ defaultExpandAllRows: true, expandedRowRender: (p: any) => { const pi = scanData.projects.findIndex((item: any) => item.relative_dir === p.relative_dir); return size="small" rowKey="relative_path" pagination={false} dataSource={p.files} columns={[{ title: '文件', dataIndex: 'relative_path' }, { title: '类型', render: (_, f, fi) => ({ value, label }))]} onChange={value => editFile(pi, fi, 'category', value)} /> }, { title: '处理', render: (_, f, fi) => editFile(pi, fi, 'status', value)} /> }, { title: '扫描结果', render: (_, f) => {f.status} }, { title: '异常', dataIndex: 'error' }]} />; } }} />>}
+ {scanData && <>{errors.length > 0 && {errors.map((error, index) => {error})}} />}
+
+ c.name)} value={courseId}
+ onChange={id => { setCourseId(id); setVersionId(undefined); setVersions([]); setImported(undefined); }} />
+ v.published), v => `v${v.version} ${v.title}`)} value={versionId} onChange={setVersionId} />
+
+
+ identityInput(project, 'student_no', '学号') },
+ { title: '姓名', render: (_, project) => identityInput(project, 'name', '姓名') },
+ { title: '选题', render: (_, project) => identityInput(project, 'topic', '选题') },
+ { title: '选题方向', render: (_, project) => editProject(projectIndex(project), 'direction', event.target.value)} /> },
+ { title: '待处理', render: (_, project) => { const review = reviewImport(project); return
+ {review.errors.length > 0 ? 请修正导入信息 : 导入信息已填写}
+ {review.missing.map(category => 缺{categories[category]})}
+ {review.warnings.map(warning => {warning})}
+ {review.metadata && Object.values(review.identity).some(Boolean) && }
+ ; } },
+ ]} expandable={{ defaultExpandAllRows: true, expandedRowRender: (project: any) => {
+ const pi = projectIndex(project); return size="small" rowKey="relative_path" pagination={false} dataSource={project.files} columns={[
+ { title: '文件', dataIndex: 'relative_path' },
+ { title: '封面身份', render: (_, file) =>
+ {file.detected_identity?.name ? `${file.detected_identity.name}(${file.detected_identity.student_no || '未识别学号'})` : '首页未识别身份'}
+ {file.detected_identity?.topic || ''} },
+ { title: '类型', render: (_, file, fi) => ({ value, label }))]} onChange={value => editFile(pi, fi, 'category', value)} /> },
+ { title: '处理', render: (_, file, fi) => editFile(pi, fi, 'status', value)} /> },
+ { title: '扫描结果', render: (_, file) =>
+ {({ recognized: '已识别', needs_confirmation: '需确认类型', duplicate: '同类多份', unparseable: '无法读取', ignored: '已忽略' } as Record)[file.status] || file.status} },
+ { title: '异常', dataIndex: 'error' },
+ ]} />;
+ } }} />
+ 封面冲突请核对上表来源。允许缺少某类材料,相关评分项会标记为待处理。人工填写的信息会保留;点击“使用所选资料的封面信息”可重新采用识别值。
+ >}
>;
}
diff --git a/frontend/src/importReview.ts b/frontend/src/importReview.ts
new file mode 100644
index 0000000..5ef134d
--- /dev/null
+++ b/frontend/src/importReview.ts
@@ -0,0 +1,66 @@
+const fields = ['student_no', 'name', 'topic'] as const;
+type IdentityField = typeof fields[number];
+const labels = { student_no: '学号', name: '姓名', topic: '选题' };
+const categories: Record = { stage1: '阶段报告1', stage2: '阶段报告2', stage3: '阶段报告3',
+ final_report: '最终报告', presentation: '汇报PPT/PDF' };
+const text = (value: unknown) => String(value ?? '').trim();
+const fileName = (file: any) => text(file.relative_path).split('/').pop() || '文件';
+
+export function reviewImport(project: any) {
+ const selected = (project.files || []).filter((file: any) => file.status !== 'ignored');
+ const metadata = (project.files || []).some((file: any) => file.detected_identity != null);
+ const identity: Record = { student_no: '', name: '', topic: '' };
+ const warnings: string[] = [];
+ for (const field of fields) {
+ const sources = new Map();
+ for (const file of selected) {
+ const value = text(file.detected_identity?.[field]);
+ if (value) sources.set(value, [...(sources.get(value) || []), fileName(file)]);
+ }
+ const folderValue = text(project.folder_identity?.[field]);
+ const values = [...sources.keys()];
+ identity[field] = folderValue || (values.length === 1 ? values[0] : '');
+ if (values.length > 1) {
+ warnings.push(`${labels[field]}冲突:${values.map(value => `${value}(${sources.get(value)!.join('、')})`).join(';')}`);
+ } else if (folderValue && values.length === 1 && folderValue !== values[0]) {
+ warnings.push(`${labels[field]}与目录不一致:封面为${values[0]}(${sources.get(values[0])!.join('、')}),目录为${folderValue}`);
+ }
+ }
+ if (!metadata) warnings.push(...(project.warnings || []));
+
+ const errors: string[] = [];
+ if (!/^[0-9]{5,20}$/.test(text(project.student_no))) errors.push('学号必须为5–20位数字');
+ if (!text(project.name)) errors.push('请填写姓名');
+ if (!text(project.topic)) errors.push('请填写选题');
+ if (!selected.length) errors.push('至少选择1份资料');
+ const counts = new Map();
+ for (const file of selected) {
+ if (!categories[file.category]) errors.push(`${fileName(file)}:请选择材料类型`);
+ else counts.set(file.category, (counts.get(file.category) || 0) + 1);
+ if (file.error) errors.push(`${fileName(file)}:无法读取,请修复文件或设为忽略`);
+ }
+ for (const [category, count] of counts) {
+ if (count > 1) errors.push(`${categories[category]}选了${count}份,请只保留1份,其余设为忽略`);
+ }
+ const missing = Object.keys(categories).filter(category => !selected.some((file: any) => file.category === category));
+ return { selected, metadata, identity, warnings, errors, missing };
+}
+
+export function fillImportIdentity(project: any, force = false) {
+ const review = reviewImport(project);
+ if (!review.metadata) return project;
+ for (const field of fields) {
+ if (force || !project.identity_edits?.[field]) project[field] = review.identity[field];
+ }
+ if (force) project.identity_edits = {};
+ return project;
+}
+
+export function importErrors(projects: any[] | undefined, versionId: number | undefined) {
+ const errors = versionId ? [] : ['请选择已发布课程版本'];
+ if (!projects?.length) return [...errors, '当前目录没有PDF、DOCX或PPTX资料,请重新扫描资料目录'];
+ for (const project of projects) {
+ errors.push(...reviewImport(project).errors.map(error => `${project.relative_dir}:${error}`));
+ }
+ return errors;
+}
diff --git a/frontend/tests/import.spec.ts b/frontend/tests/import.spec.ts
new file mode 100644
index 0000000..4b83550
--- /dev/null
+++ b/frontend/tests/import.spec.ts
@@ -0,0 +1,98 @@
+import { expect, test, type Page } from '@playwright/test';
+
+const identity = { student_no: '9000000001', name: '学生甲', topic: '机器人项目' };
+const mixed = () => ({ relative_dir: 'test/student', student_no: '', name: '', topic: '',
+ status: 'needs_confirmation', folder_identity: {}, detected_identity: {}, missing: [],
+ warnings: ['不同材料封面的学号不一致,请核对后填写'], files: [
+ { relative_path: 'test/student/最终报告.pdf', category: 'final_report', status: 'recognized',
+ sha256: 'a'.repeat(64), detected_identity: identity },
+ { relative_path: 'test/student/汇报PPT.pdf', category: 'presentation', status: 'needs_confirmation',
+ sha256: 'b'.repeat(64), detected_identity: { student_no: '9000000002', name: '学生乙', topic: '另一项目' } },
+ ] });
+
+async function setup(page: Page, project: any) {
+ const imports: any[] = [];
+ await page.route('**/api/**', async route => {
+ const request = route.request();
+ const path = new URL(request.url()).pathname.replace(/^\/api/, '');
+ let data: any = [];
+ if (path === '/courses') data = [{ id: 1, name: '课程甲' }];
+ if (path === '/courses/1/versions') data = [{ id: 1, published: true, version: 1, title: '初始版本' }];
+ if (path === '/import/scan') data = { projects: [project] };
+ if (path === '/import/confirm') { imports.push(request.postDataJSON()); data = { project_ids: [12] }; }
+ await route.fulfill({ json: data });
+ });
+ await page.goto('/?page=import');
+ await page.getByRole('button', { name: '扫描此目录', exact: true }).click();
+ await choose(page, '导入课程', '课程甲');
+ await choose(page, '导入课程版本', /初始版本/);
+ return imports;
+}
+
+async function choose(page: Page, label: string, option: string | RegExp) {
+ await page.keyboard.press('Escape');
+ await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0);
+ const input = page.getByRole('combobox', { name: label, exact: true });
+ await input.focus(); await input.press('ArrowDown');
+ const listId = await input.getAttribute('aria-controls');
+ const dropdown = page.locator('.ant-select-dropdown').filter({ has: page.locator(`[id="${listId}"]`) });
+ await expect(dropdown).toBeVisible();
+ await dropdown.getByText(option, { exact: typeof option === 'string' }).click();
+}
+
+test('invalid student number explains why confirm is disabled and recovers after editing', async ({ page }) => {
+ const project = { ...mixed(), student_no: '1', name: '1', topic: '1' };
+ delete (project as any).folder_identity;
+ for (const file of project.files) delete (file as any).detected_identity;
+ await setup(page, project);
+ await expect(page.getByText(/学号必须为5–20位数字/)).toBeVisible();
+ await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeDisabled();
+ await page.getByRole('textbox', { name: '学号 test/student', exact: true }).fill('9000000001');
+ await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeEnabled();
+});
+
+test('ignoring a foreign PPT recalculates identity and submits the intended project', async ({ page }) => {
+ const imports = await setup(page, mixed());
+ await expect(page.getByText('学生乙(9000000002)', { exact: true })).toBeVisible();
+ await expect(page.getByText(/学号冲突:/)).toBeVisible();
+ await choose(page, '处理 test/student/汇报PPT.pdf', '忽略');
+ await expect(page.getByRole('textbox', { name: '学号 test/student', exact: true })).toHaveValue(identity.student_no);
+ await expect(page.getByRole('textbox', { name: '姓名 test/student', exact: true })).toHaveValue(identity.name);
+ await expect(page.getByText(/学号冲突:/)).toHaveCount(0);
+ await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeEnabled();
+ await page.getByRole('button', { name: '确认导入', exact: true }).click();
+ await expect.poll(() => imports.length).toBe(1);
+ expect(imports[0].projects[0].student_no).toBe(identity.student_no);
+ expect(imports[0].projects[0].files[1].status).toBe('ignored');
+});
+
+test('file selection preserves manual corrections until the user chooses cover values', async ({ page }) => {
+ await setup(page, mixed());
+ const name = page.getByRole('textbox', { name: '姓名 test/student', exact: true });
+ await name.fill('人工修正姓名');
+ await choose(page, '处理 test/student/汇报PPT.pdf', '忽略');
+ await expect(name).toHaveValue('人工修正姓名');
+ await page.getByRole('button', { name: '使用所选资料的封面信息', exact: true }).click();
+ await expect(name).toHaveValue(identity.name);
+});
+
+test('ignoring all files gives an explicit selected-material error', async ({ page }) => {
+ await setup(page, mixed());
+ await choose(page, '处理 test/student/汇报PPT.pdf', '忽略');
+ await choose(page, '处理 test/student/最终报告.pdf', '忽略');
+ await expect(page.getByText(/至少选择1份资料/)).toBeVisible();
+ await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeDisabled();
+});
+
+test('unknown and duplicate material types explain the block before sending a request', async ({ page }) => {
+ const project = mixed();
+ project.files[1].category = 'unknown';
+ project.files[1].detected_identity = identity;
+ await setup(page, project);
+ await expect(page.getByText(/请选择材料类型/)).toBeVisible();
+ await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeDisabled();
+ await choose(page, '材料类型 test/student/汇报PPT.pdf', '最终报告');
+ await expect(page.getByText(/最终报告选了2份/)).toBeVisible();
+ await choose(page, '处理 test/student/汇报PPT.pdf', '忽略');
+ await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeEnabled();
+});
From 46c45baaa2c2cc638ac6fd64e3cb2e6b6c8b6714 Mon Sep 17 00:00:00 2001
From: aerospaceboy <790286734@qq.com>
Date: Wed, 14 Oct 2026 14:57:41 +0800
Subject: [PATCH 03/17] fix: validate import identities and selected materials
consistently
---
backend/tests/test_import_validation.py | 36 +++++++++++++++++++++++++
backend/wisegrade/api.py | 25 ++++++++++++++---
2 files changed, 57 insertions(+), 4 deletions(-)
create mode 100644 backend/tests/test_import_validation.py
diff --git a/backend/tests/test_import_validation.py b/backend/tests/test_import_validation.py
new file mode 100644
index 0000000..19487d9
--- /dev/null
+++ b/backend/tests/test_import_validation.py
@@ -0,0 +1,36 @@
+import pytest
+from pydantic import ValidationError
+
+from wisegrade.api import ImportConfirm, ProjectConfirm
+
+
+def project(**changes):
+ return {"relative_dir": "test/student", "student_no": "9000000001", "name": "学生甲", "topic": "机器人项目",
+ "files": [{"relative_path": "test/student/最终报告.pdf", "category": "final_report", "sha256": "a" * 64}], **changes}
+
+
+def test_confirm_normalizes_identity_before_validating():
+ result = ProjectConfirm.model_validate(project(student_no=" 9000000001 ", name=" 学生甲 ", topic=" 机器人项目 "))
+ assert (result.student_no, result.name, result.topic) == ("9000000001", "学生甲", "机器人项目")
+
+
+@pytest.mark.parametrize("changes", [
+ {"name": " "}, {"topic": " "}, {"student_no": "9000000001"}, {"files": []},
+ {"files": [{"relative_path": "test/student/a.pdf", "category": "unknown", "sha256": "a" * 64}]},
+ {"files": [{"relative_path": "test/student/a.pdf", "category": "final_report", "sha256": "a" * 64, "status": "ignored"}]},
+ {"files": [{"relative_path": f"test/student/{i}.pdf", "category": "final_report", "sha256": "a" * 64} for i in range(2)]},
+])
+def test_confirm_rejects_invalid_identity_or_selection(changes):
+ with pytest.raises(ValidationError):
+ ProjectConfirm.model_validate(project(**changes))
+
+
+def test_empty_import_is_rejected():
+ with pytest.raises(ValidationError):
+ ImportConfirm(course_version_id=1, projects=[])
+
+
+def test_ignored_unknown_material_does_not_block_valid_selection():
+ data = project()
+ data["files"].append({"relative_path": "test/student/foreign.pdf", "category": "unknown", "sha256": "b" * 64, "status": "ignored"})
+ assert len(ProjectConfirm.model_validate(data).files) == 2
diff --git a/backend/wisegrade/api.py b/backend/wisegrade/api.py
index 0c65344..0a23666 100644
--- a/backend/wisegrade/api.py
+++ b/backend/wisegrade/api.py
@@ -3,7 +3,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -67,16 +67,33 @@ class FileConfirm(BaseModel):
class ProjectConfirm(BaseModel):
relative_dir: str
- student_no: str = Field(pattern=r"^\d{5,20}$")
+ student_no: str = Field(pattern=r"^[0-9]{5,20}$")
name: str = Field(min_length=1)
topic: str = Field(min_length=1)
direction: str = ""
- files: list[FileConfirm]
+ files: list[FileConfirm] = Field(min_length=1)
+
+ @field_validator("student_no", "name", "topic", "direction", mode="before")
+ @classmethod
+ def trim_identity(cls, value):
+ return value.strip() if isinstance(value, str) else value
+
+ @model_validator(mode="after")
+ def validate_selection(self):
+ selected = [file for file in self.files if file.status != "ignored"]
+ if not selected:
+ raise ValueError("至少选择1份资料")
+ categories = [file.category for file in selected]
+ if any(category not in CATEGORIES for category in categories):
+ raise ValueError("请选择有效材料类型")
+ if len(set(categories)) != len(categories):
+ raise ValueError("每类材料只允许选择1份,其余请设为忽略")
+ return self
class ImportConfirm(BaseModel):
course_version_id: int
- projects: list[ProjectConfirm]
+ projects: list[ProjectConfirm] = Field(min_length=1)
class BatchInput(BaseModel):
From eb8c545090d5d9071cd911350921c79781af14bf Mon Sep 17 00:00:00 2001
From: aerospaceboy <790286734@qq.com>
Date: Wed, 14 Oct 2026 15:08:30 +0800
Subject: [PATCH 04/17] docs: record import confirmation fixes and validation
---
docs/IMPORT_MODEL_FIX_2026-10-14.md | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/docs/IMPORT_MODEL_FIX_2026-10-14.md b/docs/IMPORT_MODEL_FIX_2026-10-14.md
index a828d7b..fdd4b47 100644
--- a/docs/IMPORT_MODEL_FIX_2026-10-14.md
+++ b/docs/IMPORT_MODEL_FIX_2026-10-14.md
@@ -25,3 +25,15 @@
`test/示例/汇报PPT.pdf` 首页实际显示“课程导学/自动化专业综合实践”。应在评分前确认它是否属于学生汇报稿,而不是课程说明。本次只导入和索引该示例资料包,未创建或启动它的新评分批次。
运行证据保存在工作区 `.wisegrade-testing/docker-preview/`,包括 `import-model-live.json`、`import-test-sample-2026-10-14.json`、浏览器截图和构建/部署日志。实际入库与批次重试的最终状态见该目录 `import-model-final-2026-10-14.json`。
+
+## 后续:确认导入的校验与封面来源
+
+按钮被禁用的原因原来隐藏在网页布尔表达式中;扫描只返回合并后的身份,冲突时清空字段,但未提供逐文件来源,忽略文件后也不重新计算。这使用户无法从页面判断如何完成合法导入。
+
+- `d7bd152`(2 个文件):扫描返回每份文件的封面身份和原目录身份,警告使用中文字段名;读取失败的文件不沿用上一份身份。
+- `b24930b`(3 个文件):显示具体阻碍原因和逐文件身份;只根据当前选中文件重算身份和冲突,忽略异项目文件后可重新预填。保留人工修改,提供“使用所选资料的封面信息”入口。材料类别未知、同类多份或没有选中材料时直接提示。
+- `36bf473`(2 个文件):API 在写入前统一去除身份字段首尾空白,要求 5–20 位 ASCII 数字学号,拒绝空白姓名/选题、空项目、全部忽略及无效/重复材料类型。忽略的未知类型文件不阻碍合法选择。
+- 后端隔离测试 **74 passed**,网页测试 **17 passed**,TypeScript/Vite 构建通过;新增用例在修复前失败。API、网页和空闲 worker 已更新,本轮没有新增数据库迁移。
+- 实际网页无接口模拟验证:输入 `1` 显示学号规则;忽略异项目 PPT 并采用剩余报告封面信息后,身份冲突清除且确认按钮可用。没有提交真实导入或调用评分模型。空项目 API 请求返回 422。证据见工作区 `docker-preview/import-root-live.json` 和对应截图。
+
+本轮修复不放宽学号格式要求,也不把不同学生的材料自动改成同一身份。已有项目若先前被人工填写为测试身份,需要核对所选资料并重新确认导入才能更新数据库中的身份。
From 93ff9c8c8d5268151fffa7947941752c6d01a9db Mon Sep 17 00:00:00 2001
From: aerospaceboy <790286734@qq.com>
Date: Sat, 17 Oct 2026 20:15:54 +0800
Subject: [PATCH 05/17] fix: include matching body sections despite nonempty
semantic recall
---
backend/tests/test_optimization.py | 33 +++++++++++++++++++++++++++++-
backend/wisegrade/rag.py | 9 ++++++--
backend/wisegrade/scoring.py | 8 +++++++-
3 files changed, 46 insertions(+), 4 deletions(-)
diff --git a/backend/tests/test_optimization.py b/backend/tests/test_optimization.py
index 95a58d8..b20d8b9 100644
--- a/backend/tests/test_optimization.py
+++ b/backend/tests/test_optimization.py
@@ -26,7 +26,7 @@ from wisegrade.pages import classify_document
from wisegrade.parsing import text_quality
from wisegrade.rag import INDEX_VERSION, build_chunks, ingest, keyword_overlap, query_terms, retrieve, retrieve_targets
from wisegrade.rules import Criterion, EvidenceTarget
-from wisegrade.scoring import _attach_page_types, _score_criterion
+from wisegrade.scoring import _attach_page_types, _body_navigator, _score_criterion
from wisegrade.seed import seed_initial
from wisegrade.storage import extract_identity, sha256
from wisegrade.submissions import file_index_lock
@@ -149,6 +149,37 @@ def test_shared_chunk_keeps_all_target_ids():
assert selection["evidence"][0]["target_ids"] == ["t0", "t1"]
+def test_plan_navigation_finds_future_work_body_despite_nonempty_semantic_hits(sqlite_db):
+ db = sqlite_db
+ item = project(db)
+ doc = document(db, item, pages=[
+ {"page_number": 3, "text": "目录\n4.2 后续改进方向.......... 11\n4.3 参考文献.......... 12"},
+ {"page_number": 6, "text": "2.3 系统控制\n控制器通过闭环反馈调整电机转速,实现稳定的运动控制。"},
+ {"page_number": 14, "text": "4.2 后续改进方向\n后续将探索深度学习检测算法,提高复杂光照场景下的鲁棒性;"
+ "引入模型预测控制优化动态跟踪,并完善状态监控和数据回放功能。"},
+ ])
+ noise = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == doc.id,
+ KnowledgeChunk.page == 6))
+ navigator = _body_navigator(db, course_version_id=1, project_id=item.id, category="stage3")
+ targets = normalize_targets(["计划", "章节"])
+ selected = retrieve_targets(db, course_version_id=1, project_id=item.id, category="stage3",
+ targets=targets, navigator=navigator, limit=2,
+ retrieve_fn=lambda *args, **kwargs: [{
+ "chunk_id": noise.id, "document_id": doc.id, "page": 6,
+ "page_type": "body", "text": noise.text, "rank": .03}])
+ assert any(hit["page"] == 14 and "t0" in hit["target_ids"] for hit in selected["evidence"])
+ assert all(hit["page_type"] == "body" for hit in selected["evidence"])
+
+
+def test_heading_navigation_supplements_existing_hits_for_custom_labels():
+ target = normalize_targets(["实验数据"])
+ raw = {"chunk_id": 1, "document_id": 1, "page": 2, "text": "仅描述装置。", "page_type": "body", "rank": .03}
+ body = {"chunk_id": 2, "document_id": 1, "page": 5, "text": "实测响应时间80毫秒。", "page_type": "body", "rank": .5}
+ result = retrieve_targets(None, course_version_id=1, project_id=1, targets=target, limit=1,
+ retrieve_fn=lambda *args, **kwargs: [raw], navigator=lambda _: [body])
+ assert result["evidence"][0]["chunk_id"] == 2
+
+
def test_document_filter_and_index_readiness_precede_limit(sqlite_db):
db = sqlite_db
item = project(db)
diff --git a/backend/wisegrade/rag.py b/backend/wisegrade/rag.py
index 7de5191..88e5397 100644
--- a/backend/wisegrade/rag.py
+++ b/backend/wisegrade/rag.py
@@ -391,11 +391,16 @@ def retrieve_targets(db, *, course_version_id: int, project_id: int, targets: li
substantive_only=target.get("use") != "structure",
semantic_pool=SEMANTIC_POOL, keyword_pool=KEYWORD_POOL)
hits = [normalize_hit(hit) for hit in (raw_hits or [])]
- if target.get("use") == "content" and not hits and navigator is not None:
+ if target.get("use") == "content" and navigator is not None:
# The contents list may only be used to navigate: its headings are matched
# against headings that really occur in body text, and those sections and
# their neighbouring pages are searched. The quotation still lands on body.
- hits = [dict(hit, via="toc_navigation") for hit in (navigator(target) or [])]
+ # Semantic recall always returns neighbours, even for a short vague
+ # query. Supplement it with matching body sections rather than waiting
+ # for zero hits, and keep those sections ahead of incidental matches.
+ navigated = [normalize_hit(hit) for hit in (navigator(target) or [])]
+ seen_ids = {hit["chunk_id"] for hit in navigated}
+ hits = navigated + [hit for hit in hits if hit["chunk_id"] not in seen_ids]
per_target[target["target_id"]] = hits
for hit in hits:
if hit["chunk_id"] not in seen:
diff --git a/backend/wisegrade/scoring.py b/backend/wisegrade/scoring.py
index c675f0a..a7af4a6 100644
--- a/backend/wisegrade/scoring.py
+++ b/backend/wisegrade/scoring.py
@@ -100,11 +100,17 @@ def _body_navigator(db, *, course_version_id, project_id, category):
label = normalize_heading(target.get("label") or "")
if not label:
return []
+ # A rubric often says simply "计划", while the actual section is future
+ # work. Expand only this concept; headings still have to exist in body text.
+ labels = [label]
+ plan_terms = ("计划", "后续改进", "后续工作", "未来工作", "下一阶段", "下一步")
+ if any(term in label for term in plan_terms):
+ labels.extend(plan_terms)
candidates = []
for heading, places in headings.items():
if not heading:
continue
- if heading.startswith(label) or label.startswith(heading) or label in heading or heading in label:
+ if any(term in heading or heading in term for term in labels):
candidates.extend(places)
if not candidates:
return []
From ef9edecff29bfa4243c702c8fad00d5d8bb4fd71 Mon Sep 17 00:00:00 2001
From: aerospaceboy <790286734@qq.com>
Date: Sat, 17 Oct 2026 20:17:19 +0800
Subject: [PATCH 06/17] fix: resolve legacy evidence targets without guessing
and guide retries
---
backend/tests/test_optimization.py | 65 ++++++++++++++++++++++++++++++
backend/wisegrade/llm.py | 12 +++++-
backend/wisegrade/scoring.py | 8 +++-
3 files changed, 82 insertions(+), 3 deletions(-)
diff --git a/backend/tests/test_optimization.py b/backend/tests/test_optimization.py
index b20d8b9..c3f4d05 100644
--- a/backend/tests/test_optimization.py
+++ b/backend/tests/test_optimization.py
@@ -563,6 +563,71 @@ def test_valid_target_cannot_be_attached_to_another_targets_chunk():
validate_score(result, criterion, [chunk])
+def test_legacy_missing_target_uses_unique_chunk_association():
+ criterion = {"criterion_id": "x", "max_score": 10, "required_evidence": ["实验数据", "实现方法"]}
+ chunk = {"chunk_id": 1, "page": 1, "page_type": "body", "text": "采用视觉传感器实现位置反馈。", "target_ids": ["t1"]}
+ result = ScoreResult(criterion_id="x", score=8, max_score=10, reason="有实现方法", confidence=.8,
+ evidence=[{"chunk_id": 1, "page": 1, "quote": chunk["text"]}])
+ assert validate_score(result, criterion, [chunk]).evidence[0].target_id == "t1"
+
+
+def test_target_mismatch_feedback_identifies_the_legal_chunk_target():
+ criterion = {"criterion_id": "x", "max_score": 10, "required_evidence": ["实验数据", "实现方法"]}
+ chunk = {"chunk_id": 42, "page": 1, "page_type": "body", "text": "采用视觉传感器实现位置反馈。", "target_ids": ["t1"]}
+ result = ScoreResult(criterion_id="x", score=8, max_score=10, reason="错误关联", confidence=.8,
+ evidence=[{"chunk_id": 42, "page": 1, "quote": chunk["text"], "target_id": "t0"}])
+ with pytest.raises(ModelError) as failure:
+ validate_score(result, criterion, [chunk])
+ assert "chunk_id=42" in str(failure.value)
+ assert "允许 target_id=t1" in str(failure.value)
+
+
+@pytest.mark.parametrize("rich", [False, True])
+def test_missing_target_is_not_guessed_when_ambiguous_or_required(rich):
+ required = [{"label": "实验数据"}, {"label": "实现方法"}] if rich else ["实验数据", "实现方法"]
+ criterion = {"criterion_id": "x", "max_score": 10, "required_evidence": required}
+ chunk = {"chunk_id": 1, "page": 1, "page_type": "body", "text": "采用视觉传感器实现位置反馈。", "target_ids": ["t0", "t1"]}
+ result = ScoreResult(criterion_id="x", score=8, max_score=10, reason="需要明确目标", confidence=.8,
+ evidence=[{"chunk_id": 1, "page": 1, "quote": chunk["text"]}])
+ with pytest.raises(ModelError, match="缺少 target_id"):
+ validate_score(result, criterion, [chunk])
+
+
+def test_scoring_retry_carries_target_contract_and_specific_repair_feedback(sqlite_db):
+ db = sqlite_db
+ item = project(db)
+ file = SubmissionFile(project_id=item.id, category="stage3", relative_path="test.pdf", mime_type="application/pdf",
+ size=1, mtime=1, sha256="test", recognition_status="recognized", parse_status="ready")
+ db.add(file)
+ db.commit()
+ doc = ingest(db, course_version_id=1, project_id=item.id, submission_file_id=file.id, title="test.pdf",
+ category="stage3", source_hash="test", pages=[{"page_number": 1, "text": "采用视觉传感器实现位置反馈。"}], embedder=FLAT)
+ chunk = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == doc.id))
+ criterion = {"criterion_id": "stage3.design", "category": "stage3", "max_score": 20,
+ "description": "实现方法", "required_evidence": ["实验数据", "实现方法"]}
+ created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
+ batch = db.get(ScoringBatch, created["id"])
+ class Client:
+ calls = 0
+ def json_call(self, prompt, payload, *args, **kwargs):
+ self.calls += 1
+ assert "每条引文的 target_id 必须属于该片段的 target_ids" in prompt
+ if self.calls == 2:
+ assert payload["validation_feedback"]["error"].endswith("允许 target_id=t1")
+ return ScoreResult(criterion_id=criterion["criterion_id"], score=15, max_score=20,
+ reason="有实现方法", confidence=.8, evidence=[{
+ "chunk_id": chunk.id, "page": 1, "quote": chunk.text,
+ "target_id": "t0" if self.calls == 1 else "t1"}])
+ client = Client()
+ def selected(*args, **kwargs):
+ return {"evidence": [{"chunk_id": chunk.id, "document_id": doc.id, "page": 1,
+ "text": chunk.text, "page_type": "body", "target_ids": ["t1"]}],
+ "uncovered_targets": []}
+ score = _score_criterion(db, batch, item, criterion, client,
+ retriever=lambda *args, **kwargs: [], multi_retriever=selected)
+ assert score.status == "scored" and client.calls == 2
+
+
@pytest.mark.parametrize("version", [None, 1])
def test_worker_rejects_old_policy_without_touching_scores(sqlite_db, version):
from wisegrade.scoring import run_batch
diff --git a/backend/wisegrade/llm.py b/backend/wisegrade/llm.py
index 7c32162..7691dbd 100644
--- a/backend/wisegrade/llm.py
+++ b/backend/wisegrade/llm.py
@@ -238,11 +238,19 @@ def validate_score(result: ScoreResult, criterion: dict, chunks: list[dict], *,
# Empty identifiers are accepted only for pre-object legacy rubrics.
if any(isinstance(value, dict) for value in criterion.get("required_evidence", [])):
raise ModelError("模型证据缺少 target_id")
- target = default_content or (targets[0] if targets else None)
+ associated = [by_target[key] for key in chunk.get("target_ids", []) if key in by_target]
+ if associated:
+ if len(associated) != 1:
+ raise ModelError(f"模型证据缺少 target_id:chunk_id={chunk['chunk_id']} 对应多个检索目标,请明确选择")
+ target = associated[0]
+ else:
+ target = default_content or (targets[0] if targets else None)
if target is None:
raise ModelError("模型证据未关联任何必备证据目标")
if chunk.get("target_ids") and target["target_id"] not in chunk["target_ids"]:
- raise ModelError("模型证据未关联该片段的检索目标")
+ allowed = ",".join(str(key) for key in chunk["target_ids"] if key in by_target)
+ raise ModelError(f"模型证据未关联该片段的检索目标:chunk_id={chunk['chunk_id']},"
+ f"target_id={target['target_id']};允许 target_id={allowed}")
if not page_type_allowed(page_type, target["allowed_page_types"]):
raise ModelError(f"模型证据来自不允许的页面类型({page_type}),不能支持「{target['label']}」")
if target["use"] == USE_CONTENT and not content_evidence_allowed(page_type):
diff --git a/backend/wisegrade/scoring.py b/backend/wisegrade/scoring.py
index a7af4a6..5f34560 100644
--- a/backend/wisegrade/scoring.py
+++ b/backend/wisegrade/scoring.py
@@ -227,6 +227,8 @@ def _score_criterion(db, batch, project, criterion, client, *, retriever, multi_
prompt = (batch.course_snapshot["prompts"]["score"] +
" 证据quote必须从所引用chunk_id的text逐字复制连续片段,保留原文标点和换行;page必须等于该片段的page;"
"target_id必须取自required_evidence_targets。不要改写、缩写或拼接引文。"
+ "每条引文的 target_id 必须属于该片段的 target_ids;同一片段可对应多个目标,"
+ "请按实际内容选择,不要按证据列表顺序分配编号。"
"不得把目录、摘要或章节标题当作内容证据;无法据此判断内容时把score设为null。")
error = ""
result = None
@@ -239,7 +241,11 @@ def _score_criterion(db, batch, project, criterion, client, *, retriever, multi_
error = str(exc)
if attempt:
raise
- prompt += f" 上次回答未通过校验({error});请改用允许页面类型中的实质正文片段,并逐字复制quote。"
+ payload["validation_feedback"] = {
+ "error": error,
+ "instruction": "根据具体错误修正输出;引用目标只能从对应片段的 target_ids 选择,"
+ "quote须为该片段原文。没有足够依据时保留空分,不要编造引用。"}
+ prompt += " 上次回答未通过校验;请按 validation_feedback 修正,不要重复错误引用。"
score = db.scalar(select(CriterionScore).where(CriterionScore.id == score.id)
.with_for_update().execution_options(populate_existing=True))
if score.status == STATUS_REVIEWED or score.teacher_score is not None:
From ef5c3ecab2fcac6975d85e4e5ada5d9dbd4c9d74 Mon Sep 17 00:00:00 2001
From: aerospaceboy <790286734@qq.com>
Date: Sat, 17 Oct 2026 20:19:23 +0800
Subject: [PATCH 07/17] fix: distinguish review, output validation and
connection failures in grading UI
---
frontend/src/App.tsx | 14 ++++++++++----
frontend/src/Batches.tsx | 7 ++++++-
frontend/tests/batches.spec.ts | 34 ++++++++++++++++++++++++++++++++++
3 files changed, 50 insertions(+), 5 deletions(-)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index aa99e50..347af11 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -31,8 +31,14 @@ const evidencePlace = (evidence: any) => `第 ${evidence.page} ${isSlideEvidence
const evidencePreviewLabel = (evidence: any) => isSlideEvidence(evidence) ? '查看该张幻灯片的完整图像' : '查看该页的完整图像';
const pageTypeLabels: Record = { cover: '封面', abstract: '摘要', toc: '目录', body: '正文', appendix: '附录', references: '参考文献', unknown: '识别不确定', mixed: '目录与正文混合页' };
const pageTypeLabel = (value: string) => pageTypeLabels[value] || '未分类页面';
-const statusLabels: Record = { pending: '待评分', scored: '模型已评分', reviewed: '教师已复核', needs_review: '待教师复核', unscorable: '材料缺失或解析失败', failed: '模型调用失败' };
+const statusLabels: Record = { pending: '待评分', scored: '模型已评分', reviewed: '教师已复核', needs_review: '待教师复核', unscorable: '材料缺失或解析失败', failed: '评分失败' };
const statusLabel = (value: string) => statusLabels[value] || value;
+const scoreStatusLabel = (row: any) => {
+ 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 '模型连接失败';
+ return '评分失败';
+};
const statusColor = (value: string) => ({ scored: 'green', reviewed: 'green', needs_review: 'gold', unscorable: 'red', failed: 'red' }[value] || 'blue');
const batchStatusLabels: Record = { created: '待启动', queued: '排队中', running: '运行中', completed: '已完成', review_required: '已完成,有待复核项', partial: '部分完成', failed: '失败' };
const batchStatusLabel = (value: string) => batchStatusLabels[value] || value;
@@ -52,7 +58,7 @@ function JsonField({ value, onChange, disabled, rows = 4, resetKey }: { value: a
function Dashboard() {
const [data, setData] = useState({});
useEffect(() => { load('/dashboard', setData); }, []);
- return <>工作概览先确认课程版本与规则,再扫描挂载目录,创建评分批次并逐项复核。{[['课程', data.courses], ['学生项目', data.projects], ['评分批次', data.batches], ['待处理评分项', data.needs_attention], ['待教师复核', data.needs_review]].map(([label, count]) => )}
>;
+ return <>工作概览先确认课程版本与规则,再扫描挂载目录,创建评分批次并逐项复核。{[['课程', data.courses], ['学生项目', data.projects], ['评分批次', data.batches], ['待处理评分项', data.needs_attention], ['待教师复核', data.needs_review]].map(([label, count]) => )}
>;
}
function Courses() {
@@ -233,8 +239,8 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }
{selectedBatch && }
- {result && <>{result.rows.some((r: any) => r.status === 'failed') && r.status === 'failed').length} 项评分失败`} description="具体原因见下表。网络恢复后,可在评分批次页点击启动/重试;已经成功的评分和教师复核会保留。" />}{Object.entries(result.totals).map(([key, value]) => {key}: {String(value ?? '待处理')})}{result.facts?.consistency_issues?.length > 0 && issue.description).join(';')} />} row.criterion.criterion_id} dataSource={result.rows} pagination={false} columns={[{ title: '评分项', render: (_, r) => <>{categories[r.criterion.category] || r.criterion.category}{r.criterion.name}> }, { title: '模型原始分', render: (_, r) => `${r.model_score ?? '—'} / ${r.criterion.max_score}` }, { title: '教师分', render: (_, r) => r.teacher_score ?? '—' }, { title: '最终分', render: (_, r) => r.final_score ?? '待处理' }, { title: '状态 / 失败原因', render: (_, r) => {statusLabel(r.status)}{r.error && {r.error}} }, { title: '操作', render: (_, r) => }]} />>}
- setReview(undefined)} onOk={async () => { try { const values = await form.validateFields(); await put(`/criterion-scores/${review.score_id}/review`, values); setReview(undefined); refresh(); message.success('复核记录已保存'); } catch (e) { fail(e); } }} okText="保存教师复核" okButtonProps={{ disabled: !review?.score_id }}>{review && <>{review.status === 'needs_review' && gap.target_label).join('、') || review.missing_evidence?.join('、') || '见缺失证据'}。系统未给出模型分,也没有按低分处理;请核对材料后直接填写教师评分。`} />} `${gap.target_label}(检索范围:${(gap.searched_page_types || []).map(pageTypeLabel).join('、') || '正文'})`).join(';') || '无' }, { key: 'suggestion', label: '改进建议', children: review.suggestion || '无' }, { key: 'error', label: '异常', children: review.error || '无' }]} />{review.evidence?.map((ev: any, index: number) => {ev.document} · {evidencePlace(ev)}{pageTypeLabel(ev.page_type)}{ev.historical && 历史评分引用}{ev.target_label && {ev.target_label}}{ev.quote}{ev.url && 查看页面文字}{ev.image_url && {evidencePreviewLabel(ev)}(核对引文)})}{review.score_id && }{revisions.length > 0 && {JSON.stringify(revisions, null, 2)}}>}
+ {result && <>{result.rows.some((r: any) => r.status === 'failed') && r.status === 'failed').length} 项评分失败`} description="具体原因见下表。连接失败请检查模型端点和网络;输出校验失败可重试,若重复发生请核对具体校验原因。可在评分批次页点击启动/重试;已经成功的评分和教师复核会保留。" />}{Object.entries(result.totals).map(([key, value]) => {key}: {String(value ?? '待处理')})}{result.facts?.consistency_issues?.length > 0 && issue.description).join(';')} />} row.criterion.criterion_id} dataSource={result.rows} pagination={false} columns={[{ title: '评分项', render: (_, r) => <>{categories[r.criterion.category] || r.criterion.category}{r.criterion.name}> }, { title: '模型原始分', render: (_, r) => `${r.model_score ?? '—'} / ${r.criterion.max_score}` }, { title: '教师分', render: (_, r) => r.teacher_score ?? '—' }, { title: '最终分', render: (_, r) => r.final_score ?? '待处理' }, { title: '状态 / 失败原因', render: (_, r) => {scoreStatusLabel(r)}{r.error && {r.error}} }, { title: '操作', render: (_, r) => }]} />>}
+ setReview(undefined)} onOk={async () => { try { const values = await form.validateFields(); await put(`/criterion-scores/${review.score_id}/review`, values); setReview(undefined); refresh(); message.success('复核记录已保存'); } catch (e) { fail(e); } }} okText="保存教师复核" okButtonProps={{ disabled: !review?.score_id }}>{review && <>{review.status === 'needs_review' && gap.target_label).join('、') || review.missing_evidence?.join('、') || '见缺失证据'}。系统未给出模型分,也没有按低分处理;请核对下方证据,补充或更正材料后重试,或直接填写教师评分。`} />} `${gap.target_label}(检索范围:${(gap.searched_page_types || []).map(pageTypeLabel).join('、') || '正文'})`).join(';') || '无' }, { key: 'suggestion', label: '改进建议', children: review.suggestion || '无' }, { key: 'error', label: '异常', children: review.error || '无' }]} />{review.evidence?.map((ev: any, index: number) => {ev.document} · {evidencePlace(ev)}{pageTypeLabel(ev.page_type)}{ev.historical && 历史评分引用}{ev.target_label && {ev.target_label}}{ev.quote}{ev.url && 查看页面文字}{ev.image_url && {evidencePreviewLabel(ev)}(核对引文)})}{review.score_id && }{revisions.length > 0 && {JSON.stringify(revisions, null, 2)}}>}
>;
}
diff --git a/frontend/src/Batches.tsx b/frontend/src/Batches.tsx
index 1e2d304..440beb0 100644
--- a/frontend/src/Batches.tsx
+++ b/frontend/src/Batches.tsx
@@ -148,10 +148,15 @@ export function Batches({ openBatch }: { openBatch: (id: number, projectId?: num
{ title: '状态', width: 140, render: (_, batch) =>
{labels[batch.status] || batch.status}
{batch.needs_review > 0 && 待复核 {batch.needs_review}}
+ {batch.failed > 0 && 评分失败 {batch.failed}}
+ {batch.unscorable > 0 && 材料异常 {batch.unscorable}}
},
- { title: '进度', width: 160, render: (_, batch) => <>
+ { title: '处理进度', width: 190, render: (_, batch) => <>
{batch.progress_done} / {batch.progress_total} 项
+ {batch.progress_total > 0 && batch.progress_done >= batch.progress_total &&
+ (batch.needs_review > 0 || batch.failed > 0 || batch.unscorable > 0) &&
+ 处理结束,仍有未评分项}
> },
{ title: '异常', dataIndex: 'error', width: 150 },
{ title: '操作', width: 180, render: (_, batch) =>
diff --git a/frontend/tests/batches.spec.ts b/frontend/tests/batches.spec.ts
index 0e6eb37..08e09fc 100644
--- a/frontend/tests/batches.spec.ts
+++ b/frontend/tests/batches.spec.ts
@@ -149,6 +149,40 @@ test('failed scoring shows the network error and retry guidance on the page', as
await expect(page.getByText(/已经成功的评分和教师复核会保留/)).toBeVisible();
});
+test('batch progress exposes failures separately from teacher review', async ({ page }) => {
+ await setup(page);
+ await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{
+ id: 71, course_version_id: 1, project_ids: [11], status: 'partial',
+ progress_done: 22, progress_total: 22, needs_review: 2, failed: 1, unscorable: 0,
+ }] }));
+ await page.goto('/?page=batches');
+ const row = page.getByRole('row').filter({ hasText: '#71' });
+ await expect(row).toContainText('评分失败 1');
+ await expect(row).toContainText('待复核 2');
+ await expect(row).toContainText('处理结束,仍有未评分项');
+});
+
+test('output validation and insufficient evidence are not presented as network failures', async ({ page }) => {
+ await setup(page);
+ await page.route('**/api/projects/11/scores?*', route => route.fulfill({ json: {
+ student_no: '9000000001', name: '学生甲', topic: '项目', totals: { overall: null }, facts: {},
+ rows: [
+ { criterion: { criterion_id: 'stage2.experiment', category: 'stage2', name: '实验方案', max_score: 20 },
+ status: 'failed', error: '模型证据未关联该片段的检索目标', evidence: [] },
+ { criterion: { criterion_id: 'stage3.plan', category: 'stage3', name: '下一阶段计划', max_score: 10 },
+ status: 'needs_review', error: '模型认为现有正文证据不足以判断该项,转教师复核。',
+ evidence: [], missing_evidence: ['计划'], evidence_gaps: [], model_comment: '当前片段不足。' },
+ ],
+ } }));
+ await page.goto('/?page=scores&batch_id=71&project_id=11');
+ await expect(page.getByText('模型输出校验失败', { exact: true })).toBeVisible();
+ await expect(page.getByText('模型调用失败', { exact: true })).toHaveCount(0);
+ await expect(page.getByText(/输出校验失败可重试/)).toBeVisible();
+ await page.getByRole('row').filter({ hasText: '下一阶段计划' }).getByRole('button', { name: '查看证据/复核' }).click();
+ await expect(page.getByText('本项证据不足以自动评分,需要教师复核', { exact: true })).toBeVisible();
+ await expect(page.getByText(/缺少或尚不足以支撑判断的目标:计划/)).toBeVisible();
+});
+
test('a slow project list keeps the student specified in a multi-project batch link', async ({ page }) => {
const state = await setup(page);
await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{
From 9a1afd502036d98224d95696f3fefd351dcbfa82 Mon Sep 17 00:00:00 2001
From: aerospaceboy <790286734@qq.com>
Date: Sat, 17 Oct 2026 20:19:37 +0800
Subject: [PATCH 08/17] fix: clear resolved evidence gaps after a successful
scoring retry
---
backend/tests/test_optimization.py | 4 ++++
backend/wisegrade/scoring.py | 1 +
2 files changed, 5 insertions(+)
diff --git a/backend/tests/test_optimization.py b/backend/tests/test_optimization.py
index c3f4d05..21dca2d 100644
--- a/backend/tests/test_optimization.py
+++ b/backend/tests/test_optimization.py
@@ -607,6 +607,9 @@ def test_scoring_retry_carries_target_contract_and_specific_repair_feedback(sqli
"description": "实现方法", "required_evidence": ["实验数据", "实现方法"]}
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
batch = db.get(ScoringBatch, created["id"])
+ db.add(CriterionScore(batch_id=batch.id, project_id=item.id, criterion_id=criterion["criterion_id"],
+ status="needs_review", evidence_gaps=[{"target_id": "t1", "reason": "上次未命中正文"}]))
+ db.commit()
class Client:
calls = 0
def json_call(self, prompt, payload, *args, **kwargs):
@@ -626,6 +629,7 @@ def test_scoring_retry_carries_target_contract_and_specific_repair_feedback(sqli
score = _score_criterion(db, batch, item, criterion, client,
retriever=lambda *args, **kwargs: [], multi_retriever=selected)
assert score.status == "scored" and client.calls == 2
+ assert score.evidence_gaps == []
@pytest.mark.parametrize("version", [None, 1])
diff --git a/backend/wisegrade/scoring.py b/backend/wisegrade/scoring.py
index 5f34560..a128b78 100644
--- a/backend/wisegrade/scoring.py
+++ b/backend/wisegrade/scoring.py
@@ -263,6 +263,7 @@ def _score_criterion(db, batch, project, criterion, client, *, retriever, multi_
gap["reason"] = result.reason
else:
score.status, score.error = STATUS_SCORED, ""
+ score.evidence_gaps = []
for evidence in db.scalars(select(ScoreEvidence).where(ScoreEvidence.score_id == score.id)).all():
db.delete(evidence)
by_id = {chunk["chunk_id"]: chunk for chunk in chunks}
From d27ad535c6a2d562ba62e62db09c7de1a22ca8b7 Mon Sep 17 00:00:00 2001
From: aerospaceboy <790286734@qq.com>
Date: Sat, 17 Oct 2026 20:21:18 +0800
Subject: [PATCH 09/17] fix: respect target page restrictions during body
section navigation
---
backend/tests/test_optimization.py | 1 +
backend/wisegrade/scoring.py | 2 ++
2 files changed, 3 insertions(+)
diff --git a/backend/tests/test_optimization.py b/backend/tests/test_optimization.py
index 21dca2d..8471aa8 100644
--- a/backend/tests/test_optimization.py
+++ b/backend/tests/test_optimization.py
@@ -169,6 +169,7 @@ def test_plan_navigation_finds_future_work_body_despite_nonempty_semantic_hits(s
"page_type": "body", "text": noise.text, "rank": .03}])
assert any(hit["page"] == 14 and "t0" in hit["target_ids"] for hit in selected["evidence"])
assert all(hit["page_type"] == "body" for hit in selected["evidence"])
+ assert navigator({**targets[0], "allowed_page_types": ["appendix"]}) == []
def test_heading_navigation_supplements_existing_hits_for_custom_labels():
diff --git a/backend/wisegrade/scoring.py b/backend/wisegrade/scoring.py
index a128b78..af66298 100644
--- a/backend/wisegrade/scoring.py
+++ b/backend/wisegrade/scoring.py
@@ -97,6 +97,8 @@ def _body_navigator(db, *, course_version_id, project_id, category):
return None
def navigate(target):
+ if "body" not in target.get("allowed_page_types", ["body"]):
+ return []
label = normalize_heading(target.get("label") or "")
if not label:
return []
From f1230781024874e839dc3e14f718274c3f8ea152 Mon Sep 17 00:00:00 2001
From: aerospaceboy <790286734@qq.com>
Date: Sat, 17 Oct 2026 20:23:29 +0800
Subject: [PATCH 10/17] docs: record scoring evidence regressions and live
verification
---
docs/SCORING_REVIEW_FIX_2026-10-17.md | 29 +++++++++++++++++++++++++++
1 file changed, 29 insertions(+)
create mode 100644 docs/SCORING_REVIEW_FIX_2026-10-17.md
diff --git a/docs/SCORING_REVIEW_FIX_2026-10-17.md b/docs/SCORING_REVIEW_FIX_2026-10-17.md
new file mode 100644
index 0000000..21c8088
--- /dev/null
+++ b/docs/SCORING_REVIEW_FIX_2026-10-17.md
@@ -0,0 +1,29 @@
+# 评分待复核与引用校验修复(2026-10-17)
+
+## 触发场景与根因
+
+- 批次 #8 的 `stage3.plan` 目标为“计划”。实际阶段报告3的 PDF 第14页有“4.2 后续改进方向”正文,但旧检索选出的14个片段没有这一页。短查询仍会得到语义近邻,导致原先仅在零命中时启用的章节导航不执行;“计划”与“后续改进方向”也没有按同一概念匹配。
+- `stage2.experiment` 记录的错误为“模型证据未关联该片段的检索目标”。模型提示只要求使用规则中的目标编号,没有明确要求同时属于片段的 `target_ids`;重试也未指出具体可用的片段与目标关联。此外,旧字符串规则中省略编号时会错误地默认绑定第一个内容目标。
+- 页面将所有 `failed` 统一标成“模型调用失败”,重试提示也一律指向网络;批次进度只显示待复核数量,没有显示失败数量。100% 表示处理结束,并不表示所有评分都成功。
+
+## 修复
+
+- 内容检索始终补充匹配的真实正文章节,兼容计划/后续改进/后续工作等表述,保留14片段预算、项目/材料范围、正文许可与引用校验。
+- 只有旧规则省略编号且片段唯一关联某个目标时才补全;多目标歧义、显式错误编号、目录冒充内容证据仍然拒绝。重试带具体校验错误及允许编号,不通过忽略错误来获得分数。
+- 成功重试清理旧证据缺口;教师复核记录仍按原逻辑保护。
+- 页面区分输出校验失败、连接失败和一般评分失败,分别显示待复核/失败/材料异常数量,并解释处理完成不等于全部已评分。
+
+## 验证
+
+- 新增/扩展的导航、目标关联、重试反馈、旧缺口清理和页面测试均先观察到失败,再验证修复后通过。
+- 后端4个相关测试文件:81 passed(隔离 SQLite 业务测试,不代表全量 PostgreSQL 测试通过)。
+- Playwright:19 passed;TypeScript/Vite 构建通过。
+- Docker API/Worker/Web 已更新并健康;更新前无运行中批次,队列和执行中任务均为0。
+- 生产数据只读 PostgreSQL + 真实离线 BGE-M3 检索:相同规则修复前未选第14页,修复后选中该页正文,片段数量仍为14。
+- 真实网页验证:批次 #8 显示“待复核2”“评分失败1”和未评分项提示;失败行显示“模型输出校验失败”;复核弹窗解释证据尚不足以自动评分。无页面错误、无业务写请求、无实际模型调用。
+
+## 边界与使用
+
+历史批次分数未自动改写。刷新网页后可重试 #8 的未成功项;已成功评分和教师复核项不重新评分。此次未实际调用收费模型重新给学生打分,因此不声称重试一定消除全部待复核项。
+
+课程导学PPT缺少学生项目汇报内容、报告安全论证不足等情况仍应提示复核。修复检索遗漏和校验反馈,不将证据不足强制变成有效分数。
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 11/17] 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
From 14cef5c9297d431de3b506f5ddedaf5f74a80101 Mon Sep 17 00:00:00 2001
From: aerospaceboy <790286734@qq.com>
Date: Sat, 17 Oct 2026 20:44:24 +0800
Subject: [PATCH 12/17] perf: skip fact extraction for fully graded projects on
batch retries
---
backend/tests/test_optimization.py | 37 ++++++++++++++++++++++++++++++
backend/wisegrade/scoring.py | 12 ++++++++++
2 files changed, 49 insertions(+)
diff --git a/backend/tests/test_optimization.py b/backend/tests/test_optimization.py
index 1c73346..283a579 100644
--- a/backend/tests/test_optimization.py
+++ b/backend/tests/test_optimization.py
@@ -402,6 +402,43 @@ def test_failed_model_call_cannot_overwrite_a_concurrent_teacher_review(sqlite_d
assert db.get(ScoringBatch, batch.id).progress_done == 1
+@pytest.mark.parametrize("complete", [True, False])
+def test_retry_skips_facts_only_for_fully_graded_projects(sqlite_db, monkeypatch, complete):
+ 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"])
+ criteria = batch.rubric_snapshot["criteria"][:2]
+ batch.rubric_snapshot = {**batch.rubric_snapshot, "criteria": criteria}
+ batch.status, batch.progress_total, batch.progress_done = "running", 2, 0
+ item.facts = {"conclusions": ["已抽取并确认的事实"]}
+ db.add_all([
+ CriterionScore(batch_id=batch.id, project_id=item.id, criterion_id=criteria[0]["criterion_id"], status="scored", model_score=0),
+ CriterionScore(batch_id=batch.id, project_id=item.id, criterion_id=criteria[1]["criterion_id"],
+ status="reviewed" if complete else "needs_review", teacher_score=0 if complete else None),
+ ])
+ db.commit()
+ calls = []
+ def client_factory(*args):
+ calls.append("client")
+ def facts(*args, **kwargs):
+ calls.append("facts")
+ return {}
+ def scored(session, batch, item, criterion, *args, **kwargs):
+ return session.scalar(select(CriterionScore).where(CriterionScore.batch_id == batch.id,
+ CriterionScore.project_id == item.id, CriterionScore.criterion_id == criterion["criterion_id"]))
+ monkeypatch.setattr(scoring, "_project_facts", facts)
+ monkeypatch.setattr(scoring, "_score_criterion", scored)
+ factory = sessionmaker(bind=db.get_bind(), expire_on_commit=False)
+ failures, reviews = scoring._run_project(batch.id, item.id, factory, client_factory, FLAT, lambda *a, **k: [])
+ assert calls == ([] if complete else ["client", "facts"])
+ assert failures == 0 and reviews == (0 if complete else 1)
+ db.expire_all()
+ assert db.get(ScoringBatch, batch.id).progress_done == 2
+ assert db.get(Project, item.id).facts == {"conclusions": ["已抽取并确认的事实"]}
+
+
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 52f3e09..c176e66 100644
--- a/backend/wisegrade/scoring.py
+++ b/backend/wisegrade/scoring.py
@@ -358,6 +358,18 @@ def _run_project(batch_id, project_id, session_factory, client_factory, embedder
with session_factory() as db:
batch = db.get(ScoringBatch, batch_id)
project = db.get(Project, project_id)
+ existing = db.scalars(select(CriterionScore).where(CriterionScore.batch_id == batch_id,
+ CriterionScore.project_id == project_id)).all()
+ graded = {score.criterion_id for score in existing
+ if score.status in (STATUS_SCORED, STATUS_REVIEWED)}
+ criteria = batch.rubric_snapshot["criteria"]
+ if all(criterion["criterion_id"] in graded for criterion in criteria):
+ # A retry in a multi-student batch must not spend two more model calls
+ # extracting facts for a student whose scores will all be skipped.
+ db.execute(update(ScoringBatch).where(ScoringBatch.id == batch_id).values(
+ progress_done=ScoringBatch.progress_done + len(criteria)))
+ db.commit()
+ return 0, 0
try:
client = client_factory(db, batch)
except Exception:
From 5a6e5d47a08992069164192f6317e9949ab21250 Mon Sep 17 00:00:00 2001
From: aerospaceboy <790286734@qq.com>
Date: Sat, 17 Oct 2026 20:44:50 +0800
Subject: [PATCH 13/17] fix: attribute descriptive review gaps to the named
evidence target
---
backend/tests/test_optimization.py | 28 ++++++++++++++++++++++++++++
backend/wisegrade/scoring.py | 8 +++++++-
2 files changed, 35 insertions(+), 1 deletion(-)
diff --git a/backend/tests/test_optimization.py b/backend/tests/test_optimization.py
index 283a579..75cf4e8 100644
--- a/backend/tests/test_optimization.py
+++ b/backend/tests/test_optimization.py
@@ -373,6 +373,34 @@ def test_model_declining_score_records_original_gap(sqlite_db):
assert score.evidence_gaps[0]["reason"] == "只有装置介绍"
+@pytest.mark.parametrize("missing", [["误差分析(t1):缺少误差来源和统计数据"],
+ ["尚缺 t1 对应的正文"], ["当前资料无法判断"]])
+def test_declined_score_attributes_descriptive_missing_evidence_to_its_target(sqlite_db, missing):
+ db = sqlite_db
+ item = project(db)
+ file = SubmissionFile(project_id=item.id, category="stage3", relative_path="test.pdf", mime_type="application/pdf",
+ size=1, mtime=1, sha256="test", recognition_status="recognized", parse_status="ready")
+ db.add(file)
+ db.commit()
+ ingest(db, course_version_id=1, project_id=item.id, submission_file_id=file.id, title="test.pdf",
+ category="stage3", source_hash="test", pages=[{"page_number": 1, "text": "实验测得响应时间80毫秒,但未解释误差来源。"}], embedder=FLAT)
+ criterion = {"criterion_id": "stage3.data", "category": "stage3", "max_score": 20,
+ "description": "实验数据与误差分析", "required_evidence": ["实验数据", "误差分析"]}
+ created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
+ class Client:
+ def json_call(self, *args, **kwargs):
+ return ScoreResult(criterion_id="stage3.data", score=None, max_score=20,
+ reason="误差分析尚不足以判断", missing_evidence=missing, confidence=.2)
+ def retriever(session, **kwargs):
+ if kwargs.get("public_only"):
+ return []
+ return retrieve(session, **{**kwargs, "embedder": FLAT})
+ score = _score_criterion(db, db.get(ScoringBatch, created["id"]), item, criterion, Client(), retriever=retriever)
+ expected = ["t0", "t1"] if missing == ["当前资料无法判断"] else ["t1"]
+ assert [gap["target_id"] for gap in score.evidence_gaps] == expected
+ assert score.status == "needs_review" and score.model_score is None
+
+
def test_failed_model_call_cannot_overwrite_a_concurrent_teacher_review(sqlite_db, monkeypatch):
from wisegrade import scoring
db = sqlite_db
diff --git a/backend/wisegrade/scoring.py b/backend/wisegrade/scoring.py
index c176e66..ce8b240 100644
--- a/backend/wisegrade/scoring.py
+++ b/backend/wisegrade/scoring.py
@@ -1,4 +1,5 @@
from concurrent.futures import ThreadPoolExecutor, as_completed
+import re
from sqlalchemy import and_, or_, select, update
@@ -258,8 +259,13 @@ def _score_criterion(db, batch, project, criterion, client, *, retriever, multi_
if result.score is None:
# The retrieval found body pages but the model judged them insufficient.
score.status, score.error = STATUS_NEEDS_REVIEW, "模型认为现有正文证据不足以判断该项,转教师复核。"
+ # Models normally write "误差分析(t1):缺少…", not just the bare
+ # label. Attribute those explanations to their target instead of marking
+ # every target as missing. Unknown explanations remain conservative.
declined = [target for target in targets if target["use"] == USE_CONTENT and
- (not result.missing_evidence or target["label"] in result.missing_evidence)]
+ any(target["label"] in note or re.search(
+ r"(?
Date: Sat, 17 Oct 2026 20:46:29 +0800
Subject: [PATCH 14/17] feat: filter grading items by review and failure status
---
frontend/src/App.tsx | 21 ++++++++++++++++++++-
frontend/tests/batches.spec.ts | 32 ++++++++++++++++++++++++++++++++
2 files changed, 52 insertions(+), 1 deletion(-)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 347af11..8cbb27a 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -205,6 +205,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 [form] = Form.useForm();
const [sourcesReady, setSourcesReady] = useState(false);
useEffect(() => {
@@ -219,6 +220,7 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }
const scopedProjects = projectsInBatch(selectedBatch, projects);
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);
@@ -230,6 +232,12 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }
return () => { cancelled = true; };
}, [batch, project, batches, projects, sourcesReady]);
const refresh = () => batch && project && load(`/projects/${project}/scores?batch_id=${batch}`, setResult);
+ 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);
+ const attentionCount = scoreRows.filter(unfinished).length;
+ const filteredScores = scoreRows.filter(row => scoreFilter === 'all' ||
+ (scoreFilter === 'attention' ? unfinished(row) : row.status === scoreFilter));
return <>逐项评分与教师复核
{selectedBatch && }
- {result && <>{result.rows.some((r: any) => r.status === 'failed') && r.status === 'failed').length} 项评分失败`} description="具体原因见下表。连接失败请检查模型端点和网络;输出校验失败可重试,若重复发生请核对具体校验原因。可在评分批次页点击启动/重试;已经成功的评分和教师复核会保留。" />}{Object.entries(result.totals).map(([key, value]) => {key}: {String(value ?? '待处理')})}{result.facts?.consistency_issues?.length > 0 && issue.description).join(';')} />} row.criterion.criterion_id} dataSource={result.rows} pagination={false} columns={[{ title: '评分项', render: (_, r) => <>{categories[r.criterion.category] || r.criterion.category}{r.criterion.name}> }, { title: '模型原始分', render: (_, r) => `${r.model_score ?? '—'} / ${r.criterion.max_score}` }, { title: '教师分', render: (_, r) => r.teacher_score ?? '—' }, { title: '最终分', render: (_, r) => r.final_score ?? '待处理' }, { title: '状态 / 失败原因', render: (_, r) => {scoreStatusLabel(r)}{r.error && {r.error}} }, { title: '操作', render: (_, r) => }]} />>}
+ {result &&
+
+ 已评分 {count('scored')} 项 · 教师已复核 {count('reviewed')} 项 · 待处理 {attentionCount} 项
+ }
+ {result && <>{result.rows.some((r: any) => r.status === 'failed') && r.status === 'failed').length} 项评分失败`} description="具体原因见下表。连接失败请检查模型端点和网络;输出校验失败可重试,若重复发生请核对具体校验原因。可在评分批次页点击启动/重试;已经成功的评分和教师复核会保留。" />}{Object.entries(result.totals).map(([key, value]) => {key}: {String(value ?? '待处理')})}{result.facts?.consistency_issues?.length > 0 && issue.description).join(';')} />} row.criterion.criterion_id} dataSource={filteredScores} locale={{ emptyText: '当前筛选下没有评分项' }} pagination={false} columns={[{ title: '评分项', render: (_, r) => <>{categories[r.criterion.category] || r.criterion.category}{r.criterion.name}> }, { title: '模型原始分', render: (_, r) => `${r.model_score ?? '—'} / ${r.criterion.max_score}` }, { title: '教师分', render: (_, r) => r.teacher_score ?? '—' }, { title: '最终分', render: (_, r) => r.final_score ?? '待处理' }, { title: '状态 / 失败原因', render: (_, r) => {scoreStatusLabel(r)}{r.error && {r.error}} }, { title: '操作', render: (_, r) => }]} />>}
setReview(undefined)} onOk={async () => { try { const values = await form.validateFields(); await put(`/criterion-scores/${review.score_id}/review`, values); setReview(undefined); refresh(); message.success('复核记录已保存'); } catch (e) { fail(e); } }} okText="保存教师复核" okButtonProps={{ disabled: !review?.score_id }}>{review && <>{review.status === 'needs_review' && gap.target_label).join('、') || review.missing_evidence?.join('、') || '见缺失证据'}。系统未给出模型分,也没有按低分处理;请核对下方证据,补充或更正材料后重试,或直接填写教师评分。`} />} `${gap.target_label}(检索范围:${(gap.searched_page_types || []).map(pageTypeLabel).join('、') || '正文'})`).join(';') || '无' }, { key: 'suggestion', label: '改进建议', children: review.suggestion || '无' }, { key: 'error', label: '异常', children: review.error || '无' }]} />{review.evidence?.map((ev: any, index: number) => {ev.document} · {evidencePlace(ev)}{pageTypeLabel(ev.page_type)}{ev.historical && 历史评分引用}{ev.target_label && {ev.target_label}}{ev.quote}{ev.url && 查看页面文字}{ev.image_url && {evidencePreviewLabel(ev)}(核对引文)})}{review.score_id && }{revisions.length > 0 && {JSON.stringify(revisions, null, 2)}}>}
>;
}
diff --git a/frontend/tests/batches.spec.ts b/frontend/tests/batches.spec.ts
index 08e09fc..18f404a 100644
--- a/frontend/tests/batches.spec.ts
+++ b/frontend/tests/batches.spec.ts
@@ -183,6 +183,38 @@ test('output validation and insufficient evidence are not presented as network f
await expect(page.getByText(/缺少或尚不足以支撑判断的目标:计划/)).toBeVisible();
});
+test('score filters isolate actionable rows without hiding zero grades or changing data', async ({ page }) => {
+ await setup(page);
+ const writes: string[] = [];
+ page.on('request', request => { if (request.method() !== 'GET') writes.push(request.url()); });
+ await page.route('**/api/projects/11/scores?*', route => route.fulfill({ json: {
+ student_no: '9000000001', name: '学生甲', topic: '项目', totals: { overall: null }, facts: {},
+ rows: ['scored', 'reviewed', 'needs_review', 'failed', 'unscorable'].map((status, i) => ({
+ criterion: { criterion_id: `stage1.${i}`, category: 'stage1', name: `测试项目${i}`, max_score: 20 },
+ status, model_score: status === 'scored' ? 0 : null, teacher_score: status === 'reviewed' ? 0 : null,
+ final_score: ['scored', 'reviewed'].includes(status) ? 0 : null,
+ error: status === 'failed' ? '模型证据未能匹配检索原文' : '', evidence: [],
+ })),
+ } }));
+ await page.goto('/?page=scores&batch_id=71&project_id=11');
+ await expect(page.getByText('已评分 1 项 · 教师已复核 1 项 · 待处理 3 项', { exact: true })).toBeVisible();
+ const rows = page.getByRole('row').filter({ hasText: '测试项目' });
+ await expect(rows).toHaveCount(5);
+ for (const [option, count, name] of [
+ ['仅待处理(3)', 3, '测试项目2'], ['仅待教师复核(1)', 1, '测试项目2'],
+ ['仅评分失败(1)', 1, '测试项目3'], ['仅材料异常(1)', 1, '测试项目4'],
+ ['全部评分项(5)', 5, '测试项目0'],
+ ] as const) {
+ await page.keyboard.press('Escape');
+ await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0);
+ await choose(page, '评分项筛选', option);
+ await expect(rows).toHaveCount(count);
+ await expect(page.getByRole('row').filter({ hasText: name })).toBeVisible();
+ }
+ await expect(page.getByRole('row').filter({ hasText: '测试项目0' })).toContainText('0 / 20');
+ expect(writes).toEqual([]);
+});
+
test('a slow project list keeps the student specified in a multi-project batch link', async ({ page }) => {
const state = await setup(page);
await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{
From a5cb40f83dc788b26c6d669ee0449e2cc8fed2a9 Mon Sep 17 00:00:00 2001
From: aerospaceboy <790286734@qq.com>
Date: Sat, 17 Oct 2026 20:50:25 +0800
Subject: [PATCH 15/17] fix: allow blocked chat import drafts while keeping
execution validation strict
---
backend/tests/test_chat_agent.py | 6 ++++++
backend/wisegrade/actions.py | 11 +++++++++--
backend/wisegrade/api.py | 8 ++++++--
3 files changed, 21 insertions(+), 4 deletions(-)
diff --git a/backend/tests/test_chat_agent.py b/backend/tests/test_chat_agent.py
index c720c7b..cfadc73 100644
--- a/backend/tests/test_chat_agent.py
+++ b/backend/tests/test_chat_agent.py
@@ -214,6 +214,12 @@ def test_duplicate_scan_requires_teacher_file_choice(environment, db):
generated_action = db.get(PendingAction, generated["pending_action"]["id"])
assert generated_action.preview["requires_edit"] is True
assert all(file["status"] == "ignored" for file in generated_action.payload["projects"][0]["files"])
+ with pytest.raises(HTTPException) as blocked:
+ confirm(db, conversation, generated_action.id)
+ assert blocked.value.status_code == 409
+ with pytest.raises(HTTPException) as invalid_selection:
+ prepare(db, conversation, "import", generated_action.payload, teacher_edited=True)
+ assert invalid_selection.value.status_code == 400
proposed = prepare(db, conversation, "import", payload)
assert proposed.preview["requires_edit"] is True
with pytest.raises(HTTPException):
diff --git a/backend/wisegrade/actions.py b/backend/wisegrade/actions.py
index bd46d95..c8542b9 100644
--- a/backend/wisegrade/actions.py
+++ b/backend/wisegrade/actions.py
@@ -6,6 +6,7 @@ from pathlib import Path
from uuid import uuid4
from fastapi import HTTPException
+from pydantic import ValidationError
from sqlalchemy import select
from . import services
@@ -149,7 +150,12 @@ def prepare(db, conversation: Conversation, kind: str, payload: dict, *, teacher
raise HTTPException(400, "整批 Excel 导出须在未限定学生项目的会话中准备")
if kind == "import":
from .api import ImportConfirm
- data = ImportConfirm.model_validate(payload)
+ try:
+ data = ImportConfirm.model_validate(payload, context={
+ "allow_unselected_import_draft": not teacher_edited})
+ except ValidationError as exc:
+ messages = [error["msg"] for error in exc.errors(include_input=False, include_context=False)]
+ raise HTTPException(400, "导入资料尚未满足确认条件:" + ";".join(messages)) from exc
payload = data.model_dump()
require_scope(db, conversation, version_id=data.course_version_id)
if teacher_edited and any(item.student_no == "00000" or item.name == "待确认" or item.topic == "待确认" for item in data.projects):
@@ -164,9 +170,10 @@ def prepare(db, conversation: Conversation, kind: str, payload: dict, *, teacher
scanned.append(match)
if match["status"] != "recognized" or match["warnings"] or any(f["status"] != "recognized" for f in match["files"]):
ambiguous.append(item.relative_dir)
+ unselected = any(not any(file.status != "ignored" for file in item.files) for item in data.projects)
preview = {"course_version_id": data.course_version_id, "projects": scanned,
"selection": payload["projects"], "ambiguous": ambiguous,
- "requires_edit": bool(ambiguous) and not teacher_edited}
+ "requires_edit": (bool(ambiguous) or unselected) and not teacher_edited}
elif kind == "course_ingest":
require_scope(db, conversation, version_id=payload["course_version_id"])
preview = {"course_version_id": payload["course_version_id"], "templates":
diff --git a/backend/wisegrade/api.py b/backend/wisegrade/api.py
index 0a23666..7341ba2 100644
--- a/backend/wisegrade/api.py
+++ b/backend/wisegrade/api.py
@@ -3,7 +3,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
-from pydantic import BaseModel, Field, field_validator, model_validator
+from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -79,9 +79,13 @@ class ProjectConfirm(BaseModel):
return value.strip() if isinstance(value, str) else value
@model_validator(mode="after")
- def validate_selection(self):
+ def validate_selection(self, info: ValidationInfo):
selected = [file for file in self.files if file.status != "ignored"]
if not selected:
+ # Only the chat preview builder supplies this context. HTTP imports,
+ # edited previews and final execution always use strict validation.
+ if (info.context or {}).get("allow_unselected_import_draft"):
+ return self
raise ValueError("至少选择1份资料")
categories = [file.category for file in selected]
if any(category not in CATEGORIES for category in categories):
From f7db0a47ac27287ecd95c3aaf3a6258ea2b6552a Mon Sep 17 00:00:00 2001
From: aerospaceboy <790286734@qq.com>
Date: Sat, 17 Oct 2026 20:50:48 +0800
Subject: [PATCH 16/17] test: align scoring fixtures and upgrade checks with
current contracts
---
backend/tests/test_chat_agent.py | 2 +-
backend/tests/test_chat_sessions.py | 3 ++-
backend/tests/test_evidence_pages.py | 3 ++-
3 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/backend/tests/test_chat_agent.py b/backend/tests/test_chat_agent.py
index cfadc73..36666ea 100644
--- a/backend/tests/test_chat_agent.py
+++ b/backend/tests/test_chat_agent.py
@@ -136,7 +136,7 @@ def test_scoring_uses_the_same_page_image_as_its_saved_evidence(db, environment)
sent_images.extend(images or [])
return ScoreResult(criterion_id=criterion["criterion_id"], score=1,
max_score=criterion["max_score"], reason="测试评分",
- evidence=[{"chunk_id": chunk.id, "page": 2, "quote": "原文证据"}], confidence=.9)
+ evidence=[{"chunk_id": chunk.id, "page": 2, "quote": "原文证据", "target_id": "t0"}], confidence=.9)
# The evidence page must be a substantive body page: a contents page cannot support
# a content judgement, so the source page is classified accordingly during ingest.
diff --git a/backend/tests/test_chat_sessions.py b/backend/tests/test_chat_sessions.py
index 1a236eb..374843d 100644
--- a/backend/tests/test_chat_sessions.py
+++ b/backend/tests/test_chat_sessions.py
@@ -388,7 +388,8 @@ def test_upgrade_from_legacy_0003_migrates_pages_and_citations(session_factory,
config.set_main_option('script_location', str(root / 'migrations'))
command.upgrade(config, 'head')
with session_factory() as db:
- assert db.scalar(text('SELECT version_num FROM alembic_version')) == '0006'
+ from alembic.script import ScriptDirectory
+ assert db.scalar(text('SELECT version_num FROM alembic_version')) == ScriptDirectory.from_config(config).get_current_head()
assert db.get(Conversation, conversation_id).title == '旧记录'
assert db.scalar(select(ChatMessage.content).where(ChatMessage.conversation_id == conversation_id)) == '旧问题'
run = db.scalar(select(AgentRun).where(AgentRun.conversation_id == conversation_id))
diff --git a/backend/tests/test_evidence_pages.py b/backend/tests/test_evidence_pages.py
index 7ddbcfd..8442eee 100644
--- a/backend/tests/test_evidence_pages.py
+++ b/backend/tests/test_evidence_pages.py
@@ -428,13 +428,14 @@ def test_project_and_version_isolation_is_unchanged(db):
def test_batch_snapshot_records_the_evidence_policy(db):
from types import SimpleNamespace
from wisegrade import services
+ from wisegrade.evidence import POLICY_VERSION
from wisegrade.models import ScoringBatch
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
assert created["id"]
batch = db.get(ScoringBatch, created["id"])
plan = batch.rubric_snapshot["evidence_plan"]
- assert plan["version"] == 1
+ assert plan["version"] == POLICY_VERSION
assert plan["content_allowed_page_types"] == ["body", "appendix"]
targets = plan["criteria"]["stage3.data"]
assert [target["use"] for target in targets] == ["content", "content"]
From bc1bafae63ec0e9082f0433a17445cbdeea70bf8 Mon Sep 17 00:00:00 2001
From: aerospaceboy <790286734@qq.com>
Date: Sat, 17 Oct 2026 20:53:39 +0800
Subject: [PATCH 17/17] docs: record retry optimizations and isolated
PostgreSQL validation
---
docs/SCORING_REVIEW_FIX_2026-10-17.md | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/docs/SCORING_REVIEW_FIX_2026-10-17.md b/docs/SCORING_REVIEW_FIX_2026-10-17.md
index 21c8088..99e7f1a 100644
--- a/docs/SCORING_REVIEW_FIX_2026-10-17.md
+++ b/docs/SCORING_REVIEW_FIX_2026-10-17.md
@@ -27,3 +27,20 @@
历史批次分数未自动改写。刷新网页后可重试 #8 的未成功项;已成功评分和教师复核项不重新评分。此次未实际调用收费模型重新给学生打分,因此不声称重试一定消除全部待复核项。
课程导学PPT缺少学生项目汇报内容、报告安全论证不足等情况仍应提示复核。修复检索遗漏和校验反馈,不将证据不足强制变成有效分数。
+
+## 后续优化:重试开销、人工复核保护与快速筛选
+
+- `050aa4e`:复现“教师保存零分后模型超时”的交错场景,修复异常路径覆盖人工复核状态的问题。异常处理重新加锁读取评分,保留教师分与评语。
+- `14cef5c`:多学生批次重试时,已全部评分完成的学生直接跳过模型初始化、事实抽取和一致性检查,批次进度仍正确增加;有待复核项的学生仍正常处理。
+- `5a6e5d4`:模型返回“误差分析(t1):缺少……”等描述时,证据缺口归到对应目标;无法识别目标的说明仍保守保留所有相关内容目标,分数继续为空。
+- `4acc756`:评分页增加全部/待处理/待教师复核/失败/材料异常筛选,以及已评分、教师已复核、待处理数量汇总。仅在浏览器内筛选,不重新评分;零分正常保留,无结果时说明当前筛选下没有评分项。
+- `a5cb40f`:聊天导入遇到重复文件时允许生成待编辑草稿;所有资料都被忽略的草稿强制要求编辑,正式导入与编辑后预览仍严格校验。非法编辑返回400,未修改草稿不能执行(409)。
+- `f7db0a4`:修正扩展测试暴露的旧假模型缺目标编号、迁移头版本和证据政策版本断言,保留真实引用、迁移数据与证据权限的检查。
+
+验证:
+
+- Docker 独立 PostgreSQL/Redis 测试服务内执行完整非 `integration` 后端套件:146 passed,2 deselected;未运行在线模型调用测试。测试服务已停止,未使用正式数据库写入测试数据。
+- Playwright:20 passed;TypeScript/Vite 构建通过。
+- 已更新 API/Worker/Web,服务健康。更新前确认无排队和执行中的任务。
+- 实际网页:批次8显示22项已评分,筛选待处理得到0项与明确空态;批次7筛选出1项待复核。无页面异常、无业务写请求、无模型调用。
+- 批次8完整评分响应在更新前后规范化 JSON 的 SHA-256 一致,22项成绩未被改写。本轮无需重新运行已有评分。