feat: unify scoring and chat evidence pages

This commit is contained in:
2026-09-27 17:47:32 +08:00
parent 039e93d0de
commit 83aa0e84a2
21 changed files with 511 additions and 132 deletions
+84 -24
View File
@@ -12,8 +12,9 @@ from wisegrade import agent_tools, chat_api, services
from wisegrade.db import get_db
from wisegrade.main import app
from wisegrade.models import (AgentRun, AuditLog, ChatMessage, Conversation, CourseVersion, CriterionScore,
KnowledgeChunk, KnowledgeDocument, PendingAction, Project, ScoreEvidence,
KnowledgeChunk, KnowledgeDocument, KnowledgePage, PendingAction, Project, ScoreEvidence,
ScoringBatch, Student, SubmissionFile, now)
from wisegrade.rag import ingest, split_pages
def client_for(session_factory):
@@ -179,7 +180,7 @@ def test_tool_record_keeps_bounded_query_but_redacts_review_text_and_secrets():
assert 'secret' not in str(search)
def test_score_citations_link_text_and_restore_legacy_image_only(session_factory):
def test_score_citations_link_the_canonical_text_and_image_page(session_factory, environment):
with session_factory() as db:
student = Student(student_no="987654320", name="测试学生")
db.add(student)
@@ -192,13 +193,15 @@ def test_score_citations_link_text_and_restore_legacy_image_only(session_factory
recognition_status="recognized", parse_status="ready")
db.add(file)
db.flush()
document = KnowledgeDocument(course_version_id=1, project_id=project.id, submission_file_id=file.id,
title="报告.pdf", document_type="final_report", source_hash="a" * 64,
index_version=2, status="ready")
db.add(document)
db.flush()
db.add(KnowledgeChunk(document_id=document.id, course_version_id=1, project_id=project.id,
document_type="final_report", page=2, section="测试", text="这里有原文证据和分析"))
image_path = environment / "processed" / f"file-{file.id}" / "page-2.png"
image_path.parent.mkdir(parents=True)
image_path.write_bytes(b"\x89PNG\r\n\x1a\n")
source = {"page_number": 2, "section": "测试", "text": "这里有原文证据和分析",
"rendered_image_path": str(image_path)}
file.parsed = {"pages": [source]}
document = ingest(db, course_version_id=1, project_id=project.id, submission_file_id=file.id,
title="报告.pdf", category="final_report", source_hash="a" * 64,
pages=[source], embedder=lambda texts: [[0.1] * 1024 for _ in texts])
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
batch = db.get(ScoringBatch, created["id"])
score = CriterionScore(batch_id=batch.id, project_id=project.id,
@@ -207,32 +210,33 @@ def test_score_citations_link_text_and_restore_legacy_image_only(session_factory
db.add(score)
db.flush()
db.add(ScoreEvidence(score_id=score.id, document_id=document.id, submission_file_id=file.id,
page=2, quote="原文证据", image_path="page-2.png"))
conversation = Conversation(title="旧评分引用", course_version_id=1, batch_id=batch.id, project_id=project.id)
page=2, quote="原文证据", image_path=str(image_path)))
conversation = Conversation(title="评分引用", course_version_id=1, batch_id=batch.id, project_id=project.id)
db.add(conversation)
db.flush()
image_url = f"/api/assets/{file.id}/2"
db.add(ChatMessage(conversation_id=conversation.id, role="assistant", content="历史回答", citations=[
{"title": "报告.pdf", "page": 2, "quote": "原文证据", "url": image_url},
{"title": "报告.pdf", "page": 2, "quote": "不匹配的引文", "url": image_url}]))
db.commit()
expected = f"/api/knowledge/documents/{document.id}/pages/2"
db.add(ChatMessage(conversation_id=conversation.id, role="assistant", content="历史回答", citations=[
{"title": "报告.pdf", "document_id": document.id, "page": 2, "quote": "原文证据",
"url": expected, "image_url": expected + "/image"}]))
db.commit()
generated = agent_tools.execute(db, conversation, "get_scores",
{"batch_id": batch.id, "project_id": project.id}, "")
assert generated["citations"][0]["text_url"] == expected
assert chat_api._answer_citations(generated["citations"])[0]["text_url"] == expected
assert generated["citations"][0]["image_url"] == image_url
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
conversation_id = conversation.id
client = client_for(session_factory)
try:
detail = client.get(f"/api/conversations/{conversation_id}").json()
citations = detail["messages"][0]["citations"]
assert citations[0]["text_url"] == expected
assert "text_url" not in citations[1]
assert citations[0]["url"] == expected
assert citations[0]["image_url"] == expected + "/image"
assert "原文证据" in client.get(expected).json()["text"]
assert client.get(expected + "/image").content.startswith(b"\x89PNG")
with session_factory() as db:
stored = db.scalar(select(ChatMessage).where(ChatMessage.conversation_id == conversation_id))
assert "text_url" not in stored.citations[0]
assert stored.citations[0]["url"] == expected
finally:
app.dependency_overrides.clear()
@@ -307,7 +311,7 @@ def test_large_score_result_keeps_structured_rows_for_model_and_history(session_
app.dependency_overrides.clear()
def test_upgrade_from_legacy_0003_preserves_history(session_factory):
def test_upgrade_from_legacy_0003_migrates_pages_and_citations(session_factory, environment):
from pathlib import Path
from alembic import command
from alembic.config import Config
@@ -319,21 +323,77 @@ def test_upgrade_from_legacy_0003_preserves_history(session_factory):
db.flush()
db.add(ChatMessage(conversation_id=row.id, role="user", content="旧问题"))
db.add(AgentRun(conversation_id=row.id, status="completed"))
document = KnowledgeDocument(course_version_id=1, title="旧课程资料", document_type="course",
source_hash="legacy", index_version=2, status="ready")
db.add(document)
db.flush()
legacy_text = "旧资料第三页的完整文字。" * 80
for part in split_pages([{"page_number": 3, "text": legacy_text}]):
db.add(KnowledgeChunk(document_id=document.id, course_version_id=1, project_id=None,
document_type="course", page=3, text=part["text"]))
student = Student(student_no="987654322", name="迁移测试")
db.add(student)
db.flush()
project = Project(course_version_id=1, student_id=student.id, topic="迁移", source_dir="test/migration")
db.add(project)
db.flush()
file = SubmissionFile(project_id=project.id, category="final_report", relative_path="test/migration/report.pdf",
mime_type="application/pdf", size=1, mtime=1, sha256="b" * 64,
recognition_status="recognized", parse_status="ready")
db.add(file)
db.flush()
image = environment / "processed" / "migration" / "page-2.png"
image.parent.mkdir(parents=True)
image.write_bytes(b"\x89PNG\r\n\x1a\n")
file.parsed = {"pages": [{"page_number": 2, "text": "迁移评分证据原文", "rendered_image_path": str(image)}]}
student_doc = KnowledgeDocument(course_version_id=1, project_id=project.id,
submission_file_id=file.id, title="迁移报告.pdf", document_type="final_report",
source_hash="student", index_version=2, status="ready")
db.add(student_doc)
db.flush()
db.add(KnowledgeChunk(document_id=student_doc.id, course_version_id=1, project_id=project.id,
document_type="final_report", page=2, text="迁移评分证据原文"))
batch_id = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)["id"]
batch = db.get(ScoringBatch, batch_id)
score = CriterionScore(batch_id=batch_id, project_id=project.id,
criterion_id=batch.rubric_snapshot["criteria"][0]["criterion_id"], status="scored")
db.add(score)
db.flush()
db.add(ScoreEvidence(score_id=score.id, document_id=student_doc.id, submission_file_id=file.id,
page=2, quote="迁移评分证据", image_path=str(image)))
assistant = ChatMessage(conversation_id=row.id, role="assistant", content="旧回答", citations=[
{"title": "旧课程资料", "page": 3, "quote": legacy_text[:30],
"url": f"/api/knowledge/documents/{document.id}/pages/3"},
{"title": "迁移报告.pdf", "page": 2, "quote": "迁移评分证据",
"url": f"/api/assets/{file.id}/2"}])
db.add(assistant)
db.commit()
conversation_id = row.id
document_id = document.id
student_doc_id = student_doc.id
engine = session_factory.kw['bind']
with engine.begin() as connection:
connection.exec_driver_sql('ALTER TABLE conversations DROP COLUMN first_request_id')
connection.exec_driver_sql('ALTER TABLE agent_runs DROP COLUMN tool_calls')
connection.exec_driver_sql('ALTER TABLE agent_runs DROP COLUMN events')
connection.exec_driver_sql('DROP TABLE knowledge_pages')
connection.exec_driver_sql("UPDATE alembic_version SET version_num = '0003'")
root = Path(__file__).resolve().parents[1]
config = Config(str(root / 'alembic.ini'))
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')) == '0004'
assert db.scalar(text('SELECT version_num FROM alembic_version')) == '0005'
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))
assert run.tool_calls == [] and run.events == []
assert db.get(KnowledgePage, (document_id, 3)).text == legacy_text
saved = db.scalar(select(ChatMessage).where(ChatMessage.conversation_id == conversation_id,
ChatMessage.role == "assistant"))
assert saved.citations[0]["document_id"] == document_id
assert saved.citations[0]["url"] == f"/api/knowledge/documents/{document_id}/pages/3"
score_citation = saved.citations[1]
assert score_citation["document_id"] == student_doc_id
assert score_citation["url"] == f"/api/knowledge/documents/{student_doc_id}/pages/2"
assert score_citation["image_url"] == score_citation["url"] + "/image"