fix: improve import, evidence indexing and material-scoped grading
Preserve document revisions and evidence, harden indexing and export behavior, add explicit material selection and direct-folder import, validate model JSON schemas with safe error messages, and retain student selection while loading scoring data.
This commit is contained in:
@@ -0,0 +1,808 @@
|
||||
"""Regressions runnable without model credentials or a production database.
|
||||
|
||||
SQLite exercises business state and scope. PostgreSQL/pgvector acceptance remains
|
||||
covered by the dedicated-database suite, not by these tests.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import create_engine, select, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from wisegrade import agent_tools, chat_api, services
|
||||
from wisegrade.config import get_settings
|
||||
from wisegrade.db import Base
|
||||
from wisegrade.evidence import normalize_targets
|
||||
from wisegrade.exports import _html_report, export_excel
|
||||
from wisegrade.indexing import reindex_stale, run_index_task
|
||||
from wisegrade.llm import ModelError, ScoreResult, validate_score
|
||||
from wisegrade.models import (Conversation, CriterionScore, KnowledgeChunk, KnowledgeDocument, ScoreEvidence,
|
||||
KnowledgePage, Project, ScoringBatch, Student, SubmissionFile, IndexTask)
|
||||
from wisegrade.pages import classify_document
|
||||
from wisegrade.parsing import text_quality
|
||||
from wisegrade.rag import INDEX_VERSION, build_chunks, ingest, keyword_overlap, query_terms, retrieve, retrieve_targets
|
||||
from wisegrade.rules import Criterion, EvidenceTarget
|
||||
from wisegrade.scoring import _attach_page_types, _score_criterion
|
||||
from wisegrade.seed import seed_initial
|
||||
from wisegrade.storage import extract_identity, sha256
|
||||
from wisegrade.submissions import file_index_lock
|
||||
|
||||
FLAT = lambda texts: [[.1] * 1024 for _ in texts]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_db(environment, tmp_path):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'isolated.sqlite3'}")
|
||||
@event.listens_for(engine, "connect")
|
||||
def foreign_keys(connection, _record):
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
with factory() as session:
|
||||
seed_initial(session)
|
||||
yield session
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def project(db):
|
||||
student = Student(student_no="9999900001", name="测试学生")
|
||||
db.add(student)
|
||||
db.flush()
|
||||
item = Project(course_version_id=1, student_id=student.id, topic="测试", source_dir="test")
|
||||
db.add(item)
|
||||
db.commit()
|
||||
return item
|
||||
|
||||
|
||||
def document(db, item, title="报告", pages=None):
|
||||
return ingest(db, course_version_id=1, project_id=item.id if item else None,
|
||||
submission_file_id=None, title=title, category="stage3", source_hash=title,
|
||||
pages=pages or [{"page_number": 1, "text": "实际实验测得系统响应时间为100毫秒。"}], embedder=FLAT)
|
||||
|
||||
|
||||
def test_abstract_below_running_title_is_navigation():
|
||||
pages = [{"page_number": 1, "text": "报告标题\n摘要\n本文声称实现了实时追踪系统。\n关键词:视觉;控制"}]
|
||||
assert classify_document(pages)[0]["page_type"] == "abstract"
|
||||
assert all(c["page_type"] == "abstract" for c in build_chunks(pages))
|
||||
|
||||
|
||||
def test_mixed_body_is_retrieved_and_validated_as_body(sqlite_db):
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
pages = [{"page_number": 2, "text": "目录\n第1 章 项目背景.......... 1\n"
|
||||
"第1 章 项目背景\n本项目面向智能检测场景,通过多传感器融合实现目标识别。"}]
|
||||
document(db, item, pages=pages)
|
||||
hits = retrieve(db, course_version_id=1, project_id=item.id, query="多传感器融合",
|
||||
include_public=False, allowed_page_types=["body"], substantive_only=True, embedder=FLAT)
|
||||
assert hits and all(h["block_type"] == "body" for h in hits)
|
||||
hits = _attach_page_types(db, hits)
|
||||
assert hits[0]["page_type"] == "body"
|
||||
result = ScoreResult(criterion_id="x", score=8, max_score=10, reason="有正文", confidence=.8,
|
||||
evidence=[{"chunk_id": hits[0]["chunk_id"], "page": 2, "quote": "多传感器融合", "target_id": "t0"}])
|
||||
assert validate_score(result, {"criterion_id": "x", "max_score": 10, "required_evidence": ["实现方法"]}, hits).score == 8
|
||||
|
||||
|
||||
def test_section_chunks_do_not_inherit_the_last_heading():
|
||||
pages = [{"page_number": 1, "text": "1 系统设计\n1.1 硬件设计\n本系统通过传感器采集数据。\n1.2 软件设计\n程序使用状态机处理结果。"}]
|
||||
chunks = build_chunks(pages)
|
||||
hardware = next(c for c in chunks if "传感器采集" in c["text"])
|
||||
assert hardware["section"] == "1.1 硬件设计" and hardware["chapter"] == "1 系统设计"
|
||||
assert "状态机" not in hardware["text"]
|
||||
|
||||
|
||||
def test_independent_tables_are_not_dropped():
|
||||
chunks = build_chunks([{"page_number": 1, "text": "本页展示实验结果用于性能分析。",
|
||||
"tables": [{"rows": [["指标", "9876"]]}]}])
|
||||
assert any("9876" in c["text"] for c in chunks)
|
||||
|
||||
|
||||
def test_cover_labels_may_contain_spacing():
|
||||
value = extract_identity("学 号:9999900001\n姓 名:测试学生\n题 目:运动控制系统")
|
||||
assert value == {"student_no": "9999900001", "name": "测试学生", "topic": "运动控制系统"}
|
||||
|
||||
|
||||
def test_corrupt_text_is_preserved_flagged_and_excluded(sqlite_db):
|
||||
text = "系统测试记录的响应时间为\uffff200ms,请核对原页。"
|
||||
assert text_quality(text)["needs_review"]
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
document(db, item, pages=[{"page_number": 1, "text": text, "text_quality": text_quality(text)}])
|
||||
assert db.scalar(select(KnowledgePage)).text == text
|
||||
hits = retrieve(db, course_version_id=1, project_id=item.id, query="响应时间",
|
||||
allowed_page_types=["body"], include_public=False, embedder=FLAT)
|
||||
assert not hits
|
||||
|
||||
|
||||
def test_invalid_target_and_structure_only_positive_are_rejected():
|
||||
criterion = {"criterion_id": "x", "max_score": 10, "required_evidence": ["实验数据", "章节完整性"]}
|
||||
toc = {"chunk_id": 1, "page": 2, "page_type": "toc", "text": "第1 章 项目背景.......... 1"}
|
||||
result = ScoreResult(criterion_id="x", score=8, max_score=10, reason="目录", confidence=.8,
|
||||
evidence=[{"chunk_id": 1, "page": 2, "quote": "第1 章 项目背景", "target_id": "t1"}])
|
||||
with pytest.raises(ModelError, match="内容证据"):
|
||||
validate_score(result, criterion, [toc])
|
||||
result.evidence[0].target_id = "invalid"
|
||||
with pytest.raises(ModelError, match="未知"):
|
||||
validate_score(result, criterion, [toc])
|
||||
|
||||
|
||||
def test_target_objects_round_trip_with_stable_ids_and_strict_permissions():
|
||||
target = {"target_id": "experiment", "label": "实验数据", "use": "content", "allowed_page_types": ["body", "appendix"]}
|
||||
value = Criterion(criterion_id="x", category="stage1", name="实验", max_score=10, description="核对实验",
|
||||
required_evidence=[target], score_levels=[{"min": 0, "max": 10, "description": "实验质量"}])
|
||||
stored = value.model_dump()
|
||||
assert all(stored["required_evidence"][0][key] == field for key, field in target.items())
|
||||
assert Criterion.model_validate(stored).required_evidence[0].target_id == "experiment"
|
||||
with pytest.raises(ValidationError):
|
||||
EvidenceTarget(label="数据", use="content", allowed_page_types=["toc"])
|
||||
with pytest.raises(ValidationError):
|
||||
Criterion.model_validate({**stored, "required_evidence": [target, target]})
|
||||
|
||||
|
||||
def test_shared_chunk_keeps_all_target_ids():
|
||||
targets = normalize_targets(["实验数据", "系统设计"])
|
||||
hit = {"chunk_id": 1, "document_id": 1, "page": 1, "text": "真实内容", "page_type": "body"}
|
||||
selection = retrieve_targets(None, course_version_id=1, project_id=1, targets=targets, retrieve_fn=lambda *a, **k: [hit])
|
||||
assert selection["evidence"][0]["target_ids"] == ["t0", "t1"]
|
||||
|
||||
|
||||
def test_document_filter_and_index_readiness_precede_limit(sqlite_db):
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
target = document(db, item, "目标")
|
||||
other = document(db, item, "其他")
|
||||
other.status = "indexing"
|
||||
db.commit()
|
||||
hits = retrieve(db, course_version_id=1, project_id=item.id, query="响应时间", limit=1,
|
||||
document_id=target.id, include_public=False, embedder=FLAT)
|
||||
assert len(hits) == 1 and hits[0]["document_id"] == target.id
|
||||
target.index_version = INDEX_VERSION - 1
|
||||
db.commit()
|
||||
assert retrieve(db, course_version_id=1, project_id=item.id, query="响应时间", include_public=False, embedder=FLAT) == []
|
||||
|
||||
|
||||
def test_ascii_keywords_respect_token_boundaries():
|
||||
tokens, bigrams = query_terms("PID")
|
||||
assert keyword_overlap("RAPID", tokens, bigrams) == 0
|
||||
assert keyword_overlap("PID 控制", tokens, bigrams) == 1
|
||||
|
||||
|
||||
def test_same_page_number_does_not_attach_another_document():
|
||||
citations = [{"document_id": 1, "title": "甲报告.pdf", "page": 11, "quote": "甲报告独有原文证据"},
|
||||
{"document_id": 2, "title": "乙报告.pdf", "page": 11, "quote": "乙报告独有原文证据"}]
|
||||
assert [c["document_id"] for c in chat_api._answer_citations(citations, "甲报告第11页介绍测试过程。")] == [1]
|
||||
assert chat_api._answer_citations(citations, "第11页有结果。") == []
|
||||
|
||||
|
||||
def test_explicit_citation_link_selects_only_that_chunk():
|
||||
items = [{"document_id": 1, "chunk_id": i, "title": "报告", "page": 11,
|
||||
"quote": f"不同原文段落{i}", "answer_url": f"/api/knowledge/documents/1/pages/11#chunk-{i}"} for i in (1, 2)]
|
||||
# A linked source must not activate a broad page-number fallback for other hits.
|
||||
answer = "结果见[报告,第11页](/api/knowledge/documents/1/pages/11#chunk-2)。"
|
||||
assert [c["chunk_id"] for c in chat_api._answer_citations(items, answer)] == [2]
|
||||
|
||||
|
||||
def test_page_inventory_includes_pages_without_chunks(sqlite_db):
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
doc = document(db, item)
|
||||
db.add(KnowledgePage(document_id=doc.id, page=2, text="", page_type="unknown"))
|
||||
conversation = Conversation(course_version_id=1, project_id=item.id)
|
||||
result = agent_tools.get_document_pages(db, conversation, SimpleNamespace(document_id=doc.id), "")
|
||||
assert [row["page"] for row in result["pages"]] == [1, 2]
|
||||
|
||||
|
||||
def test_last_review_updates_batch_and_keeps_zero(sqlite_db):
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
data = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
|
||||
batch = db.get(ScoringBatch, data["id"])
|
||||
batch.status = "review_required"
|
||||
for criterion in batch.rubric_snapshot["criteria"]:
|
||||
db.add(CriterionScore(batch_id=batch.id, project_id=item.id, criterion_id=criterion["criterion_id"],
|
||||
status="scored", model_score=0))
|
||||
db.flush()
|
||||
score = db.scalar(select(CriterionScore).where(CriterionScore.batch_id == batch.id))
|
||||
score.model_score, score.status = None, "needs_review"
|
||||
db.commit()
|
||||
services.review_score(score.id, SimpleNamespace(score=0, comment="核对", reason="确认零分"), db)
|
||||
assert batch.status == "completed" and score.final_score == 0
|
||||
task = export_excel(db, batch.id)
|
||||
from openpyxl import load_workbook
|
||||
with Path(task.path).open("rb") as file:
|
||||
sheet = load_workbook(file, read_only=True).active
|
||||
assert sheet["D2"].value == 0
|
||||
|
||||
|
||||
def test_pdf_html_preserves_zero_and_none():
|
||||
batch = SimpleNamespace(id=1, course_version_id=1, rubric_version_id=1, model_config_snapshot={},
|
||||
embedding_model_name="test", embedding_model_version="test")
|
||||
row = {"criterion": {"name": "实验", "max_score": 10}, "final_score": 0, "model_score": 0,
|
||||
"teacher_score": None, "status": "scored", "evidence": [], "missing_evidence": [],
|
||||
"model_comment": "核对", "teacher_comment": None, "suggestion": "", "error": ""}
|
||||
result = {"student_no": "test", "name": "test", "topic": "test", "rows": [row],
|
||||
"totals": {"stage1": 0, "stage2": None, "stage3": 0, "final": 0, "overall": None}}
|
||||
html = _html_report(result, batch)
|
||||
assert "实验(0 / 10)" in html and "模型原始分:0" in html
|
||||
|
||||
|
||||
def test_public_reindex_uses_saved_pages(sqlite_db):
|
||||
db = sqlite_db
|
||||
doc = document(db, None, "课程资料")
|
||||
doc.index_version = INDEX_VERSION - 1
|
||||
db.commit()
|
||||
result = reindex_stale(db, 1, embedder=FLAT)
|
||||
assert result["remaining"] == 0 and result["rebuilt"][0]["document_id"] == doc.id
|
||||
assert doc.status == "superseded" and doc.index_version == INDEX_VERSION - 1
|
||||
replacement = db.get(KnowledgeDocument, result["rebuilt"][0]["replacement_document_id"])
|
||||
assert replacement.id != doc.id and replacement.index_version == INDEX_VERSION
|
||||
|
||||
|
||||
def test_student_reindex_reuses_parse_output(sqlite_db, environment, monkeypatch):
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
path = environment / "inbox" / "saved.pdf"
|
||||
path.write_bytes(b"source hash only; parser must not be called")
|
||||
file = SubmissionFile(project_id=item.id, category="stage3", relative_path="saved.pdf", mime_type="application/pdf",
|
||||
size=path.stat().st_size, mtime=path.stat().st_mtime, sha256=sha256(path),
|
||||
recognition_status="recognized", parse_status="ready",
|
||||
parsed={"pages": [{"page_number": 1, "text": "已有正文解析结果供重复索引使用。"}]})
|
||||
db.add(file)
|
||||
db.commit()
|
||||
doc = ingest(db, course_version_id=1, project_id=item.id, submission_file_id=file.id, title=path.name,
|
||||
category="stage3", source_hash=file.sha256, pages=file.parsed["pages"], embedder=FLAT)
|
||||
doc.index_version = INDEX_VERSION - 1
|
||||
db.commit()
|
||||
monkeypatch.setattr("wisegrade.submissions.parse_document", lambda *a, **k: pytest.fail("不应重新解析"))
|
||||
assert reindex_stale(db, 1, embedder=FLAT)["remaining"] == 0
|
||||
|
||||
|
||||
def test_file_lock_serializes_threads(environment):
|
||||
entered, waiting, acquired, release = Event(), Event(), Event(), Event()
|
||||
def first():
|
||||
with file_index_lock(99):
|
||||
entered.set()
|
||||
assert release.wait(2)
|
||||
def second():
|
||||
assert entered.wait(2)
|
||||
waiting.set()
|
||||
with file_index_lock(99):
|
||||
acquired.set()
|
||||
return True
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
one, two = pool.submit(first), pool.submit(second)
|
||||
assert waiting.wait(2)
|
||||
try:
|
||||
assert not acquired.wait(.1)
|
||||
finally:
|
||||
release.set()
|
||||
one.result(timeout=5)
|
||||
assert two.result(timeout=5)
|
||||
|
||||
|
||||
def test_rule_api_and_batch_snapshot_preserve_object_targets(sqlite_db, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
from wisegrade import main
|
||||
from wisegrade.db import get_db
|
||||
db = sqlite_db
|
||||
factory = sessionmaker(bind=db.get_bind(), expire_on_commit=False)
|
||||
monkeypatch.setattr(main, "SessionLocal", factory)
|
||||
def sessions():
|
||||
with factory() as session:
|
||||
yield session
|
||||
main.app.dependency_overrides[get_db] = sessions
|
||||
target = {"target_id": "measured", "label": "实验数据", "use": "content", "allowed_page_types": ["body"]}
|
||||
try:
|
||||
with TestClient(main.app) as client:
|
||||
created = client.post("/api/course-versions/1/rubrics", json={"weights": {"stage3": 1}, "criteria": [{
|
||||
"criterion_id": "new.data", "category": "stage3", "name": "实验", "description": "实验依据",
|
||||
"max_score": 100, "required_evidence": [target],
|
||||
"score_levels": [{"min": 0, "max": 100, "description": "实验质量"}]}]})
|
||||
assert created.status_code == 200, created.text
|
||||
rubric_id = created.json()["id"]
|
||||
assert client.post(f"/api/rubrics/{rubric_id}/publish").status_code == 200
|
||||
batch = client.post("/api/scoring-batches", json={"course_version_id": 1, "rubric_version_id": rubric_id})
|
||||
assert batch.status_code == 200
|
||||
saved = db.get(ScoringBatch, batch.json()["id"])
|
||||
assert saved.rubric_snapshot["criteria"][0]["required_evidence"][0]["target_id"] == "measured"
|
||||
assert saved.rubric_snapshot["evidence_plan"]["criteria"]["new.data"][0]["use"] == "content"
|
||||
finally:
|
||||
main.app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_model_declining_score_records_original_gap(sqlite_db):
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
file = SubmissionFile(project_id=item.id, category="stage3", relative_path="test.pdf", mime_type="application/pdf",
|
||||
size=1, mtime=1, sha256="test", recognition_status="recognized", parse_status="ready", parsed={})
|
||||
db.add(file)
|
||||
db.commit()
|
||||
doc = ingest(db, course_version_id=1, project_id=item.id, submission_file_id=file.id, title="test.pdf",
|
||||
category="stage3", source_hash="test", pages=[{"page_number": 1, "text": "实验描述只列出装置,未给出可核对的测量数据。"}], embedder=FLAT)
|
||||
criterion = {"criterion_id": "stage3.data", "category": "stage3", "max_score": 20,
|
||||
"description": "实验数据", "required_evidence": ["实验数据"]}
|
||||
data = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
|
||||
batch = db.get(ScoringBatch, data["id"])
|
||||
class Client:
|
||||
def json_call(self, *args, **kwargs):
|
||||
return ScoreResult(criterion_id="stage3.data", score=None, max_score=20, reason="只有装置介绍",
|
||||
missing_evidence=["实验数据"], confidence=.2)
|
||||
def retriever(session, **kwargs):
|
||||
if kwargs.get("public_only"):
|
||||
return []
|
||||
kwargs["embedder"] = FLAT
|
||||
return retrieve(session, **kwargs)
|
||||
score = _score_criterion(db, batch, item, criterion, Client(), retriever=retriever)
|
||||
assert score.status == "needs_review" and score.model_score is None
|
||||
assert score.evidence_gaps[0]["target_label"] == "实验数据"
|
||||
assert score.evidence_gaps[0]["reason"] == "只有装置介绍"
|
||||
|
||||
|
||||
def test_unrebuilt_course_document_does_not_complete_task(sqlite_db):
|
||||
db = sqlite_db
|
||||
doc = KnowledgeDocument(course_version_id=1, title="无页面资料", document_type="course",
|
||||
source_hash="test", index_version=INDEX_VERSION - 1, status="ready")
|
||||
task = IndexTask(course_version_id=1, kind="reindex")
|
||||
db.add_all([doc, task])
|
||||
db.commit()
|
||||
factory = sessionmaker(bind=db.get_bind(), expire_on_commit=False)
|
||||
run_index_task(task.id, session_factory=factory, embedder=FLAT)
|
||||
db.refresh(task)
|
||||
assert task.status == "failed" and task.result["remaining"] == 1
|
||||
|
||||
|
||||
def test_replacement_index_keeps_historical_evidence(sqlite_db):
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
old = document(db, item, "报告.pdf")
|
||||
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
|
||||
score = CriterionScore(batch_id=created["id"], project_id=item.id, criterion_id="stage3.data", status="scored", model_score=8)
|
||||
db.add(score)
|
||||
db.flush()
|
||||
db.add(ScoreEvidence(score_id=score.id, document_id=old.id, page=1, quote="系统响应时间为100毫秒"))
|
||||
db.commit()
|
||||
new = ingest(db, course_version_id=1, project_id=item.id, submission_file_id=None, title="报告.pdf",
|
||||
category="stage3", source_hash="changed", pages=[{"page_number": 1, "text": "新版本测得系统响应时间为80毫秒。"}], embedder=FLAT)
|
||||
assert new.id != old.id and old.status == "superseded"
|
||||
assert db.get(KnowledgePage, (old.id, 1)).text == "实际实验测得系统响应时间为100毫秒。"
|
||||
assert db.scalar(select(ScoreEvidence)).document_id == old.id
|
||||
assert all(h["document_id"] == new.id for h in retrieve(db, course_version_id=1, project_id=item.id,
|
||||
query="响应时间", include_public=False, embedder=FLAT))
|
||||
|
||||
|
||||
def test_three_line_table_is_bounded_and_code_listing_is_not_a_table(tmp_path):
|
||||
import pymupdf
|
||||
from wisegrade.parsing import parse_document
|
||||
pdf = pymupdf.open()
|
||||
page = pdf.new_page()
|
||||
for y in (200, 225, 290):
|
||||
page.draw_line((80, y), (520, y))
|
||||
for x, label in ((95, "ID"), (230, "Value"), (390, "Result")):
|
||||
page.insert_text((x, 217), label)
|
||||
for y, values in ((245, ("1", "85", "PASS")), (270, ("2", "178", "PASS"))):
|
||||
for x, value in zip((95, 230, 390), values):
|
||||
page.insert_text((x, y), value)
|
||||
page = pdf.new_page()
|
||||
for y in (200, 225, 290):
|
||||
page.draw_line((80, y), (520, y))
|
||||
page.insert_text((95, 217), "Listing 1: Sample code")
|
||||
page.insert_text((95, 245), "void process(void) { return; }")
|
||||
page.insert_text((95, 270), "// A second line of source code")
|
||||
path = tmp_path / "tables.pdf"
|
||||
pdf.save(path)
|
||||
pdf.close()
|
||||
pages = parse_document(path, "final_report", tmp_path / "assets", ocr=False)["pages"]
|
||||
assert len(pages[0]["tables"]) == 1
|
||||
values = str(pages[0]["tables"][0]["rows"])
|
||||
assert "178" in values and "PASS" in values
|
||||
assert pages[0]["tables"][0]["warning"]
|
||||
assert pages[1]["tables"] == []
|
||||
|
||||
|
||||
def test_mixed_citations_keep_block_type_when_page_is_toc(sqlite_db, monkeypatch):
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
document(db, item, pages=[{"page_number": 1, "text": "目录\n第1 章 系统设计.......... 1\n"
|
||||
"第1 章 系统设计\n本系统通过视觉传感器获取目标位置并闭环控制电机。"}])
|
||||
def retriever(session, **kwargs):
|
||||
kwargs["embedder"] = FLAT
|
||||
return retrieve(session, **kwargs)
|
||||
monkeypatch.setattr(agent_tools, "retrieve", retriever)
|
||||
result = agent_tools._search(db, 1, item.id, "视觉传感器")
|
||||
assert result["found"] and all(c["page_type"] == "body" for c in result["citations"])
|
||||
|
||||
|
||||
def test_index_default_migration_replays_on_sqlite(environment, monkeypatch):
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import inspect
|
||||
backend = Path(__file__).resolve().parents[1]
|
||||
url = f"sqlite:///{environment / 'migration.sqlite3'}"
|
||||
monkeypatch.setenv("DATABASE_URL", url)
|
||||
get_settings.cache_clear()
|
||||
config = Config(str(backend / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(backend / "migrations"))
|
||||
command.upgrade(config, "head")
|
||||
command.downgrade(config, "0006")
|
||||
command.upgrade(config, "head")
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
columns = inspect(engine).get_columns("knowledge_documents")
|
||||
default = next(c["default"] for c in columns if c["name"] == "index_version")
|
||||
assert default.strip("'") == str(INDEX_VERSION)
|
||||
finally:
|
||||
engine.dispose()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_evidence_result_query_count_is_bounded(sqlite_db):
|
||||
from wisegrade.exports import project_result
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
doc = document(db, item)
|
||||
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
|
||||
batch = db.get(ScoringBatch, created["id"])
|
||||
for criterion in batch.rubric_snapshot["criteria"]:
|
||||
score = CriterionScore(batch_id=batch.id, project_id=item.id, criterion_id=criterion["criterion_id"], status="scored", model_score=0)
|
||||
db.add(score)
|
||||
db.flush()
|
||||
for _ in range(4):
|
||||
db.add(ScoreEvidence(score_id=score.id, document_id=doc.id, page=1, quote="响应时间为100毫秒"))
|
||||
db.commit()
|
||||
queries = []
|
||||
def count(_conn, _cursor, statement, _params, _context, _many):
|
||||
if statement.lstrip().upper().startswith("SELECT"):
|
||||
queries.append(statement)
|
||||
event.listen(db.get_bind(), "before_cursor_execute", count)
|
||||
try:
|
||||
result = project_result(db, batch, item)
|
||||
finally:
|
||||
event.remove(db.get_bind(), "before_cursor_execute", count)
|
||||
assert len(result["rows"]) == len(batch.rubric_snapshot["criteria"])
|
||||
assert all(len(row["evidence"]) == 4 for row in result["rows"])
|
||||
assert len(queries) <= 7
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["course", "reindex"])
|
||||
def test_open_index_task_is_reused_without_reenqueue(sqlite_db, monkeypatch, kind):
|
||||
from wisegrade.indexing import create_course_index_task, create_reindex_task
|
||||
db = sqlite_db
|
||||
task = IndexTask(course_version_id=1, kind=kind, status="running")
|
||||
db.add(task)
|
||||
db.commit()
|
||||
monkeypatch.setattr(services, "enqueue_index_task", lambda *args: pytest.fail("不能重复提交运行中的任务"))
|
||||
create = create_course_index_task if kind == "course" else create_reindex_task
|
||||
assert create(db, 1).id == task.id
|
||||
|
||||
|
||||
def test_reimport_reuses_running_file_task(sqlite_db, environment, monkeypatch):
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
folder = environment / "inbox" / "test"
|
||||
folder.mkdir()
|
||||
path = folder / "report.pdf"
|
||||
path.write_bytes(b"unchanged")
|
||||
file = SubmissionFile(project_id=item.id, category="stage3", relative_path="test/report.pdf", mime_type="application/pdf",
|
||||
size=path.stat().st_size, mtime=path.stat().st_mtime, sha256=sha256(path),
|
||||
recognition_status="recognized", parse_status="pending")
|
||||
db.add(file)
|
||||
db.flush()
|
||||
task = IndexTask(course_version_id=1, submission_file_id=file.id, kind="project", status="running")
|
||||
db.add(task)
|
||||
db.commit()
|
||||
monkeypatch.setattr(services, "enqueue_index_task", lambda *args: pytest.fail("不能重复提交运行中的任务"))
|
||||
payload = SimpleNamespace(course_version_id=1, projects=[SimpleNamespace(relative_dir="test", student_no="9999900001",
|
||||
name="测试学生", topic="测试", direction="", files=[SimpleNamespace(relative_path="test/report.pdf", category="stage3",
|
||||
sha256=file.sha256, status="recognized")])])
|
||||
assert services.confirm_import(payload, db)["index_task_ids"] == [task.id]
|
||||
|
||||
|
||||
def test_injected_embedder_is_used_for_every_target(sqlite_db):
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
document(db, item)
|
||||
result = retrieve_targets(db, course_version_id=1, project_id=item.id, targets=normalize_targets(["响应时间"]), embedder=FLAT)
|
||||
assert result["evidence"]
|
||||
|
||||
|
||||
def test_unselected_file_is_excluded_even_if_document_is_ready(sqlite_db):
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
file = SubmissionFile(project_id=item.id, category="stage3", relative_path="old.pdf", mime_type="application/pdf",
|
||||
size=1, mtime=1, sha256="test", recognition_status="recognized", parse_status="ready")
|
||||
db.add(file)
|
||||
db.commit()
|
||||
ingest(db, course_version_id=1, project_id=item.id, submission_file_id=file.id, title="old.pdf", category="stage3",
|
||||
source_hash="test", pages=[{"page_number": 1, "text": "该旧材料记录了系统测试与响应时间的实验数据。"}], embedder=FLAT)
|
||||
assert retrieve(db, course_version_id=1, project_id=item.id, query="响应时间", embedder=FLAT, include_public=False)
|
||||
file.recognition_status = "ignored"
|
||||
db.commit()
|
||||
assert retrieve(db, course_version_id=1, project_id=item.id, query="响应时间", embedder=FLAT, include_public=False) == []
|
||||
|
||||
|
||||
def test_valid_target_cannot_be_attached_to_another_targets_chunk():
|
||||
criterion = {"criterion_id": "x", "max_score": 10, "required_evidence": ["实验数据", "实现方法"]}
|
||||
chunk = {"chunk_id": 1, "page": 1, "page_type": "body", "text": "采用视觉传感器实现位置反馈。", "target_ids": ["t1"]}
|
||||
result = ScoreResult(criterion_id="x", score=8, max_score=10, reason="错误关联", confidence=.8,
|
||||
evidence=[{"chunk_id": 1, "page": 1, "quote": chunk["text"], "target_id": "t0"}])
|
||||
with pytest.raises(ModelError, match="检索目标"):
|
||||
validate_score(result, criterion, [chunk])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", [None, 1])
|
||||
def test_worker_rejects_old_policy_without_touching_scores(sqlite_db, version):
|
||||
from wisegrade.scoring import run_batch
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
|
||||
batch = db.get(ScoringBatch, created["id"])
|
||||
snapshot = dict(batch.rubric_snapshot)
|
||||
snapshot["evidence_plan"] = {} if version is None else {"version": version}
|
||||
batch.rubric_snapshot = snapshot
|
||||
score = CriterionScore(batch_id=batch.id, project_id=item.id, criterion_id="stage3.data", teacher_score=0, status="reviewed")
|
||||
db.add(score)
|
||||
db.commit()
|
||||
factory = sessionmaker(bind=db.get_bind(), expire_on_commit=False)
|
||||
result = run_batch(batch.id, session_factory=factory, client_factory=lambda *args: pytest.fail("旧政策不应调用模型"))
|
||||
assert result["status"] == "failed"
|
||||
db.refresh(score)
|
||||
assert score.teacher_score == 0 and score.status == "reviewed"
|
||||
|
||||
|
||||
def test_reindex_preserves_original_pages_chunks_and_citations(sqlite_db):
|
||||
db = sqlite_db
|
||||
old = document(db, None, "课程资料历史")
|
||||
old.index_version = INDEX_VERSION - 1
|
||||
chunk = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == old.id))
|
||||
chunk_id, original_text = chunk.id, chunk.text
|
||||
original_page = db.get(KnowledgePage, (old.id, 1)).text
|
||||
db.commit()
|
||||
result = reindex_stale(db, 1, embedder=FLAT)
|
||||
db.refresh(old)
|
||||
assert old.status == "superseded"
|
||||
assert db.get(KnowledgeChunk, chunk_id).text == original_text
|
||||
assert db.get(KnowledgePage, (old.id, 1)).text == original_page
|
||||
current = db.scalar(select(KnowledgeDocument).where(KnowledgeDocument.title == old.title,
|
||||
KnowledgeDocument.status == "ready"))
|
||||
assert current.id != old.id and current.source_hash == old.source_hash
|
||||
assert result["rebuilt"][0]["replacement_document_id"] == current.id
|
||||
assert result["remaining"] == 0
|
||||
|
||||
|
||||
def test_reverting_same_file_hash_creates_a_new_revision(sqlite_db):
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
file = SubmissionFile(project_id=item.id, category="stage3", relative_path="report.pdf",
|
||||
mime_type="application/pdf", size=1, mtime=1, sha256="hash-a",
|
||||
recognition_status="recognized", parse_status="ready")
|
||||
db.add(file)
|
||||
db.commit()
|
||||
def insert(source_hash, category="stage3"):
|
||||
return ingest(db, course_version_id=1, project_id=item.id, submission_file_id=file.id,
|
||||
title="report.pdf", category=category, source_hash=source_hash,
|
||||
pages=[{"page_number": 1, "text": "原始实验记录包含响应时间与可核对的数据。"}], embedder=FLAT)
|
||||
first = insert("hash-a")
|
||||
original_chunk_id = db.scalar(select(KnowledgeChunk.id).where(KnowledgeChunk.document_id == first.id))
|
||||
second = insert("hash-b")
|
||||
restored = insert("hash-a", "final_report")
|
||||
db.refresh(first)
|
||||
db.refresh(second)
|
||||
assert restored.id not in (first.id, second.id)
|
||||
assert first.status == second.status == "superseded"
|
||||
assert restored.status == "ready" and restored.document_type == "final_report"
|
||||
assert db.get(KnowledgeChunk, original_chunk_id) is not None
|
||||
assert first.document_type == "stage3"
|
||||
assert restored.revision > first.revision
|
||||
assert insert("hash-a", "final_report").id == restored.id
|
||||
|
||||
|
||||
def test_index_task_rolls_back_failed_database_transaction(sqlite_db, monkeypatch):
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
file = SubmissionFile(project_id=item.id, category="stage3", relative_path="test.pdf", mime_type="application/pdf",
|
||||
size=1, mtime=1, sha256="test", recognition_status="recognized", parse_status="pending")
|
||||
db.add(file)
|
||||
db.flush()
|
||||
task = IndexTask(course_version_id=1, submission_file_id=file.id, kind="project", status="queued")
|
||||
db.add(task)
|
||||
db.commit()
|
||||
def broken_write(session, *args, **kwargs):
|
||||
session.add(KnowledgeDocument(course_version_id=999999, title="无效外键", document_type="stage3", source_hash="test"))
|
||||
session.flush()
|
||||
monkeypatch.setattr("wisegrade.indexing.parse_submission", broken_write)
|
||||
factory = sessionmaker(bind=db.get_bind(), expire_on_commit=False)
|
||||
run_index_task(task.id, session_factory=factory)
|
||||
db.refresh(task)
|
||||
assert task.status == "failed" and "FOREIGN KEY" in task.error
|
||||
assert db.scalar(select(KnowledgeDocument).where(KnowledgeDocument.title == "无效外键")) is None
|
||||
|
||||
|
||||
def test_course_index_continues_after_a_database_write_failure(sqlite_db, environment, monkeypatch):
|
||||
from wisegrade.models import CourseVersion
|
||||
db = sqlite_db
|
||||
version = db.get(CourseVersion, 1)
|
||||
version.templates = ["broken.pdf", "good.pdf"]
|
||||
db.commit()
|
||||
root = environment / "course-docs"
|
||||
root.mkdir()
|
||||
for filename in version.templates:
|
||||
(root / filename).write_bytes(b"test")
|
||||
monkeypatch.setattr(services, "Path", lambda value: root if value == "/app/course-docs" else Path(value))
|
||||
monkeypatch.setattr(services, "parse_document", lambda *args: {"pages": [{"page_number": 1,
|
||||
"text": "课程说明包含实验任务和可核对的评价指标。"}]})
|
||||
def ingest_with_failure(session, **kwargs):
|
||||
if kwargs["title"] == "broken.pdf":
|
||||
session.add(KnowledgeDocument(course_version_id=999999, title="无效课程", document_type="course", source_hash="test"))
|
||||
session.flush()
|
||||
return ingest(session, **kwargs, embedder=FLAT)
|
||||
monkeypatch.setattr(services, "ingest", ingest_with_failure)
|
||||
result = services.ingest_course(1, db)
|
||||
assert set(result["errors"]) == {"broken.pdf"}
|
||||
assert "good.pdf" in result["completed"]
|
||||
assert "已发布评分规则" in result["completed"]
|
||||
assert db.scalar(select(KnowledgeDocument).where(KnowledgeDocument.title == "无效课程")) is None
|
||||
|
||||
|
||||
def test_retired_score_and_chat_citations_are_labelled_without_rewriting(sqlite_db):
|
||||
from wisegrade.exports import project_result
|
||||
from wisegrade.models import ChatMessage
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
old = document(db, item, "历史材料.pdf")
|
||||
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
|
||||
batch = db.get(ScoringBatch, created["id"])
|
||||
criterion = batch.rubric_snapshot["criteria"][0]["criterion_id"]
|
||||
score = CriterionScore(batch_id=batch.id, project_id=item.id, criterion_id=criterion, status="scored", model_score=8)
|
||||
db.add(score)
|
||||
db.flush()
|
||||
db.add(ScoreEvidence(score_id=score.id, document_id=old.id, page=1, quote="响应时间为100毫秒",
|
||||
page_type="body", source_role="content", historical=False))
|
||||
conversation = Conversation(title="历史引用", course_version_id=1, project_id=item.id)
|
||||
db.add(conversation)
|
||||
db.flush()
|
||||
saved = {"document_id": old.id, "page": 1, "historical": False, "url": f"/api/knowledge/documents/{old.id}/pages/1"}
|
||||
message = ChatMessage(conversation_id=conversation.id, role="assistant", content="原始回答", citations=[saved])
|
||||
db.add(message)
|
||||
db.commit()
|
||||
ingest(db, course_version_id=1, project_id=item.id, submission_file_id=None, title=old.title,
|
||||
category="stage3", source_hash="new-version", pages=[{"page_number": 1, "text": "新实验测得响应时间为80毫秒。"}], embedder=FLAT)
|
||||
result = project_result(db, batch, item)
|
||||
assert result["rows"][0]["evidence"][0]["historical"] is True
|
||||
detail = chat_api._detail(db, conversation)
|
||||
assert detail["messages"][0]["citations"][0]["historical"] is True
|
||||
assert detail["messages"][0]["citations"][0]["url"] == saved["url"]
|
||||
db.refresh(message)
|
||||
assert message.citations == [saved]
|
||||
|
||||
|
||||
def test_document_revision_migration_preserves_legacy_rows_and_blocks_lossy_downgrade(environment, monkeypatch):
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import inspect, text
|
||||
backend = Path(__file__).resolve().parents[1]
|
||||
url = f"sqlite:///{environment / 'legacy-revisions.sqlite3'}"
|
||||
monkeypatch.setenv("DATABASE_URL", url)
|
||||
get_settings.cache_clear()
|
||||
config = Config(str(backend / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(backend / "migrations"))
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("""CREATE TABLE knowledge_documents (
|
||||
id INTEGER PRIMARY KEY, course_version_id INTEGER NOT NULL, project_id INTEGER,
|
||||
submission_file_id INTEGER, title VARCHAR(500), document_type VARCHAR(80),
|
||||
source_hash VARCHAR(64) NOT NULL, index_version INTEGER NOT NULL DEFAULT 4,
|
||||
status VARCHAR(40), UNIQUE(course_version_id, project_id, submission_file_id, source_hash))"""))
|
||||
connection.execute(text("CREATE TABLE knowledge_pages (document_id INTEGER REFERENCES knowledge_documents(id), page INTEGER, text TEXT)"))
|
||||
connection.execute(text("INSERT INTO knowledge_documents VALUES (10, 1, 2, 3, 'legacy.pdf', 'stage3', 'same-hash', 3, 'ready')"))
|
||||
connection.execute(text("INSERT INTO knowledge_pages VALUES (10, 1, 'original citation text')"))
|
||||
command.stamp(config, "0007")
|
||||
command.upgrade(config, "0008")
|
||||
with engine.begin() as connection:
|
||||
assert connection.execute(text("SELECT revision FROM knowledge_documents WHERE id=10")).scalar_one() == 1
|
||||
assert connection.execute(text("SELECT text FROM knowledge_pages WHERE document_id=10")).scalar_one() == "original citation text"
|
||||
connection.execute(text("""INSERT INTO knowledge_documents
|
||||
(id, course_version_id, project_id, submission_file_id, title, document_type, source_hash, index_version, status, revision)
|
||||
VALUES (11, 1, 2, 3, 'legacy.pdf', 'stage3', 'same-hash', 4, 'ready', 2)"""))
|
||||
with pytest.raises(RuntimeError, match="不能无损降级"):
|
||||
command.downgrade(config, "0007")
|
||||
with engine.connect() as connection:
|
||||
assert connection.execute(text("SELECT COUNT(*) FROM knowledge_documents")).scalar_one() == 2
|
||||
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == "0008"
|
||||
assert any(c["name"] == "revision" for c in inspect(engine).get_columns("knowledge_documents"))
|
||||
finally:
|
||||
engine.dispose()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_failed_rebuild_keeps_previous_citation_sources_and_retryable_index(sqlite_db):
|
||||
db = sqlite_db
|
||||
old = document(db, None, "失败后可重试")
|
||||
old.index_version = INDEX_VERSION - 1
|
||||
chunk_id = db.scalar(select(KnowledgeChunk.id).where(KnowledgeChunk.document_id == old.id))
|
||||
db.commit()
|
||||
def fail_embedding(texts):
|
||||
raise RuntimeError("测试嵌入失败")
|
||||
failed = reindex_stale(db, 1, embedder=fail_embedding)
|
||||
assert failed["remaining"] == 1 and len(failed["failed"]) == 1
|
||||
db.refresh(old)
|
||||
assert old.status == "ready" and db.get(KnowledgeChunk, chunk_id) is not None
|
||||
assert reindex_stale(db, 1, embedder=FLAT)["remaining"] == 0
|
||||
assert db.get(KnowledgeChunk, chunk_id) is not None
|
||||
|
||||
|
||||
def test_overlapping_public_reindex_jobs_create_only_one_current_revision(sqlite_db, monkeypatch):
|
||||
from contextlib import contextmanager
|
||||
from threading import Barrier
|
||||
from wisegrade import indexing
|
||||
db = sqlite_db
|
||||
old = document(db, None, "并发课程重建")
|
||||
old.index_version = INDEX_VERSION - 1
|
||||
db.commit()
|
||||
factory = sessionmaker(bind=db.get_bind(), expire_on_commit=False)
|
||||
barrier = Barrier(2)
|
||||
real_lock = indexing.file_index_lock
|
||||
@contextmanager
|
||||
def overlapping(lock_id):
|
||||
# Both jobs have selected the same stale snapshot before either acquires
|
||||
# the real filesystem lock, reproducing an overlapping queue delivery.
|
||||
barrier.wait(timeout=10)
|
||||
with real_lock(lock_id):
|
||||
yield
|
||||
monkeypatch.setattr(indexing, "file_index_lock", overlapping)
|
||||
def rebuild(_):
|
||||
with factory() as session:
|
||||
return reindex_stale(session, 1, embedder=FLAT)
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(rebuild, range(2)))
|
||||
assert all(result["remaining"] == 0 and not result["failed"] for result in results)
|
||||
states = db.scalars(select(KnowledgeDocument.status).where(KnowledgeDocument.title == old.title)).all()
|
||||
assert len(states) == 2
|
||||
assert states.count("ready") == 1
|
||||
|
||||
|
||||
def test_knowledge_inventory_batches_chunk_counts_and_flags_failed_indexes(sqlite_db):
|
||||
from wisegrade.api import knowledge
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
for number in range(25):
|
||||
document(db, item, f"材料-{number}.pdf")
|
||||
failed = KnowledgeDocument(course_version_id=1, project_id=item.id, title="失败索引", document_type="stage3",
|
||||
source_hash="failed", index_version=INDEX_VERSION, status="failed")
|
||||
db.add(failed)
|
||||
db.commit()
|
||||
queries = []
|
||||
def capture(connection, cursor, statement, parameters, context, executemany):
|
||||
queries.append(statement)
|
||||
event.listen(db.get_bind(), "before_cursor_execute", capture)
|
||||
try:
|
||||
rows = knowledge(1, item.id, db)
|
||||
finally:
|
||||
event.remove(db.get_bind(), "before_cursor_execute", capture)
|
||||
assert len(queries) <= 2
|
||||
assert len(rows) == 26
|
||||
assert next(row for row in rows if row["id"] == failed.id)["requires_reindex"] is True
|
||||
assert all(row["chunks"] > 0 and row["revision"] == 1 for row in rows if row["id"] != failed.id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gap", [" ", " " * 50, "\t"])
|
||||
def test_undotted_contents_titles_are_navigation_for_every_alignment(gap):
|
||||
from wisegrade.rag import build_chunks
|
||||
pages = [{"page_number": 1, "text": f"目录\n1 装置设计{gap}3\n2 控制策略{gap}4\n3 验证结果{gap}5"},
|
||||
{"page_number": 2, "text": "1 装置设计\n本装置采用位置传感器完成电机闭环反馈,实验记录响应时间。"}]
|
||||
classification = classify_document(pages)
|
||||
assert classification[0]["page_type"] == "toc" and classification[0]["mixed"] is False
|
||||
chunks = build_chunks(pages)
|
||||
assert all(chunk["page_type"] == "toc" for chunk in chunks if chunk["page"] == 1)
|
||||
assert any(chunk["page_type"] == "body" for chunk in chunks if chunk["page"] == 2)
|
||||
|
||||
|
||||
def test_undotted_contents_context_stops_when_actual_body_begins():
|
||||
pages = [{"page_number": 1, "text": "目录\n1 装置设计 3\n2 控制策略 4\n"
|
||||
"1 装置设计\n本装置通过位置传感器反馈电机状态,测量数据如下。\n1 供电电压 3"}]
|
||||
classified = classify_document(pages)[0]
|
||||
assert classified["page_type"] == "toc" and classified["mixed"] is True
|
||||
assert any("供电电压" in block["text"] and block["kind"] == "body" for block in classified["blocks"])
|
||||
Reference in New Issue
Block a user