feat: unify scoring and chat evidence pages
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
Reference in New Issue
Block a user