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
+4 -49
View File
@@ -1,8 +1,6 @@
"""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
@@ -20,7 +18,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, KnowledgeChunk, PendingAction, Project, ScoreEvidence, ScoringBatch, now
from .models import AgentRun, ChatMessage, Conversation, PendingAction, Project, ScoringBatch, now
from .settings_store import client_for_chat
router = APIRouter(prefix="/api/conversations")
@@ -90,48 +88,6 @@ 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()
@@ -143,11 +99,10 @@ 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", "tool_calls", "created_at")},
"citations": restored_citations.get(msg.id, msg.citations)} for msg in messages],
"citations": 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)}
@@ -265,7 +220,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), ("text_url", 500))
(("title", 200), ("quote", 500), ("url", 500), ("image_url", 500), ("score_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)}
@@ -280,7 +235,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("text_url"))
safe.get("page"), safe.get("quote"), safe.get("url"), safe.get("score_url"))
if key not in seen:
seen.add(key)
citations.append(safe)