fix: link score citations to their source text pages
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user