feat: separate contents, abstract and body evidence
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user