fix: link score citations to their source text pages

This commit is contained in:
2026-09-27 17:15:55 +08:00
parent 92906c82bc
commit 039e93d0de
9 changed files with 143 additions and 18 deletions
+2 -1
View File
@@ -83,7 +83,7 @@ def test_get_scores_pages_projects_and_criteria(db, monkeypatch):
"rows": [{"criterion": {"criterion_id": f"C{index}", "name": f"评分项{index}", "max_score": 10},
"score_id": index, "model_score": 8, "teacher_score": None, "final_score": 8,
"status": "scored", "error": "", "evidence": [{"page": index + 1,
"quote": "证据" * 200, "document": "报告.pdf",
"quote": "证据" * 200, "document": "报告.pdf", "document_id": index + 100,
"image_url": f"/api/assets/{item.id}/1" if index == 0 else None}]}
for index in range(14)]}
@@ -95,6 +95,7 @@ def test_get_scores_pages_projects_and_criteria(db, monkeypatch):
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"][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
+63 -2
View File
@@ -2,15 +2,18 @@
import time
from concurrent.futures import ThreadPoolExecutor
from threading import Event
from types import SimpleNamespace
from uuid import uuid4
from fastapi.testclient import TestClient
from sqlalchemy import func, select
from wisegrade import chat_api
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, PendingAction, Project, ScoringBatch, Student, now
from wisegrade.models import (AgentRun, AuditLog, ChatMessage, Conversation, CourseVersion, CriterionScore,
KnowledgeChunk, KnowledgeDocument, PendingAction, Project, ScoreEvidence,
ScoringBatch, Student, SubmissionFile, now)
def client_for(session_factory):
@@ -176,6 +179,64 @@ 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):
with session_factory() as db:
student = Student(student_no="987654320", name="测试学生")
db.add(student)
db.flush()
project = Project(course_version_id=1, student_id=student.id, topic="测试项目", source_dir="test/citation")
db.add(project)
db.flush()
file = SubmissionFile(project_id=project.id, category="final_report", relative_path="test/citation/report.pdf",
mime_type="application/pdf", size=1, mtime=1, sha256="a" * 64,
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="这里有原文证据和分析"))
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,
criterion_id=batch.rubric_snapshot["criteria"][0]["criterion_id"],
model_score=8, status="scored")
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)
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"
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
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 "原文证据" in client.get(expected).json()["text"]
with session_factory() as db:
stored = db.scalar(select(ChatMessage).where(ChatMessage.conversation_id == conversation_id))
assert "text_url" not in stored.citations[0]
finally:
app.dependency_overrides.clear()
def test_late_citations_and_search_snippets_survive_restoration(session_factory, monkeypatch):
class FakeClient:
def tool_call(self, messages, _tools):
+3 -1
View File
@@ -285,7 +285,9 @@ def scores(db, convo, args, _user):
for evidence in row["evidence"][:2]:
citations.append({"title": str(evidence["document"])[:200], "page": evidence["page"],
"quote": evidence["quote"][:320],
"url": url, "image_url": evidence["image_url"]})
"url": url, "image_url": evidence["image_url"],
"text_url": f"/api/knowledge/documents/{evidence['document_id']}/pages/{evidence['page']}"
if evidence.get("document_id") else None})
next_criterion_offset = args.criterion_offset + 6 if page and args.criterion_offset + 6 < len(page[0][1]["rows"]) else None
next_project_offset = args.project_offset + 1 if args.project_offset + 1 < len(results) else None
return {"batch_id": batch.id, "status_filter": args.status, "count": len(results),
+51 -5
View File
@@ -1,6 +1,8 @@
"""Persistent teacher conversations and SSE agent orchestration."""
import json
import re
import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from datetime import timedelta
from threading import Lock
@@ -18,7 +20,7 @@ from .agent_tools import TOOLS, execute, tool_specs
from .config import get_settings
from .db import get_db
from .llm import ModelError
from .models import AgentRun, ChatMessage, Conversation, PendingAction, Project, ScoringBatch, now
from .models import AgentRun, ChatMessage, Conversation, KnowledgeChunk, PendingAction, Project, ScoreEvidence, ScoringBatch, now
from .settings_store import client_for_chat
router = APIRouter(prefix="/api/conversations")
@@ -88,6 +90,48 @@ def _validated_scope(db, data):
return scope
def _restored_citations(db, messages):
"""Resolve old score image links only when saved evidence identifies one live text page."""
refs = []
originals = {msg.id: msg.citations or [] for msg in messages}
for msg in messages:
for index, item in enumerate(originals[msg.id]):
if not isinstance(item, dict) or item.get("text_url") or re.fullmatch(
r"/api/knowledge/documents/\d+/pages/\d+/?", str(item.get("url") or "")):
continue
match = re.fullmatch(r"/api/assets/(\d+)/(\d+)/?", str(item.get("image_url") or item.get("url") or ""))
if match and isinstance(item.get("quote"), str) and item["quote"]:
refs.append((msg.id, index, item, int(match[1]), int(match[2])))
if not refs:
return {}
file_ids = {file_id for _, _, _, file_id, _ in refs}
pages = {page for _, _, _, _, page in refs}
evidence = defaultdict(list)
for file_id, page, quote, document_id in db.execute(select(
ScoreEvidence.submission_file_id, ScoreEvidence.page, ScoreEvidence.quote, ScoreEvidence.document_id).where(
ScoreEvidence.submission_file_id.in_(file_ids), ScoreEvidence.page.in_(pages),
ScoreEvidence.document_id.is_not(None))):
evidence[file_id, page].append((quote, document_id))
document_ids = {document_id for rows in evidence.values() for _, document_id in rows}
if not document_ids:
return {}
chunks = defaultdict(list)
for document_id, page, content in db.execute(select(
KnowledgeChunk.document_id, KnowledgeChunk.page, KnowledgeChunk.text).where(
KnowledgeChunk.document_id.in_(document_ids), KnowledgeChunk.page.in_(pages))):
chunks[document_id, page].append(content)
restored = {}
for message_id, index, item, file_id, page in refs:
quote = item["quote"]
matches = {document_id for saved_quote, document_id in evidence[file_id, page]
if saved_quote.startswith(quote) and any(quote in content for content in chunks[document_id, page])}
if len(matches) == 1:
citations = restored.setdefault(message_id, [dict(value) if isinstance(value, dict) else value
for value in originals[message_id]])
citations[index]["text_url"] = f"/api/knowledge/documents/{matches.pop()}/pages/{page}"
return restored
def _detail(db, conversation):
stale = db.scalars(select(AgentRun).where(AgentRun.conversation_id == conversation.id,
AgentRun.status == "running", AgentRun.created_at < now() - timedelta(minutes=15))).all()
@@ -99,9 +143,11 @@ def _detail(db, conversation):
db.commit()
runs = db.scalars(select(AgentRun).where(AgentRun.conversation_id == conversation.id).order_by(AgentRun.id.desc()).limit(20)).all()
messages = db.scalars(select(ChatMessage).where(ChatMessage.conversation_id == conversation.id).order_by(ChatMessage.id)).all()
restored_citations = _restored_citations(db, messages)
actions = db.scalars(select(PendingAction).where(PendingAction.conversation_id == conversation.id).order_by(PendingAction.id)).all()
return {**_summary(conversation), "messages": [{key: getattr(msg, key) for key in
("id", "role", "content", "citations", "tool_calls", "created_at")} for msg in messages],
return {**_summary(conversation), "messages": [{**{key: getattr(msg, key) for key in
("id", "role", "content", "tool_calls", "created_at")},
"citations": restored_citations.get(msg.id, msg.citations)} for msg in messages],
"actions": [action_dict(action) for action in actions],
"runs": [{key: getattr(run, key) for key in ("id", "status", "error", "tool_calls", "created_at", "finished_at")} for run in runs],
"scope_locked": any(msg.role == "user" for msg in messages)}
@@ -219,7 +265,7 @@ def _safe_citation(item):
if not isinstance(item, dict):
return None
return {key: value[:limit] if isinstance(value, str) else value for key, limit in
(("title", 200), ("quote", 500), ("url", 500), ("image_url", 500))
(("title", 200), ("quote", 500), ("url", 500), ("image_url", 500), ("text_url", 500))
if isinstance((value := item.get(key)), str)} | {
key: item[key] for key in ("chunk_id", "document_id", "page", "project_id")
if isinstance(item.get(key), int)}
@@ -234,7 +280,7 @@ def _answer_citations(items):
if not safe:
continue
key = (safe.get("document_id"), safe.get("chunk_id"), safe.get("title"),
safe.get("page"), safe.get("quote"), safe.get("url"))
safe.get("page"), safe.get("quote"), safe.get("url"), safe.get("text_url"))
if key not in seen:
seen.add(key)
citations.append(safe)
+1
View File
@@ -29,6 +29,7 @@ def project_result(db, batch: ScoringBatch, project: Project):
"missing_evidence": score.missing_evidence if score else [], "suggestion": score.suggestion if score else "",
"status": score.status if score else "pending", "error": score.error if score else "",
"evidence": [{"page": item.page, "quote": item.quote, "file_id": item.submission_file_id,
"document_id": item.document_id,
"document": db.get(SubmissionFile, item.submission_file_id).relative_path if item.submission_file_id else "课程资料",
"image_url": f"/api/assets/{item.submission_file_id}/{item.page}" if item.image_path else None} for item in evidence]})
student = db.get(Student, project.student_id)