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
+42 -1
View File
@@ -1,9 +1,12 @@
from fastapi.testclient import TestClient
from sqlalchemy import select
from wisegrade import agent_tools
from wisegrade.db import get_db
from wisegrade.main import app
from wisegrade.models import CourseVersion, CriterionScore, Project, RubricVersion, Student
from wisegrade.models import (Conversation, CourseVersion, CriterionScore, KnowledgeChunk,
KnowledgePage, Project, RubricVersion, Student)
from wisegrade.rag import ingest
from wisegrade.scoring import run_batch
@@ -15,6 +18,44 @@ def client_for(session_factory):
return TestClient(app)
def test_file_page_has_text_and_image_virtual_page_has_text_only(session_factory, environment, monkeypatch):
image = environment / "processed" / "course-1" / "report" / "page-49.png"
image.parent.mkdir(parents=True)
image.write_bytes(b"\x89PNG\r\n\x1a\n")
vectors = lambda texts: [[0.1] * 1024 for _ in texts]
with session_factory() as db:
document = ingest(db, course_version_id=1, project_id=None, submission_file_id=None,
title="课程报告.pdf", category="course", source_hash="file",
pages=[{"page_number": 49, "text": "第四十九页完整文字", "tables": [{"rows": [["指标", "结果"]]}],
"rendered_image_path": str(image)}], embedder=vectors)
virtual = ingest(db, course_version_id=1, project_id=None, submission_file_id=None,
title="系统生成规则", category="course", source_hash="virtual",
pages=[{"page_number": 1, "text": "规则文字"}], embedder=vectors)
assert db.get(KnowledgePage, (document.id, 49)).image_path == "course-1/report/page-49.png"
chunk = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id))
monkeypatch.setattr(agent_tools, "retrieve", lambda *_, **__: [{"chunk_id": chunk.id,
"document_id": document.id, "project_id": None, "page": 49, "text": chunk.text}])
citation = agent_tools.execute(db, Conversation(course_version_id=1), "search_course_material",
{"course_version_id": 1, "query": "完整文字"}, "")["citations"][0]
assert citation["url"] == f"/api/knowledge/documents/{document.id}/pages/49"
assert citation["image_url"] == citation["url"] + "/image"
document_id, virtual_id = document.id, virtual.id
client = client_for(session_factory)
try:
resource = client.get(f"/api/knowledge/documents/{document_id}/pages/49")
assert resource.status_code == 200
assert resource.json()["text"] == "第四十九页完整文字\n表格: [['指标', '结果']]"
image_url = resource.json()["image_url"]
assert image_url == f"/api/knowledge/documents/{document_id}/pages/49/image"
assert client.get(image_url).content.startswith(b"\x89PNG")
virtual_resource = client.get(f"/api/knowledge/documents/{virtual_id}/pages/1")
assert virtual_resource.json()["text"] == "规则文字"
assert virtual_resource.json()["image_url"] is None
assert client.get(f"/api/knowledge/documents/{virtual_id}/pages/1/image").status_code == 404
finally:
app.dependency_overrides.clear()
def test_course_and_rubric_versions_are_immutable(session_factory):
client = client_for(session_factory)
try:
+45 -6
View File
@@ -14,12 +14,13 @@ from wisegrade.actions import confirm, prepare
from wisegrade.agent_tools import execute
from wisegrade.db import get_db
from wisegrade.indexing import run_index_task
from wisegrade.llm import DeepSeekClient, ModelError
from wisegrade.llm import DeepSeekClient, ModelError, ScoreResult
from wisegrade.main import app
from wisegrade.models import (AgentRun, ChatMessage, Conversation, CourseVersion, CriterionScore, IndexTask, PendingAction,
Project, RubricVersion, ScoringBatch, Student, TeacherRevision, now)
Project, RubricVersion, ScoreEvidence, ScoringBatch, Student, SubmissionFile,
TeacherRevision, now)
from wisegrade.rag import ingest
from wisegrade.scoring import run_batch
from wisegrade.scoring import _score_criterion, run_batch
from wisegrade.storage import scan
@@ -93,9 +94,9 @@ def test_get_scores_pages_projects_and_criteria(db, monkeypatch):
assert first_page["results"][0]["project_id"] == first.id
assert len(first_page["results"][0]["rows"]) == 6
assert first_page["next_criterion_offset"] == 6 and first_page["next_project_offset"] == 1
assert first_page["citations"][0]["url"] == f"/?page=scores&batch_id={created['id']}&project_id={first.id}"
assert first_page["citations"][0]["image_url"] == f"/api/assets/{first.id}/1"
assert first_page["citations"][0]["text_url"] == "/api/knowledge/documents/100/pages/1"
assert first_page["citations"][0]["url"] is None
assert first_page["citations"][0]["image_url"] is None
assert first_page["citations"][0]["score_url"] == f"/?page=scores&batch_id={created['id']}&project_id={first.id}"
assert first_page["citations"][1]["url"] == first_page["citations"][0]["url"]
assert first_page["citations"][1]["image_url"] is None
assert len(json.dumps(first_page, ensure_ascii=False)) < 12000
@@ -106,6 +107,44 @@ def test_get_scores_pages_projects_and_criteria(db, monkeypatch):
assert later["next_project_offset"] is None and later["next_criterion_offset"] is None
def test_scoring_uses_the_same_page_image_as_its_saved_evidence(db, environment):
project = add_project(db, "123450090")
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]
file = SubmissionFile(project_id=project.id, category=criterion["category"],
relative_path="test/score/source.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" / f"file-{file.id}" / "page-2.png"
image.parent.mkdir(parents=True)
image.write_bytes(b"\x89PNG\r\n\x1a\n")
source = {"page_number": 2, "text": "评分所用原文证据", "rendered_image_path": str(image)}
file.parsed = {"pages": [source]}
document = ingest(db, course_version_id=1, project_id=project.id, submission_file_id=file.id,
title="报告.pdf", category=criterion["category"], source_hash=file.sha256,
pages=[source], embedder=lambda texts: [[0.1] * 1024 for _ in texts])
from wisegrade.models import KnowledgeChunk
chunk = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id))
evidence = {"chunk_id": chunk.id, "document_id": document.id, "project_id": project.id,
"page": 2, "text": chunk.text}
sent_images = []
class FakeClient:
def json_call(self, _prompt, _payload, _schema, *, images=None):
sent_images.extend(images or [])
return ScoreResult(criterion_id=criterion["criterion_id"], score=1,
max_score=criterion["max_score"], reason="测试评分",
evidence=[{"chunk_id": chunk.id, "page": 2, "quote": "原文证据"}], confidence=.9)
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))
assert sent_images == [str(image)]
assert saved.document_id == document.id and saved.page == 2 and saved.image_path == str(image)
def test_import_confirmation_hash_change_index_and_idempotency(environment, db, monkeypatch, session_factory):
folder = environment / "inbox" / "batch" / "123450003_张三_机器人"
folder.mkdir(parents=True)
+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"
+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")) == "0004"
assert db.scalar(text("SELECT version_num FROM alembic_version")) == "0005"
assert db.scalar(text("SELECT extname FROM pg_extension WHERE extname = 'vector'")) == "vector"
+3 -1
View File
@@ -3,7 +3,7 @@ from PIL import Image, ImageDraw
from sqlalchemy import select
from wisegrade.llm import DeepSeekClient, FactResult, ModelError, ScoreResult, validate_score
from wisegrade.models import AppSetting, KnowledgeChunk, Project, Student
from wisegrade.models import AppSetting, KnowledgeChunk, KnowledgePage, Project, Student
from wisegrade.rag import ingest, local_encoder, retrieve, split_pages
from wisegrade.settings_store import model_config, save_model_config
@@ -82,12 +82,14 @@ def test_stale_knowledge_index_reuses_document_id(db):
title="报告", category="stage3", source_hash="same-source", pages=pages)
old_chunk = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id))
old_chunk.text = "过时片段"
db.get(KnowledgePage, (document.id, 1)).text = "过时整页"
document.index_version = 1
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 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"]
def test_model_key_is_encrypted_and_hidden(db):