diff --git a/backend/tests/test_optimization.py b/backend/tests/test_optimization.py index e6a3563..7af1d43 100644 --- a/backend/tests/test_optimization.py +++ b/backend/tests/test_optimization.py @@ -293,6 +293,30 @@ def test_pdf_html_preserves_zero_and_none(): assert "实验(0 / 10)" in html and "模型原始分:0" in html +@pytest.mark.parametrize("error, label", [ + ("模型证据未关联该片段的检索目标", "模型输出校验失败"), + ("模型请求或JSON校验失败(ConnectError:无法连接模型端点),已重试2次", "模型连接失败"), + ("模型请求或JSON校验失败(模型请求超时,请稍后重试),已重试2次", "模型连接失败"), + ("模型请求或JSON校验失败(HTTP 429:请求限流),已重试2次", "模型请求失败"), + ("模型请求或JSON校验失败(ValidationError:模型返回字段不符合格式),已重试2次", "模型输出校验失败"), + ("后台数据处理异常", "评分失败"), +]) +def test_score_api_and_export_share_precise_failure_labels(sqlite_db, error, label): + from wisegrade.exports import project_result + db = sqlite_db + item = project(db) + created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db) + batch = db.get(ScoringBatch, created["id"]) + criterion = batch.rubric_snapshot["criteria"][0] + db.add(CriterionScore(batch_id=batch.id, project_id=item.id, + criterion_id=criterion["criterion_id"], status="failed", error=error)) + db.commit() + result = project_result(db, batch, item) + assert result["rows"][0]["status_label"] == label + assert f"状态:{label}" in _html_report(result, batch) + assert result["rows"][0]["model_score"] is None + + def test_public_reindex_uses_saved_pages(sqlite_db): db = sqlite_db doc = document(db, None, "课程资料") diff --git a/backend/wisegrade/agent_tools.py b/backend/wisegrade/agent_tools.py index 9475484..72c9af6 100644 --- a/backend/wisegrade/agent_tools.py +++ b/backend/wisegrade/agent_tools.py @@ -9,7 +9,7 @@ from sqlalchemy import select from .actions import prepare, require_scope from .evidence import CONTENT_ALLOWED, STRUCTURE_ALLOWED -from .exports import STATUS_LABELS, batch_projects, page_type_label, project_result +from .exports import batch_projects, page_type_label, project_result from .knowledge_pages import page_links from .models import (Conversation, Course, CourseVersion, IndexTask, KnowledgeChunk, KnowledgeDocument, KnowledgePage, Project, RubricVersion, ScoringBatch, Student, SubmissionFile) @@ -384,7 +384,7 @@ def scores(db, convo, args, _user): "max_score": row["criterion"]["max_score"], "score_id": row["score_id"], "model_score": row["model_score"], "teacher_score": row["teacher_score"], "final_score": row["final_score"], "status": row["status"], - "status_label": STATUS_LABELS.get(row["status"], row["status"]), + "status_label": row["status_label"], "error": str(row["error"] or "")[:300], # Which body targets still lack evidence, so the answer can # explain a review item instead of reporting a score. diff --git a/backend/wisegrade/exports.py b/backend/wisegrade/exports.py index ba2d106..767e985 100644 --- a/backend/wisegrade/exports.py +++ b/backend/wisegrade/exports.py @@ -1,5 +1,6 @@ from html import escape from pathlib import Path +import re from openpyxl import Workbook from openpyxl.styles import Font, PatternFill @@ -18,7 +19,7 @@ STATUS_LABELS = { "reviewed": "教师已复核", "needs_review": "待教师复核", "unscorable": "材料缺失或解析失败", - "failed": "模型调用失败", + "failed": "评分失败", } PAGE_TYPE_LABELS = { "cover": "封面", "abstract": "摘要", "toc": "目录", "body": "正文", @@ -38,6 +39,21 @@ def page_type_label(value) -> str: return PAGE_TYPE_LABELS.get(str(value or "unknown"), "未分类页面") +def criterion_status_label(status: str, error: str = "") -> str: + """One description for API, chat and exported feedback, including old errors.""" + if status != "failed": + return STATUS_LABELS.get(status, status) + # The client wraps transport failures in "模型请求或JSON校验失败" too: + # inspect the concrete cause before that generic wrapper. + if re.search(r"ConnectError|Timeout|Network|无法连接|请求超时", error, re.IGNORECASE): + return "模型连接失败" + if re.search(r"HTTP\s*\d{3}|未配置.*(?:模型|Key)|未配置或无法连接", error, re.IGNORECASE): + return "模型请求失败" + if re.search(r"模型证据|target_id|评分项或满分|JSON校验|ValidationError", error): + return "模型输出校验失败" + return STATUS_LABELS["failed"] + + def project_result(db, batch: ScoringBatch, project: Project): scores = db.scalars(select(CriterionScore).where(CriterionScore.batch_id == batch.id, CriterionScore.project_id == project.id)).all() @@ -81,6 +97,7 @@ def project_result(db, batch: ScoringBatch, project: Project): "model_comment": score.model_comment if score else "", "teacher_comment": score.teacher_comment if score else None, "missing_evidence": score.missing_evidence if score else [], "suggestion": score.suggestion if score else "", "status": score.status if score else "pending", "error": score.error if score else "", + "status_label": criterion_status_label(score.status, score.error or "") if score else STATUS_LABELS["pending"], "evidence_gaps": (score.evidence_gaps or []) if score else [], "evidence": [evidence_dict(item, evidence_links(db, item)) for item in evidence]}) student = db.get(Student, project.student_id) @@ -152,12 +169,12 @@ def _html_report(result, batch): missing = "、".join(row["missing_evidence"] or []) gaps = ";".join(f"{gap.get('target_label')}(检索范围:{'、'.join(page_type_label(t) for t in gap.get('searched_page_types') or []) or '正文'})" for gap in row.get("evidence_gaps") or []) - review = "本项缺少正文证据,已转教师复核。" if row["status"] == "needs_review" else "" + review = "本项证据尚不足以自动评分,已转教师复核。" if row["status"] == "needs_review" else "" rows.append(f"

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

" - f"

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

" + f"

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

" f"

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

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

" f"

{esc(review)}

" - f"

缺失证据:{esc(missing)}

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

" + f"

缺失证据:{esc(missing)}

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

" f"

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

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

" f"
") totals_ = result["totals"]