feat: separate contents, abstract and body evidence

This commit is contained in:
2026-10-01 00:07:40 +08:00
parent 540d0e2918
commit b7634c8374
25 changed files with 2592 additions and 172 deletions
+3 -1
View File
@@ -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()))
+9 -4
View File
@@ -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))
+444
View File
@@ -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"] == [["实验数据", "分析"]]
+1 -1
View File
@@ -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"
+58 -5
View File
@@ -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"]