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:
2026-10-14 13:52:56 +08:00
parent 29c95ce5ad
commit ebe33392ca
42 changed files with 2332 additions and 304 deletions
+808
View File
@@ -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"])