diff --git a/HANDOFF.md b/HANDOFF.md index 3164a1d..2d18b4b 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,6 +1,6 @@ # WiseGrade(智评)交接说明 -更新日期:2026-09-27。先阅读 [项目指南](docs/GUIDE.md)(部署、使用、技术设计与会话实现);本文件记录当前实现、验证边界和接手后的优先事项。 +更新日期:2026-09-30。先阅读 [项目指南](docs/GUIDE.md)(部署、使用、技术设计与会话实现);本文件记录当前实现、验证边界和接手后的优先事项。 ## 当前状态 @@ -79,6 +79,32 @@ uv run pytest -q 当前代码位于 `main` 分支,阶段提交使用 Conventional Commits。交接前工作区应保持干净;`.env`、`models/`、`data/`、缓存和构建产物由 `.gitignore` 排除。 +## 2026-09-30 目录、摘要与正文证据治理 + +### 本次改动 + +新增 `backend/wisegrade/pages.py`(页面分类、混合页切块、真实章节路径)和 `backend/wisegrade/evidence.py`(证据政策)。`rag.py` 重写为按目标检索:页面类型过滤在候选上限之前完成,语义与中文二元组关键词各自召回后按排名倒数融合,名额先分散到不同页面并对同章节设软上限。`scoring.py` 按必备证据目标分别检索,正文补检走目录导航,内容目标缺失时进入 `needs_review`。迁移 `0006` 添加页面类型、章节、证据目标与历史标记、评分项缺证据记录列,并按已保存的解析结果重新分类页面;索引版本升到 3。 + +关键词召回不再使用 PostgreSQL `to_tsvector('simple', …)`。在隔离测试库中对真实页面实测:该配置把整个中文串当成一个词元,查询"文献调研"无法命中含"第2章文献调研与技术现状"的页面(`文献调研`、`串级PID 结构`、`视觉伺服与闭环控制` 均不匹配),因此旧实现的关键词分实际上长期接近零。现改为字符二元组加 ASCII/数字整词的重叠度。 + +### 修复的缺陷 + +批次 3 的 77 条评分引文中 14 条来自目录页(涉及 9 个评分项),会话 6 的 20 条引用中 5 条指向阶段报告 1 的目录页。根因有两层:目录页文本被当作正文片段参与检索并占用候选名额;评分提示词只要求引文原文和页码一致,没有任何来源类型约束。现在目录页作为导航块单独存放,内容目标只接受正文和附录,引文必须标注它要支持的证据目标。 + +### 本次验证(隔离环境) + +隔离 PostgreSQL + pgvector 测试容器执行 `pytest -q -m 'not integration'`:**59 通过、2 个真实集成测试未运行**。新增 `backend/tests/test_evidence_pages.py` 覆盖阶段报告 1 目录与第 6–7 页文献调研正文的区分、阶段报告 3 目录与第 11–12 页测试正文的区分、最终报告摘要/跨页目录/正文/附录的区分、混合页与目录续页与重复页眉与识别不确定页、内容性判断拒绝目录与摘要与单独标题、结构性判断仍可用目录、正文补检不足时模型分为 null 且状态为待教师复核且教师可复核且缺证据记录保留、Agent 引用页面类型标记与历史评分引用标记、检索过滤先于候选上限、学生项目与课程版本隔离、批次快照记录证据政策。前端 `npm run build` 通过(仍有 Vite 大块提示)。 + +真实报告的分类结果已逐页核对:阶段报告 1 第 1 页封面、第 2–3 页目录、第 4–21 页正文、第 22 页参考文献;阶段报告 3 第 2–3 页目录、第 11–12 页为测试正文;最终报告第 2–3 页摘要、第 4–5 页跨页目录、第 49 页参考文献、第 50–53 页附录。 + +### 未验证与待业务环境执行 + +- 使用真实 DeepSeek 的完整评分批次**未**在本次运行。需要模型的部分(评分调用、事实抽取、一致性检查、Agent 回答)只用测试替身验证了编排与校验逻辑,尚未用真实模型端到端复跑。 +- 未对业务数据库执行迁移、重建索引或重新评分,也未运行 PDF/Excel 导出(导出代码已更新,但未实际生成文件)。 +- 未在 Windows Docker Desktop 实测。 + +业务环境应用步骤与批次 3 复核清单见 [docs/EVIDENCE_POLICY_BATCH3.md](docs/EVIDENCE_POLICY_BATCH3.md)。 + ## 2026-09-27 会话更新 新版 UI 的入口仍使用 `?page=chat`,旧 API 与历史记录保持可读。前端按运行 ID 订阅 SSE,并依靠会话列表和详情轮询恢复后台 Agent 运行;服务进程中断后的运行在 15 分钟超时后标为中断。正式 API 启动时会自动应用 `0004` 迁移。此处的测试结果以本次实际命令为准,历史验证记录仍保留在上方。 diff --git a/README.md b/README.md index b44b1ff..50fdea7 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ WiseGrade 是面向《自动化专业综合实践》课程的教师端自动评 - [会话与业务 Agent 实现](docs/GUIDE.md#agent):任务、数据、工具边界、API 和恢复机制。 - [开发与测试](docs/GUIDE.md#development) · [故障定位](docs/GUIDE.md#troubleshooting)。 +[证据政策上线与批次 3 复核清单](docs/EVIDENCE_POLICY_BATCH3.md) 记录把页面类型证据政策应用到业务环境的步骤,以及批次 3 建议复核的评分项。该文件中的业务数据操作均未执行。 + [开发提示词](docs/prompts/README.md) 集中存放阶段任务说明;[交接记录](HANDOFF.md) 保留实现进展、验证记录和接手事项。 课程原始资料及测试示例仍位于 `docs/` 根目录,应用和测试按现有路径读取;学生原件、模型权重和运行产物不纳入项目文档。 diff --git a/backend/migrations/versions/0006_page_evidence_policy.py b/backend/migrations/versions/0006_page_evidence_policy.py new file mode 100644 index 0000000..10db710 --- /dev/null +++ b/backend/migrations/versions/0006_page_evidence_policy.py @@ -0,0 +1,128 @@ +"""Page-level evidence typing, target-attributed citations and review state. + +Revision ID: 0006 +Revises: 0005 + +Existing indexes are deliberately not upgraded in place. Chunking changed (a contents +page is now stored as navigation rather than body text), so legacy documents keep their +old ``index_version`` and are excluded from retrieval until the reindex task rebuilds +them. That is what stops an old index from being silently used as classified body text. +Reclassification of page types needs no embedding model, so this migration applies it +immediately for every document whose parsed pages are still saved on disk. +""" +import json + +from alembic import op +import sqlalchemy as sa + +revision = "0006" +down_revision = "0005" +branch_labels = None +depends_on = None + + +PAGE_COLUMNS = ( + ("page_type", sa.String(20), "unknown"), + ("mixed", sa.Boolean(), "false"), +) +PAGE_TEXT_COLUMNS = ( + ("section", sa.String(500), ""), + ("chapter", sa.String(500), ""), +) +CHUNK_COLUMNS = ( + ("chapter", sa.String(500), ""), + ("page_type", sa.String(20), "unknown"), + ("block_type", sa.String(20), "legacy"), +) +EVIDENCE_COLUMNS = ( + ("target_id", sa.String(50), ""), + ("target_label", sa.String(300), ""), + ("page_type", sa.String(20), "unknown"), + ("source_role", sa.String(20), "content"), + ("historical", sa.Boolean(), "false"), +) + + +def _columns(table): + return {column["name"] for column in sa.inspect(op.get_bind()).get_columns(table)} + + +def _add(table, name, column_type, default, *, nullable=False): + if name in _columns(table): + return False + op.add_column(table, sa.Column(name, column_type, nullable=nullable, server_default=default)) + return True + + +def upgrade(): + added = {} + for name, column_type, default in PAGE_COLUMNS: + added[f"knowledge_pages.{name}"] = _add("knowledge_pages", name, column_type, default) + added["knowledge_pages.section"] = _add("knowledge_pages", "section", sa.String(500), "") + added["knowledge_pages.chapter"] = _add("knowledge_pages", "chapter", sa.String(500), "") + added["knowledge_pages.confidence"] = _add("knowledge_pages", "confidence", sa.Float(), None, nullable=True) + added["knowledge_pages.classification"] = _add("knowledge_pages", "classification", sa.JSON(), "{}") + for name, column_type, default in CHUNK_COLUMNS: + added[f"knowledge_chunks.{name}"] = _add("knowledge_chunks", name, column_type, default) + for name, column_type, default in EVIDENCE_COLUMNS: + added[f"score_evidences.{name}"] = _add("score_evidences", name, column_type, default) + added["criterion_scores.evidence_gaps"] = _add("criterion_scores", "evidence_gaps", sa.JSON(), "[]") + + db = op.get_bind() + # Quotations written before this policy existed are marked as historical so the + # interface never presents a contents-page quotation as report body evidence. + db.execute(sa.text("UPDATE score_evidences SET historical = true WHERE historical = false")) + # A legacy chunk was never split by block, so it may not be used as content + # evidence until the reindex task rebuilds it. + if added["knowledge_chunks.block_type"]: + db.execute(sa.text("UPDATE knowledge_chunks SET block_type = 'legacy'")) + _reclassify_saved_pages(db) + + +def _json(value): + return json.loads(value) if isinstance(value, str) else value or {} + + +def _reclassify_saved_pages(db): + """Re-derive page types from saved parse output; retryable and audit friendly.""" + from wisegrade.pages import classify_document + + files = {row.id: _json(row.parsed) for row in db.execute(sa.text( + "SELECT id, parsed FROM submission_files")).mappings()} + documents = db.execute(sa.text( + "SELECT id, submission_file_id FROM knowledge_documents WHERE submission_file_id IS NOT NULL")).mappings().all() + update = sa.text("UPDATE knowledge_pages SET page_type = :page_type, mixed = :mixed, section = :section, " + "chapter = :chapter, confidence = :confidence, classification = :classification " + "WHERE document_id = :document_id AND page = :page") + chunk_update = sa.text("UPDATE knowledge_chunks SET page_type = :page_type, section = :section, " + "chapter = :chapter WHERE document_id = :document_id AND page = :page") + rebuilt = 0 + for doc in documents: + pages = files.get(doc.submission_file_id, {}).get("pages", []) + if not pages: + continue + for item in classify_document(pages): + payload = {"page_type": item["page_type"], "mixed": bool(item["mixed"]), + "section": item["section"], "chapter": item["chapter"], + "confidence": item["confidence"], + "classification": json.dumps({**item["signals"], + "classifier_version": item["classifier_version"]}, + ensure_ascii=False, default=str), + "document_id": doc.id, "page": item["page"]} + db.execute(update, payload) + db.execute(chunk_update, payload) + rebuilt += 1 + if rebuilt: + db.execute(sa.text("INSERT INTO audit_logs (action, entity_type, entity_id, details, created_at) " + "VALUES ('reclassify', 'knowledge_document', NULL, :details, CURRENT_TIMESTAMP)"), + {"details": json.dumps({"migration": "0006", "documents": rebuilt}, ensure_ascii=False)}) + + +def downgrade(): + for table, names in (("criterion_scores", ("evidence_gaps",)), + ("score_evidences", tuple(name for name, _, _ in EVIDENCE_COLUMNS)), + ("knowledge_chunks", tuple(name for name, _, _ in CHUNK_COLUMNS)), + ("knowledge_pages", ("page_type", "mixed", "section", "chapter", "confidence", "classification"))): + for name in names: + if name in _columns(table): + op.drop_column(table, name) diff --git a/backend/tests/test_chat_agent.py b/backend/tests/test_chat_agent.py index e9829c4..c720c7b 100644 --- a/backend/tests/test_chat_agent.py +++ b/backend/tests/test_chat_agent.py @@ -138,6 +138,8 @@ def test_scoring_uses_the_same_page_image_as_its_saved_evidence(db, environment) max_score=criterion["max_score"], reason="测试评分", evidence=[{"chunk_id": chunk.id, "page": 2, "quote": "原文证据"}], 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. scored = _score_criterion(db, batch, project, criterion, FakeClient(), retriever=lambda *_, **kwargs: [] if kwargs.get("public_only") else [evidence]) saved = db.scalar(select(ScoreEvidence).where(ScoreEvidence.score_id == scored.id)) @@ -333,7 +335,7 @@ def test_sse_persists_messages_and_tool_failure(session_factory, monkeypatch): def test_tool_arguments_cover_registered_fields_without_review_prose(): numbers = {"course_version_id", "batch_id", "project_id", "score_id", "task_id", "rubric_version_id", - "score", "offset", "limit", "project_offset", "criterion_offset"} + "score", "offset", "limit", "project_offset", "criterion_offset", "document_id"} strings = {"query", "criterion_id", "format", "status", "student_no", "relative_dir"} redacted = {"comment", "reason"} fields = set().union(*(tool.schema.model_fields for tool in agent_tools.TOOLS.values())) diff --git a/backend/tests/test_chat_sessions.py b/backend/tests/test_chat_sessions.py index 2024854..1a236eb 100644 --- a/backend/tests/test_chat_sessions.py +++ b/backend/tests/test_chat_sessions.py @@ -224,7 +224,9 @@ def test_score_citations_link_the_canonical_text_and_image_page(session_factory, assert generated["citations"][0]["url"] == expected assert generated["citations"][0]["image_url"] == expected + "/image" assert generated["citations"][0]["score_url"].startswith("/?page=scores") - assert chat_api._answer_citations(generated["citations"])[0]["url"] == expected + # An answer keeps only the citations it actually used: naming the page retains it. + assert chat_api._answer_citations(generated["citations"], "评分证据见第 2 页。")[0]["url"] == expected + assert chat_api._answer_citations(generated["citations"], "没有引用任何页面。") == [] conversation_id = conversation.id client = client_for(session_factory) try: @@ -268,8 +270,11 @@ def test_late_citations_and_search_snippets_survive_restoration(session_factory, assert response.status_code == 200 detail = client.get(f"/api/conversations/{created['id']}").json() answer = detail['messages'][-1] - assert len(answer['citations']) == 30 - assert [item['page'] for item in answer['citations'][-6:]] == [48] * 6 + # The answer names page 48 only, so only that page's passages are attached; the + # other tool hits were retrieved but not used in the answer. + assert len(answer['citations']) == 6 + assert [item['page'] for item in answer['citations']] == [48] * 6 + # Every tool hit stays available in the tool record for auditing. assert answer['tool_calls'][-1]['result']['citations'][0]['quote'] == '第48页原文片段0' assert detail['runs'][0]['tool_calls'][-1]['result']['citations'][0]['page'] == 48 finally: @@ -383,7 +388,7 @@ 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')) == '0005' + assert db.scalar(text('SELECT version_num FROM alembic_version')) == '0006' 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 new file mode 100644 index 0000000..7ddbcfd --- /dev/null +++ b/backend/tests/test_evidence_pages.py @@ -0,0 +1,444 @@ +"""Regression tests for page-typed evidence retrieval and scoring. + +The real course reports in ``docs/`` are used where the acceptance criteria name them, +plus synthetic pages for the cases those files do not contain (a genuinely mixed page, +a contents continuation, a repeated header, an unsettled page). A constant embedding +vector is used on purpose: it leaves semantic similarity flat, so the tests prove the +Chinese keyword recall and the page-type filtering, which is where the reported defect +came from. +""" +from pathlib import Path + +import pymupdf as fitz +import pytest +from sqlalchemy import select + +from wisegrade.evidence import normalize_targets +from wisegrade.llm import ModelError, ScoreResult, validate_score +from wisegrade.models import CriterionScore, KnowledgeChunk, Project, ScoreEvidence, Student +from wisegrade.pages import classify_document +from wisegrade.rag import retrieve, retrieve_targets +from wisegrade.scoring import _score_criterion + +#: Constant vectors: semantic ranking contributes nothing, so recall is lexical. +FLAT = lambda texts: [[0.1] * 1024 for _ in texts] + +COURSE_DOCS = ("阶段报告1.pdf", "阶段报告3.pdf", "自动化专业综合实践报告.pdf") + + +def docs_root() -> Path: + for candidate in (Path("/app/course-docs"), Path(__file__).resolve().parents[2] / "docs", + Path(__file__).resolve().parents[1] / "docs"): + if all((candidate / name).is_file() for name in COURSE_DOCS): + return candidate + pytest.skip("课程示例报告不可用") + + +def report_pages(name: str) -> list[dict]: + with fitz.open(docs_root() / name) as pdf: + return [{"page_number": index + 1, "text": page.get_text(sort=True)} for index, page in enumerate(pdf)] + + +def add_project(db, number: str): + student = Student(student_no=number, name=f"测试学生{number[-2:]}") + db.add(student) + db.flush() + project = Project(course_version_id=1, student_id=student.id, topic="证据治理测试", + source_dir=f"tests/evidence-{number}") + db.add(project) + db.flush() + return project + + +def ingest_pages(db, project, pages, *, category="stage1", title="报告.pdf", source_hash="hash"): + from wisegrade.rag import ingest + return ingest(db, course_version_id=1, project_id=project.id, submission_file_id=None, title=title, + category=category, source_hash=source_hash, pages=pages, embedder=FLAT) + + +def page_types(db, project): + from wisegrade.models import KnowledgeDocument, KnowledgePage + document = db.scalar(select(KnowledgeDocument).where(KnowledgeDocument.project_id == project.id)) + rows = db.scalars(select(KnowledgePage).where(KnowledgePage.document_id == document.id) + .order_by(KnowledgePage.page)).all() + return {row.page: row.page_type for row in rows} + + +# --------------------------------------------------------------------------- # +# 1. Stage report 1: contents pages versus the literature review body # +# --------------------------------------------------------------------------- # + +def test_stage1_contents_is_separated_from_literature_body(db): + project = add_project(db, "123460001") + pages = report_pages("阶段报告1.pdf") + ingest_pages(db, project, pages) + types = page_types(db, project) + # The report's contents spans pages 2 and 3; the literature review is pages 6-7. + assert types[1] == "cover" + assert types[2] == types[3] == "toc" + assert types[6] == types[7] == "body" + assert types[22] == "references" + + criterion = {"criterion_id": "stage1.literature", "category": "stage1", "max_score": 20, + "description": "文献调研和技术现状总结", "required_evidence": ["参考文献", "技术现状"]} + targets = normalize_targets(criterion["required_evidence"]) + selection = retrieve_targets(db, course_version_id=1, project_id=project.id, targets=targets, + category="stage1", embedder=FLAT) + pages_hit = {chunk["page"] for chunk in selection["evidence"]} + # The literature analysis really exists on pages 6-7 and must be recalled there. + assert pages_hit & {6, 7}, pages_hit + content_types = {chunk["page_type"] for chunk in selection["evidence"] + if chunk["target_use"] == "content"} + assert content_types <= {"body", "appendix"}, content_types + # The structured target may cite the reference list, and it is labelled as such. + structure = [chunk for chunk in selection["evidence"] if chunk["target_use"] == "structure"] + assert all(chunk["page_type"] in ("toc", "references", "body", "cover", "abstract") + for chunk in structure) + + +def test_stage1_literature_analysis_is_not_taken_from_the_contents_page(): + pages = report_pages("阶段报告1.pdf") + classified = {item["page"]: item for item in classify_document(pages)} + toc_text = "\n".join(block["text"] for block in classified[2]["blocks"] if block["kind"] == "toc") + assert "文献调研与技术现状" in toc_text # the heading really is in the contents + body_text = "\n".join(block["text"] for block in classified[6]["blocks"] if block["kind"] == "body") + assert "PID 控制因其结构简单" in body_text # the analysis really is in the body + # The contents page carries no body block, so it cannot supply content evidence. + assert not any(block["kind"] == "body" and block.get("substantive") + for block in classified[2]["blocks"]) + + +# --------------------------------------------------------------------------- # +# 2. Stage report 3: contents versus the testing body # +# --------------------------------------------------------------------------- # + +def test_stage3_contents_is_separated_from_testing_body(db): + project = add_project(db, "123460002") + ingest_pages(db, project, report_pages("阶段报告3.pdf"), category="stage3") + types = page_types(db, project) + assert types[2] == types[3] == "toc" + assert types[11] == types[12] == "body" + + criterion = {"criterion_id": "stage3.data", "category": "stage3", "max_score": 20, + "description": "实验数据和初步分析", "required_evidence": ["实验数据", "分析"]} + selection = retrieve_targets(db, course_version_id=1, project_id=project.id, + targets=normalize_targets(criterion["required_evidence"]), + category="stage3", embedder=FLAT) + hits = [(chunk["page"], chunk["page_type"], chunk["text"][:40]) for chunk in selection["evidence"]] + pages_hit = {chunk["page"] for chunk in selection["evidence"]} + # The testing description on pages 11-12 must be reachable, not the contents. + assert pages_hit & {11, 12}, hits + assert all(chunk["page_type"] in ("body", "appendix") for chunk in selection["evidence"]), hits + # Finding the page is not a score: the evidence only has to be reachable. + assert any("测试" in chunk["text"] or "试验" in chunk["text"] for chunk in selection["evidence"]) + + +# --------------------------------------------------------------------------- # +# 3. Final report: abstract, cross-page contents, body # +# --------------------------------------------------------------------------- # + +def test_final_report_abstract_cross_page_contents_and_body(db): + project = add_project(db, "123460003") + ingest_pages(db, project, report_pages("自动化专业综合实践报告.pdf"), category="final_report") + types = page_types(db, project) + assert types[1] == "cover" + assert types[2] == types[3] == "abstract" + # The contents continues onto a second page (roman numeral IV) and is still contents. + assert types[4] == types[5] == "toc" + assert types[6] == "body" + assert types[49] == "references" + # Appendix pages stay usable for content, and are labelled as appendices. + assert types[50] == "appendix" + + +def test_contents_page_still_supports_chapter_completeness(): + """A structural criterion keeps using the contents page; that is its purpose.""" + pages = report_pages("自动化专业综合实践报告.pdf") + classified = {item["page"]: item for item in classify_document(pages)} + from wisegrade.pages import toc_entries + entries = toc_entries(classified[4]["blocks"]) + assert len(entries) >= 20 + assert any("第1 章" in entry or "第1章" in entry for entry in entries) + + criterion = {"criterion_id": "stage1.format", "max_score": 5, "description": "报告完整性和规范性", + "required_evidence": ["标题", "章节", "参考文献"]} + targets = normalize_targets(criterion["required_evidence"]) + assert all(target["use"] == "structure" for target in targets) + # A structure target admits the contents page; a content target never does. + assert all("toc" in target["allowed_page_types"] for target in targets) + + +# --------------------------------------------------------------------------- # +# 4. Mixed pages, continuations, repeated headers, unsettled pages # +# --------------------------------------------------------------------------- # + +def test_mixed_page_contents_continuation_header_and_uncertain_page(): + pages = [ + {"page_number": 1, "text": "自动化专业综合实践\n阶段性报告\n姓名:张三\n学号:1234567890"}, + # A contents page that also carries the start of the first chapter. + {"page_number": 2, "text": "目录\n第1 章 项目背景.......... 1\n第2 章 需求分析.......... 3\n" + "第1 章 项目背景\n本项目面向智能检测场景,通过多传感器融合实现目标识别。"}, + # Contents continued: dotted entries and nothing else. + {"page_number": 3, "text": "3.2 性能测试.......... 9\n3.3 结果分析.......... 11\n参考文献.......... 15"}, + # A repeated header plus real prose. + {"page_number": 4, "text": "自动化专业综合实践\n2.1 需求分析\n" + "系统需要完成目标检测、定位与结果输出,并在标准场地内稳定运行。\n" + "自动化专业综合实践"}, + # A section title with no prose at all. + {"page_number": 5, "text": "自动化专业综合实践\n3.1 功能测试"}, + ] + classified = {item["page"]: item for item in classify_document(pages)} + # The repeated header is stripped from indexed text. + from wisegrade.pages import clean_page_lines, strip_repeated_lines + repeated = strip_repeated_lines(pages) + assert "自动化专业综合实践" in repeated + assert "自动化专业综合实践" not in clean_page_lines(pages[3], repeated) + + assert classified[1]["page_type"] == "cover" + assert classified[2]["page_type"] == "toc" and classified[2]["mixed"] is True + # A mixed page keeps a usable body block instead of dropping the whole page. + body_blocks = [b for b in classified[2]["blocks"] if b["kind"] == "body" and b.get("substantive")] + assert body_blocks and "多传感器融合" in body_blocks[0]["text"] + # The contents continuation is recognised as contents, not body. + assert classified[3]["page_type"] == "toc" + assert classified[4]["page_type"] == "body" + # A page with only a section title is not silently ordinary body text. + assert classified[5]["page_type"] == "unknown" + + +def test_mixed_page_body_block_is_indexed_as_body_not_navigation(db): + project = add_project(db, "123460004") + pages = [{"page_number": 2, "text": "目录\n第1 章 项目背景.......... 1\n" + "第1 章 项目背景\n本项目面向智能检测场景,通过多传感器融合实现目标识别。"}] + ingest_pages(db, project, pages) + chunk = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id.isnot(None), + KnowledgeChunk.page == 2, + KnowledgeChunk.block_type == "body")) + # The body half of the mixed page survives as its own searchable chunk. + assert chunk is not None and "多传感器融合" in chunk.text + navigation = db.scalars(select(KnowledgeChunk).where(KnowledgeChunk.page == 2, + KnowledgeChunk.block_type == "toc")).all() + assert navigation and all("多传感器融合" not in row.text for row in navigation) + + +def test_uncertain_page_is_not_offered_as_body_content(db): + project = add_project(db, "123460005") + pages = [{"page_number": 1, "text": "封面"}, + {"page_number": 2, "text": "第1 章 项目背景与需求分析"}, + {"page_number": 3, "text": "第1 章 项目背景与需求分析\n系统面向智能检测场景,完成目标识别与定位。"}] + ingest_pages(db, project, pages) + types = page_types(db, project) + assert types[2] == "unknown" + body_chunks = db.scalars(select(KnowledgeChunk).where(KnowledgeChunk.page_type == "body")).all() + assert all(row.page != 2 for row in body_chunks) + + +# --------------------------------------------------------------------------- # +# 5. Content judgements cannot rest on navigation pages # +# --------------------------------------------------------------------------- # + +def test_content_judgement_rejects_navigation_sources_but_structure_accepts_them(): + criterion = {"criterion_id": "stage3.data", "max_score": 20, "description": "实验数据和初步分析", + "required_evidence": ["实验数据", "分析"]} + toc = {"chunk_id": 1, "page": 2, "page_type": "toc", "text": "第3 章 系统测试与验证 ......... 8"} + result = ScoreResult(criterion_id="stage3.data", score=18, max_score=20, reason="看目录", + evidence=[{"chunk_id": 1, "page": 2, "quote": "系统测试与验证", "target_id": "t0"}], + confidence=.9) + with pytest.raises(ModelError): + validate_score(result, criterion, [toc]) + + structure = {"criterion_id": "stage1.format", "max_score": 5, "description": "报告完整性和规范性", + "required_evidence": ["章节"]} + ok = ScoreResult(criterion_id="stage1.format", score=4, max_score=5, reason="章节完整", + evidence=[{"chunk_id": 1, "page": 2, "quote": "第3 章 系统测试与验证", "target_id": "t0"}], + confidence=.9) + assert validate_score(ok, structure, [toc]).score == 4 + + +# --------------------------------------------------------------------------- # +# 6. Insufficient body evidence becomes a teacher review, never a low score # +# --------------------------------------------------------------------------- # + +def test_insufficient_body_evidence_goes_to_review_with_a_null_model_score(db): + from types import SimpleNamespace + from wisegrade import services + from wisegrade.models import ScoringBatch, SubmissionFile + + project = add_project(db, "123460006") + created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db) + batch = db.get(ScoringBatch, created["id"]) + criterion = next(item for item in batch.rubric_snapshot["criteria"] + if item["criterion_id"] == "stage3.data") + file = SubmissionFile(project_id=project.id, category="stage3", relative_path="test/review/report.pdf", + mime_type="application/pdf", size=1, mtime=1, sha256="c" * 64, + recognition_status="recognized", parse_status="ready") + db.add(file) + db.flush() + # The material only contains a cover page and a contents page: no body at all. + pages = [{"page_number": 1, "text": "阶段性报告\n姓名:张三\n学号:1234567890"}, + {"page_number": 2, "text": "目录\n第3 章 系统测试与验证.......... 8\n第4 章 结论.......... 12"}] + file.parsed = {"pages": pages} + ingest_pages(db, project, pages, category="stage3", title="report.pdf", source_hash="c" * 64) + + class NeverCalled: + def json_call(self, *_, **__): + raise AssertionError("缺少正文证据时不应调用评分模型") + + scored = _score_criterion(db, batch, project, criterion, NeverCalled(), + retriever=lambda *_, **kw: [] if kw.get("public_only") else retrieve( + *_, **{**kw, "embedder": FLAT})) + assert scored.status == "needs_review" + assert scored.model_score is None + assert scored.evidence_gaps and scored.evidence_gaps[0]["target_label"] + assert scored.evidence_gaps[0]["searched_page_types"] + # The criterion shows as awaiting review, and the batch is not reported as complete. + counts = {row.status for row in db.scalars(select(CriterionScore).where( + CriterionScore.batch_id == batch.id)).all()} + assert "needs_review" in counts + + +def test_teacher_can_review_a_needs_review_item_and_the_gap_record_survives(db): + from types import SimpleNamespace + from wisegrade import services + + project = add_project(db, "123460007") + created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db) + score = CriterionScore(batch_id=created["id"], project_id=project.id, criterion_id="stage3.data", + model_score=None, status="needs_review", + evidence_gaps=[{"target_id": "t0", "target_label": "实验数据", + "searched_page_types": ["body", "appendix"], + "reason": "补检后仍未找到正文证据"}]) + db.add(score) + db.commit() + result = services.review_score(score.id, SimpleNamespace(score=17, comment="教师核对原始材料", + reason="材料确实存在测试章节"), db) + assert result["final_score"] == 17 and result["model_score"] is None + reviewed = db.get(CriterionScore, score.id) + assert reviewed.status == "reviewed" + # The original retrieval problem stays on the record after the teacher decides. + assert reviewed.evidence_gaps[0]["target_label"] == "实验数据" + + +# --------------------------------------------------------------------------- # +# 7. Agent citations only carry sources that were actually used # +# --------------------------------------------------------------------------- # + +def test_search_tools_label_page_types_and_mark_historical_citations(db): + from wisegrade import agent_tools + from wisegrade.models import Conversation + + project = add_project(db, "123460008") + ingest_pages(db, project, [ + {"page_number": 2, "text": "目录\n第3 章 系统测试与验证.......... 8"}, + {"page_number": 11, "text": "第3 章 系统测试与验证\n在自主巡线测试中,底盘记录了直线段与弯道的时间数据。"}, + ], category="stage3") + conversation = Conversation(title="检索", course_version_id=1, project_id=project.id) + db.add(conversation) + db.commit() + original = agent_tools.retrieve + agent_tools.retrieve = lambda *args, **kwargs: original(*args, **kwargs, embedder=FLAT) + try: + content = agent_tools.execute(db, conversation, "search_project_material", + {"project_id": project.id, "query": "系统测试与验证"}, "") + assert content["found"] + # Content search never returns the contents page as body evidence. + assert all(item["page_type"] in ("body", "appendix") for item in content["citations"]) + assert all(item["source_role"] == "content" for item in content["citations"]) + assert all(item["citation_id"].startswith("chunk:") for item in content["citations"]) + + structure = agent_tools.execute(db, conversation, "search_project_structure", + {"project_id": project.id, "query": "系统测试"}, "") + assert structure["found"] + assert all(item["source_role"] == "structure" for item in structure["citations"]) + # A structural hit is explicitly declared as unable to prove chapter content. + assert any(item["page_type"] == "toc" for item in structure["citations"]) + assert "不能证明章节内容" in structure["note"] + finally: + agent_tools.retrieve = original + + +def test_scores_tool_marks_historical_score_evidence(db): + from types import SimpleNamespace + from wisegrade import agent_tools, services + from wisegrade.models import Conversation + + project = add_project(db, "123460009") + created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db) + score = CriterionScore(batch_id=created["id"], project_id=project.id, criterion_id="stage1.format", + model_score=4, status="scored") + db.add(score) + db.flush() + db.add(ScoreEvidence(score_id=score.id, document_id=None, submission_file_id=None, page=2, + quote="第1 章 项目背景与需求分析", image_path=None, page_type="toc", + target_label="章节", historical=True)) + conversation = Conversation(title="评分", course_version_id=1, batch_id=created["id"]) + db.add(conversation) + db.commit() + result = agent_tools.execute(db, conversation, "get_scores", + {"batch_id": created["id"], "project_id": project.id}, "") + citations = result["citations"] + assert citations and citations[0]["historical"] is True + assert citations[0]["source"] == "historical_score_evidence" + assert citations[0]["usage"] == "历史评分引用" + + +# --------------------------------------------------------------------------- # +# 8. Isolation and existing behaviour # +# --------------------------------------------------------------------------- # + +def test_page_filtering_happens_before_the_candidate_limit(db): + """A contents page must not crowd a body page out of the candidate window.""" + project = add_project(db, "123460010") + pages = [{"page_number": 2, "text": "目录\n第3 章 系统测试与验证.......... 8"}] + for number in range(3, 25): + pages.append({"page_number": number, + "text": f"第3 章 系统测试与验证\n第{number} 页记录了系统测试的实测数据与分析过程。"}) + ingest_pages(db, project, pages, category="stage3") + + unrestricted = retrieve(db, course_version_id=1, project_id=project.id, query="系统测试与验证", + category="stage3", limit=3, embedder=FLAT, allowed_page_types=None) + filtered = retrieve(db, course_version_id=1, project_id=project.id, query="系统测试与验证", + category="stage3", limit=3, embedder=FLAT, + allowed_page_types=["body", "appendix"], substantive_only=True) + assert filtered and all(chunk["page_type"] in ("body", "appendix") for chunk in filtered) + assert {chunk["page"] for chunk in filtered} != {2} + # Without the filter the contents page is a candidate; the filter removes it before + # the limit is applied, so three real body pages still come back. + assert len(filtered) == 3 + assert any(chunk["page_type"] == "toc" for chunk in unrestricted) + + +def test_project_and_version_isolation_is_unchanged(db): + first = add_project(db, "123460011") + second = add_project(db, "123460012") + ingest_pages(db, first, [{"page_number": 3, "text": "第3 章 系统测试与验证\n一号项目记录了巡线测试数据。"}], + category="stage3", title="一号.pdf", source_hash="one") + ingest_pages(db, second, [{"page_number": 3, "text": "第3 章 系统测试与验证\n二号项目的私有测试结果。"}], + category="stage3", title="二号.pdf", source_hash="two") + hits = retrieve(db, course_version_id=1, project_id=first.id, category="stage3", + query="系统测试与验证", embedder=FLAT, allowed_page_types=["body", "appendix"]) + assert hits and all(hit["project_id"] == first.id for hit in hits) + assert "私有" not in str(hits) + # Course material stays in its own namespace and is not mixed into project search. + public = retrieve(db, course_version_id=1, project_id=first.id, query="系统测试与验证", + embedder=FLAT, public_only=True) + assert all(hit["project_id"] is None for hit in public) + + +def test_batch_snapshot_records_the_evidence_policy(db): + from types import SimpleNamespace + from wisegrade import services + 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["content_allowed_page_types"] == ["body", "appendix"] + targets = plan["criteria"]["stage3.data"] + assert [target["use"] for target in targets] == ["content", "content"] + assert all("toc" not in target["allowed_page_types"] for target in targets) + # The legacy string form is read into typed targets without being rewritten. + assert [item["required_evidence"] for item in batch.rubric_snapshot["criteria"] + if item["criterion_id"] == "stage3.data"] == [["实验数据", "分析"]] diff --git a/backend/tests/test_foundation.py b/backend/tests/test_foundation.py index 94da1b3..df95000 100644 --- a/backend/tests/test_foundation.py +++ b/backend/tests/test_foundation.py @@ -10,7 +10,7 @@ from wisegrade.storage import classify, extract_identity, parse_student_folder, def test_postgresql_migrations_and_vector_extension(db): assert db.bind.dialect.name == "postgresql" - assert db.scalar(text("SELECT version_num FROM alembic_version")) == "0005" + assert db.scalar(text("SELECT version_num FROM alembic_version")) == "0006" assert db.scalar(text("SELECT extname FROM pg_extension WHERE extname = 'vector'")) == "vector" diff --git a/backend/tests/test_llm_and_rag.py b/backend/tests/test_llm_and_rag.py index 5af2e03..f1ff671 100644 --- a/backend/tests/test_llm_and_rag.py +++ b/backend/tests/test_llm_and_rag.py @@ -4,7 +4,7 @@ from sqlalchemy import select from wisegrade.llm import DeepSeekClient, FactResult, ModelError, ScoreResult, validate_score from wisegrade.models import AppSetting, KnowledgeChunk, KnowledgePage, Project, Student -from wisegrade.rag import ingest, local_encoder, retrieve, split_pages +from wisegrade.rag import INDEX_VERSION, ingest, local_encoder, retrieve, split_pages from wisegrade.settings_store import model_config, save_model_config @@ -21,7 +21,7 @@ def make_project(db, number): def test_json_validation_and_evidence(): criterion = {"criterion_id": "stage1.problem", "max_score": 25} - chunks = [{"chunk_id": 7, "page": 2, "text": "工程需求是实时目标识别"}] + chunks = [{"chunk_id": 7, "page": 2, "text": "工程需求是实时目标识别", "page_type": "body"}] result = ScoreResult(criterion_id="stage1.problem", score=19, max_score=25, reason="满足需求", evidence=[{"chunk_id": 7, "page": 2, "quote": "实时目标识别"}], confidence=.8) assert validate_score(result, criterion, chunks).score == 19 @@ -33,8 +33,8 @@ def test_json_validation_and_evidence(): def test_evidence_quote_resolves_pdf_line_breaks_and_page(): criterion = {"criterion_id": "stage1.problem", "max_score": 25} - chunks = [{"chunk_id": 7, "page": 2, "text": "机器人\n控制精度:2 cm。"}, - {"chunk_id": 8, "page": 3, "text": "其他实验结果"}] + chunks = [{"chunk_id": 7, "page": 2, "text": "机器人\n控制精度:2 cm。", "page_type": "body"}, + {"chunk_id": 8, "page": 3, "text": "其他实验结果", "page_type": "body"}] result = ScoreResult(criterion_id="stage1.problem", score=19, max_score=25, reason="有指标", evidence=[{"chunk_id": 8, "page": 3, "quote": "机器人控制精度:2 cm"}], confidence=.8) checked = validate_score(result, criterion, chunks) @@ -42,6 +42,59 @@ def test_evidence_quote_resolves_pdf_line_breaks_and_page(): assert checked.evidence[0].quote in chunks[0]["text"] +def test_content_evidence_rejects_navigation_and_heading_only_sources(): + """A contents page, an abstract or a bare heading can never prove content.""" + criterion = {"criterion_id": "stage3.data", "max_score": 20, + "description": "实验数据和初步分析", "required_evidence": ["实验数据", "分析"]} + body = {"chunk_id": 1, "page": 11, "page_type": "body", + "text": "在自主巡线测试中,底盘在标准黑色轨迹线场地内运行,记录了直线段与弯道的时间数据。"} + good = ScoreResult(criterion_id="stage3.data", score=16, max_score=20, reason="有实测数据", + evidence=[{"chunk_id": 1, "page": 11, "quote": "在自主巡线测试中", "target_id": "t0"}], + confidence=.8) + assert validate_score(good, criterion, [body]).score == 16 + + toc = {"chunk_id": 2, "page": 2, "page_type": "toc", + "text": "第3 章系统测试与验证 ......... 8"} + bad = ScoreResult(criterion_id="stage3.data", score=16, max_score=20, reason="看目录", + evidence=[{"chunk_id": 2, "page": 2, "quote": "系统测试与验证", "target_id": "t0"}], + confidence=.8) + # The rejection is what matters; either guard may fire depending on the target's + # allowed page types. + with pytest.raises(ModelError, match="内容证据|不允许的页面类型"): + validate_score(bad, criterion, [toc]) + + abstract = {"chunk_id": 3, "page": 2, "page_type": "abstract", + "text": "摘要 本文实现了自瞄移动机器人系统,采用多处理器分布式架构。"} + bad.evidence[0].chunk_id, bad.evidence[0].page, bad.evidence[0].quote = 3, 2, "本文实现了自瞄移动机器人系统" + with pytest.raises(ModelError, match="内容证据|不允许的页面类型"): + validate_score(bad, criterion, [abstract]) + + heading_only = {"chunk_id": 4, "page": 11, "page_type": "body", "text": "第3 章 系统测试与验证"} + bad.evidence[0].chunk_id, bad.evidence[0].page, bad.evidence[0].quote = 4, 11, "第3 章 系统测试与验证" + with pytest.raises(ModelError, match="章节标题"): + validate_score(bad, criterion, [heading_only]) + + +def test_structural_targets_may_use_the_contents_page(): + """A structure target legitimately reads the contents list; a content one may not.""" + criterion = {"criterion_id": "stage1.format", "max_score": 5, "description": "报告完整性和规范性", + "required_evidence": ["标题", "章节", "参考文献"]} + toc = {"chunk_id": 5, "page": 2, "page_type": "toc", + "text": "第1 章项目背景与需求分析 .......... 1\n第2 章文献调研与技术现状 .......... 3"} + result = ScoreResult(criterion_id="stage1.format", score=4, max_score=5, reason="章节完整", + evidence=[{"chunk_id": 5, "page": 2, "quote": "第1 章项目背景与需求分析", "target_id": "t1"}], + confidence=.8) + checked = validate_score(result, criterion, [toc]) + assert checked.evidence[0].page == 2 + + +def test_model_may_return_a_null_score_when_content_evidence_is_missing(): + result = ScoreResult(criterion_id="stage3.data", score=None, max_score=20, + reason="仅检索到目录页,无法判断测试内容", evidence=[], + missing_evidence=["实验数据"], confidence=.2) + assert result.score is None + + @pytest.mark.integration def test_deepseek_adapter_live_with_image(tmp_path): image = tmp_path / "page.png" @@ -87,7 +140,7 @@ def test_stale_knowledge_index_reuses_document_id(db): db.commit() reindexed = ingest(db, course_version_id=1, project_id=project_id, submission_file_id=None, title="报告", category="stage3", source_hash="same-source", pages=pages) - assert reindexed.id == document.id and reindexed.index_version == 2 + assert reindexed.id == document.id and reindexed.index_version == INDEX_VERSION assert db.scalar(select(KnowledgeChunk.text).where(KnowledgeChunk.document_id == document.id)) == pages[0]["text"] assert db.get(KnowledgePage, (document.id, 1)).text == pages[0]["text"] diff --git a/backend/wisegrade/actions.py b/backend/wisegrade/actions.py index 15438b7..42fa84e 100644 --- a/backend/wisegrade/actions.py +++ b/backend/wisegrade/actions.py @@ -210,7 +210,7 @@ def prepare(db, conversation: Conversation, kind: str, payload: dict, *, teacher else: raise HTTPException(400, "不支持的操作") expected = _state(db, kind, payload) - if kind == "start_batch" and expected["status"] not in ("created", "failed", "partial"): + if kind == "start_batch" and expected["status"] not in ("created", "failed", "partial", "review_required"): raise HTTPException(409, "批次不可启动或重试") if kind == "index_retry" and expected["status"] != "failed": raise HTTPException(409, "仅失败任务可重试") diff --git a/backend/wisegrade/agent_tools.py b/backend/wisegrade/agent_tools.py index ef24cc5..e5f6dfb 100644 --- a/backend/wisegrade/agent_tools.py +++ b/backend/wisegrade/agent_tools.py @@ -8,10 +8,12 @@ from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import select from .actions import prepare, require_scope -from .exports import batch_projects, project_result +from .evidence import CONTENT_ALLOWED, STRUCTURE_ALLOWED +from .exports import STATUS_LABELS, batch_projects, page_type_label, project_result from .knowledge_pages import page_links -from .models import (Conversation, Course, CourseVersion, IndexTask, KnowledgeDocument, +from .models import (Conversation, Course, CourseVersion, IndexTask, KnowledgeChunk, KnowledgeDocument, Project, RubricVersion, ScoringBatch, Student, SubmissionFile) +from .pages import toc_entries from .rag import retrieve from .rules import totals from .storage import directories, scan @@ -52,6 +54,14 @@ class SearchProject(ProjectId): query: str = Field(min_length=2, max_length=300) +class DocumentId(Args): + document_id: int = Field(gt=0) + + +class SearchDocument(DocumentId): + query: str = Field(min_length=2, max_length=300) + + class Directory(Args): relative_dir: str = Field(min_length=1, max_length=500) @@ -67,7 +77,7 @@ class BatchId(Args): class ScoresFilterArgs(BatchId): project_id: int | None = Field(default=None, gt=0) - status: Literal["pending", "scored", "reviewed", "failed", "unscorable"] | None = None + status: Literal["pending", "scored", "reviewed", "needs_review", "failed", "unscorable"] | None = None class ScoresArgs(ScoresFilterArgs): @@ -201,9 +211,14 @@ def project(db, convo, args, _user): "url": f"/?page=projects&project_id={item.id}"} -def _search(db, version_id, project_id, query, *, public_only=False): +def _search(db, version_id, project_id, query, *, public_only=False, scopes=None, limit=6): + """Content search under the evidence policy: navigation pages are never body text.""" + scopes = scopes or ("content",) + allowed = sorted({page_type for scope in scopes for page_type in STRUCTURE_ALLOWED} if "structure" in scopes + else {page_type for scope in scopes for page_type in CONTENT_ALLOWED}) chunks = retrieve(db, course_version_id=version_id, project_id=project_id or 0, query=query, - limit=6, include_public=False, public_only=public_only) + limit=limit, include_public=False, public_only=public_only, + allowed_page_types=allowed, substantive_only="content" in scopes) citations = [] for chunk in chunks: doc = db.get(KnowledgeDocument, chunk["document_id"]) @@ -211,8 +226,15 @@ def _search(db, version_id, project_id, query, *, public_only=False): citations.append({"chunk_id": chunk["chunk_id"], "document_id": chunk["document_id"], "title": doc.title if doc else "资料", "page": chunk["page"], "quote": chunk["text"][:500], "project_id": chunk["project_id"], + "page_type": chunk.get("page_type") or links.get("page_type") or "unknown", + "page_type_label": page_type_label(chunk.get("page_type")), + "section": chunk.get("section") or "", + "block_type": chunk.get("block_type") or "body", + "source_role": "structure" if "structure" in scopes else "content", + # Stable identity so an answer can cite exactly what it used. + "citation_id": f"chunk:{chunk['chunk_id']}", **links}) - return {"citations": citations, "found": bool(citations)} + return {"citations": citations, "found": bool(citations), "scopes": list(scopes)} def search_course(db, convo, args, _user): @@ -225,6 +247,84 @@ def search_project(db, convo, args, _user): return _search(db, item.course_version_id, item.id, args.query) +def search_structure(db, convo, args, _user): + """Structure and contents lookup: navigation pages plus body headings. + + This tool answers questions about chapters, sections and report layout. Its results + are explicitly labelled as structure, so they can never be mistaken for content + evidence about what the student implemented or measured. + """ + item = _project(db, convo, args.project_id) + chunks = retrieve(db, course_version_id=item.course_version_id, project_id=item.id, query=args.query, + limit=8, include_public=False, allowed_page_types=sorted(STRUCTURE_ALLOWED), + substantive_only=False) + citations = [] + for chunk in chunks: + doc = db.get(KnowledgeDocument, chunk["document_id"]) + links = page_links(db, chunk["document_id"], chunk["page"]) + page_type = chunk.get("page_type") or links.get("page_type") or "unknown" + citations.append({ + "chunk_id": chunk["chunk_id"], "document_id": chunk["document_id"], + "title": doc.title if doc else "资料", "page": chunk["page"], + "quote": chunk["text"][:500], "project_id": chunk["project_id"], + "page_type": page_type, "page_type_label": page_type_label(page_type), + "section": chunk.get("section") or "", "block_type": chunk.get("block_type") or "body", + "source_role": "structure", + "citation_id": f"chunk:{chunk['chunk_id']}", + "toc_entries": toc_entries([{"kind": "toc", "text": chunk["text"]}])[:40] + if page_type == "toc" else [], + **links}) + return {"citations": citations, "found": bool(citations), "scopes": ["structure"], + "note": "这些是结构或目录信息,只能说明章节安排,不能证明章节内容。"} + + +def get_document_pages(db, convo, args, _user): + """List a document's pages with page type, so a caller can navigate precisely.""" + _document(db, convo, args.document_id) + rows = db.scalars(select(KnowledgeChunk).where(KnowledgeChunk.document_id == args.document_id) + .order_by(KnowledgeChunk.page, KnowledgeChunk.id)).all() + pages: dict[int, dict] = {} + for chunk in rows: + entry = pages.setdefault(chunk.page, {"page": chunk.page, "page_type": chunk.page_type, + "page_type_label": page_type_label(chunk.page_type), + "chapter": chunk.chapter, "section": chunk.section, + "block_types": []}) + if chunk.block_type not in entry["block_types"]: + entry["block_types"].append(chunk.block_type) + if chunk.section: + entry["section"] = chunk.section + return {"document_id": args.document_id, "count": len(pages), + "pages": [pages[key] for key in sorted(pages)]} + + +def search_document(db, convo, args, _user): + """Content search restricted to one document, under the same page-type policy.""" + document = _document(db, convo, args.document_id) + chunks = retrieve(db, course_version_id=document.course_version_id, project_id=document.project_id or 0, + query=args.query, limit=6, include_public=False, + allowed_page_types=sorted(CONTENT_ALLOWED), substantive_only=True) + citations = [] + for chunk in chunks: + if chunk["document_id"] != document.id: + continue + links = page_links(db, chunk["document_id"], chunk["page"]) + citations.append({"chunk_id": chunk["chunk_id"], "document_id": chunk["document_id"], + "title": document.title, "page": chunk["page"], "quote": chunk["text"][:500], + "project_id": chunk["project_id"], "page_type": chunk.get("page_type"), + "page_type_label": page_type_label(chunk.get("page_type")), + "section": chunk.get("section") or "", "source_role": "content", + "citation_id": f"chunk:{chunk['chunk_id']}", **links}) + return {"citations": citations, "found": bool(citations), "scopes": ["content"]} + + +def _document(db, convo, document_id): + document = db.get(KnowledgeDocument, document_id) + if not document: + raise HTTPException(404, "资料不存在") + require_scope(db, convo, version_id=document.course_version_id, project_id=document.project_id) + return document + + def batch_list(db, convo, args, _user): query = select(ScoringBatch).order_by(ScoringBatch.id.desc()) rows = [] @@ -278,15 +378,36 @@ 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"], - "error": str(row["error"] or "")[:300], "evidence": [{"page": ev["page"], - "quote": ev["quote"][:160], "document": str(ev["document"])[:200]} + "status_label": STATUS_LABELS.get(row["status"], row["status"]), + "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. + "evidence_gaps": [{"target_label": str(gap.get("target_label") or "")[:120], + "searched_page_types": gap.get("searched_page_types") or []} + for gap in (row.get("evidence_gaps") or [])][:6], + "evidence": [{"page": ev["page"], + "quote": ev["quote"][:160], "document": str(ev["document"])[:200], + "page_type": ev.get("page_type") or "unknown", + "page_type_label": page_type_label(ev.get("page_type")), + "target_label": str(ev.get("target_label") or "")[:120], + "historical": bool(ev.get("historical"))} for ev in row["evidence"][:2]]} for row in rows]}) for row in rows: for evidence in row["evidence"][:2]: links = page_links(db, evidence.get("document_id"), evidence["page"]) citations.append({"title": str(evidence["document"])[:200], "page": evidence["page"], "quote": evidence["quote"][:320], "document_id": evidence.get("document_id"), + "page_type": evidence.get("page_type") or links.get("page_type") or "unknown", + "page_type_label": page_type_label(evidence.get("page_type")), + "target_label": str(evidence.get("target_label") or "")[:120], + "historical": bool(evidence.get("historical")), + "source": "historical_score_evidence" if evidence.get("historical") else "score_evidence", **links, "score_url": url}) + if citations: + # Scoring quotations are never presented as report body evidence in an answer. + for citation in citations: + citation["usage"] = ("历史评分引用" if citation.get("historical") + else f"评分证据 · {citation.get('page_type_label')}") next_criterion_offset = args.criterion_offset + 6 if page and args.criterion_offset + 6 < len(page[0][1]["rows"]) else None next_project_offset = args.project_offset + 1 if args.project_offset + 1 < len(results) else None return {"batch_id": batch.id, "status_filter": args.status, "count": len(results), @@ -418,12 +539,15 @@ def prepare_import(db, convo, args, _user): TOOLS: dict[str, Tool] = { "list_courses": Tool("列出课程、版本和发布状态;名称或版本不清楚时先调用此工具。", Empty, courses), "get_course_rules": Tool("查询课程目标和规则。指定批次时只返回批次保存的快照。", RulesArgs, rules), - "search_course_material": Tool("检索指定课程版本的课程资料,返回原文片段和引用。", SearchCourse, search_course), + "search_course_material": Tool("检索指定课程版本的课程资料正文,返回原文片段和引用;不含目录、摘要和参考文献页。", SearchCourse, search_course), "list_directories": Tool("只读浏览 /data/inbox 下的相对目录。", Directory, browse_directories), "scan_directory": Tool("只读分页扫描学生总目录,返回材料候选、缺失、重复和识别异常。", ScanArgs, scan_directories), "list_projects": Tool("按课程版本及可选学号查询学生项目。", ProjectList, projects), "get_project": Tool("读取单个学生项目的元数据、材料和解析状态。", ProjectId, project), - "search_project_material": Tool("只检索指定学生项目报告,返回原文片段和引用。", SearchProject, search_project), + "search_project_material": Tool("只检索指定学生项目的报告正文,返回带页面类型和稳定引用标识的原文片段;不含目录、摘要和参考文献页。", SearchProject, search_project), + "search_project_structure": Tool("查询学生报告的章节结构与目录,返回目录条目和页面类型;只能说明章节安排,不能证明章节内容。", SearchProject, search_structure), + "get_document_pages": Tool("列出某份资料各页的页码、页面类型和章节,用于判断正文页位置。", DocumentId, get_document_pages), + "search_document": Tool("在指定的一份资料内检索正文段落,返回带页面类型和引用标识的片段。", SearchDocument, search_document), "list_batches": Tool("列出可访问的评分批次。", Empty, batch_list), "get_batch_progress": Tool("查询评分批次进度、状态和固定项目范围。", BatchId, progress), "get_scores": Tool("分页查询单学生或单批次评分详情,可按状态筛选。每次返回一个项目、最多六项评分;如需其余评分项,用 next_criterion_offset 作为 criterion_offset 再查;如需下个项目,用 next_project_offset 作为 project_offset,并将 criterion_offset 设为 0。", ScoresArgs, scores), diff --git a/backend/wisegrade/api.py b/backend/wisegrade/api.py index ec28b26..1db9c8a 100644 --- a/backend/wisegrade/api.py +++ b/backend/wisegrade/api.py @@ -10,13 +10,16 @@ from sqlalchemy.orm import Session from . import services from .config import get_settings from .db import get_db -from .exports import batch_projects, export_excel, export_project_pdf, project_result +from .evidence import CONTENT_ALLOWED +from .exports import (BATCH_STATUS_LABELS, batch_projects, export_excel, + export_project_pdf, page_type_label, project_result) from .knowledge_pages import page_image_path, page_links from .models import (AuditLog, Course, CourseVersion, CriterionScore, ExportTask, IndexTask, KnowledgeChunk, KnowledgeDocument, KnowledgePage, Project, RubricVersion, ScoringBatch, Student, SubmissionFile, TeacherRevision) from .materialized import sync_course_entities, sync_rubric_entities from .rules import RubricData +from .scoring import ATTENTION_STATUSES from .settings_store import key_configured, model_config, save_model_config from .storage import CATEGORIES, directories, scan @@ -108,9 +111,20 @@ class ModelSettingsInput(BaseModel): @router.get("/dashboard") def dashboard(db: Session = Depends(get_db)): + """Overview counts. Items awaiting teacher review are counted as attention needed. + + A ``needs_review`` item is not a failure: retrieval ran and found only navigation + pages where the rule requires body content, so a teacher decides it. + """ + counts = dict(db.execute(select(CriterionScore.status, func.count(CriterionScore.id)) + .group_by(CriterionScore.status)).all()) return {"courses": db.scalar(select(func.count(Course.id))), "projects": db.scalar(select(func.count(Project.id))), "batches": db.scalar(select(func.count(ScoringBatch.id))), - "needs_attention": db.scalar(select(func.count(CriterionScore.id)).where(CriterionScore.status.in_(["failed", "unscorable"]))) } + "needs_attention": sum(counts.get(status, 0) for status in ATTENTION_STATUSES), + "needs_review": counts.get("needs_review", 0), + "unscorable": counts.get("unscorable", 0), + "failed": counts.get("failed", 0), + "status_counts": counts} @router.get("/courses") @@ -337,9 +351,32 @@ def knowledge_page(document_id: int, page: int, db: Session = Depends(get_db)): return {"document_id": doc.id, "title": doc.title, "course_version_id": doc.course_version_id, "project_id": doc.project_id, "page": page, "text": record.text, + "page_type": record.page_type or "unknown", + "page_type_label": page_type_label(record.page_type), + "mixed": bool(record.mixed), + "section": record.section or "", "chapter": record.chapter or "", + "confidence": record.confidence, + "classification": record.classification or {}, + "content_allowed": (record.page_type or "unknown") in CONTENT_ALLOWED, "image_url": page_links(db, document_id, page)["image_url"]} +@router.get("/knowledge/documents/{document_id}/pages") +def knowledge_document_pages(document_id: int, db: Session = Depends(get_db)): + """Page inventory with types, so the teacher can see how a document was classified.""" + doc = found(db, KnowledgeDocument, document_id) + records = db.scalars(select(KnowledgePage).where(KnowledgePage.document_id == document_id) + .order_by(KnowledgePage.page)).all() + return {"document_id": doc.id, "title": doc.title, "index_version": doc.index_version, + "pages": [{"page": item.page, "page_type": item.page_type or "unknown", + "page_type_label": page_type_label(item.page_type), + "mixed": bool(item.mixed), "section": item.section or "", + "chapter": item.chapter or "", "confidence": item.confidence, + "classification": item.classification or {}, + "content_allowed": (item.page_type or "unknown") in CONTENT_ALLOWED} + for item in records]} + + @router.get("/knowledge/documents/{document_id}/pages/{page}/image") def knowledge_page_image(document_id: int, page: int, db: Session = Depends(get_db)): doc = found(db, KnowledgeDocument, document_id) @@ -354,6 +391,28 @@ def ingest_course(course_version_id: int, db: Session = Depends(get_db)): return services.ingest_course(course_version_id, db) +@router.get("/knowledge/stale/{course_version_id}") +def stale_knowledge(course_version_id: int, db: Session = Depends(get_db)): + """Documents still on an older index; they are excluded from retrieval until rebuilt.""" + from .indexing import stale_documents + from .rag import INDEX_VERSION + found(db, CourseVersion, course_version_id) + documents = stale_documents(db, course_version_id) + return {"index_version": INDEX_VERSION, + "documents": [{"id": item.id, "title": item.title, "type": item.document_type, + "project_id": item.project_id, "index_version": item.index_version, + "status": item.status} for item in documents]} + + +@router.post("/knowledge/reindex/{course_version_id}") +def reindex_knowledge(course_version_id: int, db: Session = Depends(get_db)): + """Queue a rebuild of every stale index for this course version.""" + from .indexing import create_reindex_task + found(db, CourseVersion, course_version_id) + task = create_reindex_task(db, course_version_id) + return get_index_task(task.id, db) + + @router.get("/index-tasks") def list_index_tasks(project_id: int | None = None, db: Session = Depends(get_db)): query = select(IndexTask).order_by(IndexTask.id.desc()).limit(100) @@ -381,10 +440,20 @@ def create_batch(data: BatchInput, db: Session = Depends(get_db)): return services.create_batch(data, db) +def batch_status_counts(db, batch_id: int) -> dict: + rows = db.execute(select(CriterionScore.status, func.count(CriterionScore.id)) + .where(CriterionScore.batch_id == batch_id).group_by(CriterionScore.status)).all() + counts = {status: count for status, count in rows} + return {"status_counts": counts, "needs_review": counts.get("needs_review", 0), + "unscorable": counts.get("unscorable", 0), "failed": counts.get("failed", 0)} + + @router.get("/scoring-batches") def list_batches(db: Session = Depends(get_db)): return [{**{key: getattr(batch, key) for key in ("id", "course_version_id", "rubric_version_id", "status", "progress_done", "progress_total", "error", "created_at", "finished_at")}, - "project_ids": batch.project_ids, "legacy_membership": batch.project_ids is None} + "status_label": BATCH_STATUS_LABELS.get(batch.status, batch.status), + "project_ids": batch.project_ids, "legacy_membership": batch.project_ids is None, + **batch_status_counts(db, batch.id)} for batch in db.scalars(select(ScoringBatch).order_by(ScoringBatch.id.desc()))] @@ -392,7 +461,9 @@ def list_batches(db: Session = Depends(get_db)): def batch_progress(batch_id: int, db: Session = Depends(get_db)): batch = found(db, ScoringBatch, batch_id) return {**{key: getattr(batch, key) for key in ("id", "status", "progress_done", "progress_total", "error", "created_at", "finished_at")}, - "project_ids": batch.project_ids, "legacy_membership": batch.project_ids is None} + "status_label": BATCH_STATUS_LABELS.get(batch.status, batch.status), + "project_ids": batch.project_ids, "legacy_membership": batch.project_ids is None, + **batch_status_counts(db, batch_id)} @router.post("/scoring-batches/{batch_id}/start") diff --git a/backend/wisegrade/chat_api.py b/backend/wisegrade/chat_api.py index 16c6575..1ac32ac 100644 --- a/backend/wisegrade/chat_api.py +++ b/backend/wisegrade/chat_api.py @@ -1,6 +1,8 @@ """Persistent teacher conversations and SSE agent orchestration.""" import json +import re import time +import unicodedata from concurrent.futures import ThreadPoolExecutor from datetime import timedelta from threading import Lock @@ -170,7 +172,8 @@ def _safe_arguments(arguments): if not isinstance(arguments, dict): return {} numeric = {"course_version_id", "batch_id", "project_id", "score_id", "task_id", - "rubric_version_id", "score", "offset", "limit", "project_offset", "criterion_offset"} + "rubric_version_id", "score", "offset", "limit", "project_offset", "criterion_offset", + "document_id"} text_limits = {"query": 300, "criterion_id": 100, "format": 20, "status": 30, "student_no": 50, "relative_dir": 500} result = {} @@ -219,26 +222,58 @@ def _result_summary(result): def _safe_citation(item): if not isinstance(item, dict): return None - return {key: value[:limit] if isinstance(value, str) else value for key, limit in - (("title", 200), ("quote", 500), ("url", 500), ("image_url", 500), ("score_url", 500)) + # The evidence-policy fields travel with the citation so the interface can tell a + # report body page from a contents page, and an answer's source from a historical + # scoring quotation. + label_limits = (("title", 200), ("quote", 500), ("url", 500), ("image_url", 500), ("score_url", 500), + ("citation_id", 80), ("page_type", 20), ("page_type_label", 30), + ("source_role", 20), ("source", 40), ("usage", 60), ("target_label", 120)) + return {key: value[:limit] if isinstance(value, str) else value for key, limit in label_limits if isinstance((value := item.get(key)), str)} | { - key: item[key] for key in ("chunk_id", "document_id", "page", "project_id") - if isinstance(item.get(key), int)} + key: item[key] for key in ("chunk_id", "document_id", "page", "project_id", "historical") + if isinstance(item.get(key), (int, bool)) and not isinstance(item.get(key), str)} -def _answer_citations(items): - """Keep each distinct source passage, including later tool calls and pages cited by the answer.""" +def _comparable_text(value): + return "".join(char for char in unicodedata.normalize("NFKC", str(value or "")) if not char.isspace()) + + +def _cited_pages(content: str) -> set[int]: + """Page and slide numbers the answer actually refers to.""" + numbers = set() + for match in re.finditer(r"第\s*(\d{1,4})\s*(?:页|张幻灯片|页幻灯片)", str(content or "")): + numbers.add(int(match.group(1))) + return numbers + + +def _answer_citations(items, content: str = ""): + """Attach only the citations the answer actually used. + + Collecting every tool hit would list material the answer never drew on. A passage is + kept when the answer quotes it, or when the answer names the page it sits on. When + the answer names no page and quotes nothing, no citation is attached: the answer made + no specific evidential claim. + """ seen = set() citations = [] + answer = _comparable_text(content) + pages = _cited_pages(content) for item in items: safe = _safe_citation(item) if not safe: continue key = (safe.get("document_id"), safe.get("chunk_id"), safe.get("title"), safe.get("page"), safe.get("quote"), safe.get("url"), safe.get("score_url")) - if key not in seen: - seen.add(key) - citations.append(safe) + if key in seen: + continue + quote = _comparable_text(safe.get("quote")) + used = bool(quote) and len(quote) >= 6 and quote[:80] in answer + if not used and pages and isinstance(safe.get("page"), int): + used = safe["page"] in pages + if not used: + continue + seen.add(key) + citations.append(safe) return citations @@ -326,7 +361,7 @@ def _run_agent(factory, conversation_id, run_id, user_text): content = reply.get("content") if not isinstance(content, str) or not content.strip(): raise ModelError("会话模型未返回文字或工具调用") - answer_citations = _answer_citations(citations) + answer_citations = _answer_citations(citations, content) db.add(ChatMessage(conversation_id=conversation_id, role="assistant", content=content, citations=answer_citations, tool_calls=used)) run.status, run.finished_at = "completed", now() diff --git a/backend/wisegrade/evidence.py b/backend/wisegrade/evidence.py new file mode 100644 index 0000000..2fead4f --- /dev/null +++ b/backend/wisegrade/evidence.py @@ -0,0 +1,150 @@ +"""Evidence policy: which page types may support which kind of judgement. + +The policy is part of the versionable scoring rules. Each required-evidence target +declares its ``use``: + +* ``structure`` — the target may be satisfied by a cover page, table of contents or + reference list (chapter completeness, bibliography, report layout). +* ``content`` — the target may only be satisfied by substantive text, data or tables + on body or appendix pages. + +Rules still in the historical string form (``["问题定义", "需求分析"]``) are read with a +permissive grammar: wording that names a structural artefact becomes a structure +target, everything else becomes a content target. The exact grammar is recorded as +``POLICY_VERSION`` in the batch snapshot so a batch can always be re-read against the +rules it was scored with. +""" +from __future__ import annotations + +import re + +from .pages import (CONTENT_PAGE_TYPES, NAVIGATION_PAGE_TYPES, STORE_PAGE_TYPES, + STRUCTURE_PAGE_TYPES) + +POLICY_VERSION = 1 +USE_STRUCTURE = "structure" +USE_CONTENT = "content" + +#: Wording that names report packaging rather than what the student did. +_STRUCTURE_WORDS = ( + "标题", "封面", "目录", "章节", "参考文献", "引用规范", "格式", "规范性", "结构完整", + "排版", "页码", "附录", "图表编号", "摘要", +) + +#: Appendix and body pages are the only pages allowed for a content judgement. +CONTENT_ALLOWED = tuple(CONTENT_PAGE_TYPES) +#: Structural targets may additionally use cover, contents and reference pages. +STRUCTURE_ALLOWED = tuple(STRUCTURE_PAGE_TYPES) +#: Navigation pages can never carry a content judgement. +NAVIGATION = tuple(NAVIGATION_PAGE_TYPES) + + +def _looks_structural(text: str) -> bool: + value = str(text or "").strip() + return bool(value) and any(word in value for word in _STRUCTURE_WORDS) + + +def normalize_target(raw) -> dict: + """Read one required-evidence target in either the rich or the legacy string form.""" + if isinstance(raw, dict): + label = str(raw.get("label") or raw.get("text") or raw.get("name") or "").strip() + use = str(raw.get("use") or "").strip().lower() + if use not in (USE_STRUCTURE, USE_CONTENT): + use = USE_STRUCTURE if _looks_structural(label) else USE_CONTENT + allowed = raw.get("allowed_page_types") + if not isinstance(allowed, list) or not allowed: + allowed = list(STRUCTURE_ALLOWED if use == USE_STRUCTURE else CONTENT_ALLOWED) + allowed = [str(item) for item in allowed if str(item) in STORE_PAGE_TYPES] + if not allowed: + allowed = list(STRUCTURE_ALLOWED if use == USE_STRUCTURE else CONTENT_ALLOWED) + if use == USE_CONTENT: + # A content target never admits navigation pages, whatever a rule claims. + allowed = [item for item in allowed if item not in NAVIGATION] or list(CONTENT_ALLOWED) + return {"label": label or "证据", "use": use, "allowed_page_types": allowed, + "query": str(raw.get("query") or label).strip() or (label or "证据")} + label = str(raw or "").strip() + use = USE_STRUCTURE if _looks_structural(label) else USE_CONTENT + return {"label": label or "证据", "use": use, + "allowed_page_types": list(STRUCTURE_ALLOWED if use == USE_STRUCTURE else CONTENT_ALLOWED), + "query": label or "证据"} + + +def normalize_targets(required_evidence) -> list[dict]: + """Normalize a criterion's required evidence, preserving order and identity.""" + values = required_evidence if isinstance(required_evidence, list) else [required_evidence] + targets = [] + for index, raw in enumerate(values): + if raw is None or (isinstance(raw, str) and not raw.strip()): + continue + target = normalize_target(raw) + target["index"] = index + target["target_id"] = str((raw.get("target_id") if isinstance(raw, dict) else "") or f"t{index}") + targets.append(target) + if not targets: + targets.append({"label": "正文实质内容", "use": USE_CONTENT, "allowed_page_types": list(CONTENT_ALLOWED), + "query": "正文实质内容", "index": 0, "target_id": "t0"}) + return targets + + +def content_targets(criterion: dict) -> list[dict]: + return [target for target in normalize_targets(criterion.get("required_evidence")) if target["use"] == USE_CONTENT] + + +def any_content_target(criterion: dict) -> bool: + return bool(content_targets(criterion)) + + +def page_type_allowed(page_type: str, allowed: list[str]) -> bool: + """An ``unknown`` page is a body page only when the target already allows body text. + + ``unknown`` marks a page whose reading is genuinely unsettled, so it may never be + used as the sole support for a content judgement, and it never widens a structural + target either. It stays retrievable so a teacher can inspect it. + """ + if page_type == "unknown": + return "body" in allowed + return page_type in allowed + + +def content_evidence_allowed(page_type: str) -> bool: + """Only substantive body and appendix pages support a content judgement.""" + return page_type in CONTENT_ALLOWED + + +def heading_only_text(text: str) -> bool: + """True when a passage is nothing but section titles, so it proves no content. + + The test is structural, not a length test: a quotation is heading-only when every + line of it is a chapter/section heading or a contents entry. A short technical + quotation such as a measured value is content, so it must not be rejected here. + """ + from .pages import heading_level, is_toc_entry + + lines = [line.strip() for line in str(text or "").splitlines() if line.strip()] + if not lines: + return False + for line in lines: + if is_toc_entry(line) or heading_level(line): + continue + return False + return True + + +_PLACEHOLDER = re.compile(r"^\s*(待确认|待定|未知|无|none|null|-+|—+)\s*$", re.IGNORECASE) + + +def meaningful(value) -> bool: + text = str(value or "").strip() + return bool(text) and not _PLACEHOLDER.match(text) + + +def plan_snapshot(criteria: list[dict]) -> dict: + """The policy attached to a batch snapshot, so old batches stay re-readable.""" + return { + "version": POLICY_VERSION, + "content_allowed_page_types": list(CONTENT_ALLOWED), + "structure_allowed_page_types": list(STRUCTURE_ALLOWED), + "navigation_page_types": list(NAVIGATION), + "criteria": {criterion["criterion_id"]: normalize_targets(criterion.get("required_evidence")) + for criterion in criteria}, + } diff --git a/backend/wisegrade/exports.py b/backend/wisegrade/exports.py index 4bb8181..bd09d78 100644 --- a/backend/wisegrade/exports.py +++ b/backend/wisegrade/exports.py @@ -11,6 +11,32 @@ from .knowledge_pages import evidence_links from .models import (CriterionScore, ExportTask, Project, ScoreEvidence, ScoringBatch, Student, SubmissionFile) from .rules import totals +#: Teacher-facing wording for a criterion status, shared by exports and the API. +STATUS_LABELS = { + "pending": "待评分", + "scored": "模型已评分", + "reviewed": "教师已复核", + "needs_review": "待教师复核", + "unscorable": "材料缺失或解析失败", + "failed": "模型调用失败", +} +PAGE_TYPE_LABELS = { + "cover": "封面", "abstract": "摘要", "toc": "目录", "body": "正文", + "appendix": "附录", "references": "参考文献", "unknown": "识别不确定", + "mixed": "目录与正文混合页", +} +#: Batch status wording. ``review_required`` means grading finished but some criteria +#: still need a teacher decision, which is not the same as an incomplete batch. +BATCH_STATUS_LABELS = { + "created": "待启动", "queued": "排队中", "running": "运行中", + "completed": "已完成", "review_required": "已完成,有待复核项", + "partial": "部分完成", "failed": "失败", +} + + +def page_type_label(value) -> str: + return PAGE_TYPE_LABELS.get(str(value or "unknown"), "未分类页面") + def project_result(db, batch: ScoringBatch, project: Project): scores = db.scalars(select(CriterionScore).where(CriterionScore.batch_id == batch.id, @@ -19,10 +45,15 @@ def project_result(db, batch: ScoringBatch, project: Project): criteria = batch.rubric_snapshot["criteria"] totals_ = totals({key: score.final_score for key, score in by_id.items()}, criteria, batch.rubric_snapshot["weights"]) rows = [] - def evidence_dict(item): - links = evidence_links(db, item) + def evidence_dict(item, links): return {"page": item.page, "quote": item.quote, "file_id": item.submission_file_id, "document_id": item.document_id, + "target_id": item.target_id or "", "target_label": item.target_label or "", + "page_type": item.page_type or links.get("page_type") or "unknown", + "source_role": item.source_role or "content", + # A quotation saved before the evidence policy existed is labelled as a + # historical citation and never presented as report body evidence. + "historical": bool(item.historical), "document": db.get(SubmissionFile, item.submission_file_id).relative_path if item.submission_file_id else "课程资料", **links} for criterion in criteria: @@ -35,7 +66,8 @@ 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 "", - "evidence": [evidence_dict(item) for item in evidence]}) + "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) return {"project_id": project.id, "student_no": student.student_no, "name": student.name, "topic": project.topic, "totals": totals_, "rows": rows, "facts": project.facts} @@ -97,14 +129,21 @@ def _html_report(result, batch): for row in result["rows"]: criterion = row["criterion"] evidences = "".join( - f"
模型原始分:{esc(row['model_score'])};教师修改分:{esc(row['teacher_score'])};状态:{esc(row['status'])}
" + f"模型原始分:{esc(row['model_score'])};教师修改分:{esc(row['teacher_score'])};状态:{esc(STATUS_LABELS.get(row['status'], row['status']))}
" f"模型评语:{esc(row['model_comment'])}
教师评语:{esc(row['teacher_comment'])}
" - f"缺失证据:{esc(missing)}
建议:{esc(row['suggestion'])}
异常:{esc(row['error'])}
" + f"{esc(review)}
" + f"缺失证据:{esc(missing)}
缺正文证据的目标:{esc(gaps)}
" + f"建议:{esc(row['suggestion'])}
异常:{esc(row['error'])}
" f"