feat: separate contents, abstract and body evidence

This commit is contained in:
2026-10-01 00:07:40 +08:00
parent 540d0e2918
commit b7634c8374
25 changed files with 2592 additions and 172 deletions
+46 -11
View File
@@ -1,6 +1,8 @@
"""Persistent teacher conversations and SSE agent orchestration."""
import json
import re
import time
import unicodedata
from concurrent.futures import ThreadPoolExecutor
from datetime import timedelta
from threading import Lock
@@ -170,7 +172,8 @@ def _safe_arguments(arguments):
if not isinstance(arguments, dict):
return {}
numeric = {"course_version_id", "batch_id", "project_id", "score_id", "task_id",
"rubric_version_id", "score", "offset", "limit", "project_offset", "criterion_offset"}
"rubric_version_id", "score", "offset", "limit", "project_offset", "criterion_offset",
"document_id"}
text_limits = {"query": 300, "criterion_id": 100, "format": 20, "status": 30,
"student_no": 50, "relative_dir": 500}
result = {}
@@ -219,26 +222,58 @@ def _result_summary(result):
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), ("score_url", 500))
# The evidence-policy fields travel with the citation so the interface can tell a
# report body page from a contents page, and an answer's source from a historical
# scoring quotation.
label_limits = (("title", 200), ("quote", 500), ("url", 500), ("image_url", 500), ("score_url", 500),
("citation_id", 80), ("page_type", 20), ("page_type_label", 30),
("source_role", 20), ("source", 40), ("usage", 60), ("target_label", 120))
return {key: value[:limit] if isinstance(value, str) else value for key, limit in label_limits
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)}
key: item[key] for key in ("chunk_id", "document_id", "page", "project_id", "historical")
if isinstance(item.get(key), (int, bool)) and not isinstance(item.get(key), str)}
def _answer_citations(items):
"""Keep each distinct source passage, including later tool calls and pages cited by the answer."""
def _comparable_text(value):
return "".join(char for char in unicodedata.normalize("NFKC", str(value or "")) if not char.isspace())
def _cited_pages(content: str) -> set[int]:
"""Page and slide numbers the answer actually refers to."""
numbers = set()
for match in re.finditer(r"第\s*(\d{1,4})\s*(?:页|张幻灯片|页幻灯片)", str(content or "")):
numbers.add(int(match.group(1)))
return numbers
def _answer_citations(items, content: str = ""):
"""Attach only the citations the answer actually used.
Collecting every tool hit would list material the answer never drew on. A passage is
kept when the answer quotes it, or when the answer names the page it sits on. When
the answer names no page and quotes nothing, no citation is attached: the answer made
no specific evidential claim.
"""
seen = set()
citations = []
answer = _comparable_text(content)
pages = _cited_pages(content)
for item in items:
safe = _safe_citation(item)
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("score_url"))
if key not in seen:
seen.add(key)
citations.append(safe)
if key in seen:
continue
quote = _comparable_text(safe.get("quote"))
used = bool(quote) and len(quote) >= 6 and quote[:80] in answer
if not used and pages and isinstance(safe.get("page"), int):
used = safe["page"] in pages
if not used:
continue
seen.add(key)
citations.append(safe)
return citations
@@ -326,7 +361,7 @@ def _run_agent(factory, conversation_id, run_id, user_text):
content = reply.get("content")
if not isinstance(content, str) or not content.strip():
raise ModelError("会话模型未返回文字或工具调用")
answer_citations = _answer_citations(citations)
answer_citations = _answer_citations(citations, content)
db.add(ChatMessage(conversation_id=conversation_id, role="assistant", content=content,
citations=answer_citations, tool_calls=used))
run.status, run.finished_at = "completed", now()