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: [{