source_tracker.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. """来源标注:从工具结果里抽取、聚合、去重。
  2. ## 为什么换掉 1.5 的做法
  3. 1.5 让**模型在正文里自己写引用**,后端再用启发式规则去正文里刮。这条
  4. 路径有两个固有失败模式:模型漏写(内容有来源但没标注)和模型编造
  5. (写出一个不存在的 url)。两者都无法在事后可靠地区分。
  6. ## 2.0 的做法
  7. 来源由**工具侧结构化产出**:工具返回 ``tools.registry.sourced(data, sources)``
  8. 构造的结构,本模块从 ToolMessage 的原始内容里把 ``sources`` 抽出来。
  9. 模型是否在正文里写引用,不影响 citations 的准确性——因为它读的是工具
  10. 的返回值,不是模型的叙述。
  11. 本轮没抽到任何来源时,发 ``model_only=True``,前端会显示「本回答未引用
  12. 外部数据源」而不是一个空卡片——如实说明「没有来源」比假装有来源重要。
  13. """
  14. from __future__ import annotations
  15. import json
  16. from typing import Any, Iterable
  17. from obs import get_logger
  18. from tools.registry import get_label
  19. logger = get_logger(__name__)
  20. # 一条来源最多保留的摘要长度
  21. _SNIPPET_LIMIT = 200
  22. # 单轮最多输出多少条引用,避免工具刷屏时把前端塞爆
  23. _MAX_CITATIONS = 50
  24. def _classify(url: str, tool_name: str) -> tuple[str, str]:
  25. """给来源分类,返回 ``(type, type_cn)``。
  26. 分类只依赖 url 形态与工具名,不看内容——这是一条纯函数式的判定,
  27. 可以被测试覆盖,不会因为模型措辞不同而漂移。
  28. """
  29. lowered = (url or "").lower()
  30. if lowered.startswith(("http://", "https://")):
  31. if "kb" in lowered or "knowledge" in lowered:
  32. return "knowledge_base", "知识库"
  33. return "web", "网页"
  34. if tool_name.startswith(("search_", "query_")):
  35. return "knowledge_base", "知识库"
  36. if lowered.startswith("file://") or lowered.endswith((".pdf", ".docx", ".md")):
  37. return "file", "文件"
  38. return "tool", "工具"
  39. def _coerce_sources(payload: Any) -> list[dict[str, Any]]:
  40. """从工具返回值里挖出 sources 列表。
  41. 容忍几种常见形态:直接 dict、JSON 字符串、被包在 content 块列表里、
  42. 或 ``{"data": {"sources": [...]}}`` 这种多套一层的情况。
  43. """
  44. if payload is None:
  45. return []
  46. if isinstance(payload, str):
  47. text = payload.strip()
  48. if not text or text[0] not in "{[":
  49. return []
  50. try:
  51. payload = json.loads(text)
  52. except (TypeError, ValueError):
  53. return []
  54. if isinstance(payload, list):
  55. collected: list[dict[str, Any]] = []
  56. for item in payload[:10]:
  57. collected.extend(_coerce_sources(item))
  58. return collected
  59. if not isinstance(payload, dict):
  60. return []
  61. sources = payload.get("sources")
  62. if isinstance(sources, list):
  63. return [s for s in sources if isinstance(s, dict)]
  64. # 多套一层:{"data": {...}} 或 {"result": {...}}
  65. for key in ("data", "result", "output"):
  66. nested = payload.get(key)
  67. if isinstance(nested, (dict, list, str)):
  68. found = _coerce_sources(nested)
  69. if found:
  70. return found
  71. return []
  72. def _normalize(record: dict[str, Any], tool_name: str) -> dict[str, Any] | None:
  73. """把一条原始来源记录规整成引用条目;无有效标识则返回 None。"""
  74. title = str(record.get("title") or record.get("doc_name") or "").strip()
  75. url = str(record.get("url") or record.get("doc_path") or "").strip()
  76. snippet = str(record.get("snippet") or record.get("text") or "").strip()
  77. if not title and not url:
  78. # 既没标题也没链接的来源无法核实,丢弃比展示一条空壳有用
  79. return None
  80. kind, kind_cn = _classify(url, tool_name)
  81. item: dict[str, Any] = {
  82. "title": title or url,
  83. "url": url,
  84. "snippet": snippet[:_SNIPPET_LIMIT],
  85. "type": str(record.get("type") or kind),
  86. "type_cn": str(record.get("type_cn") or kind_cn),
  87. "tool": tool_name,
  88. "tool_cn": get_label(tool_name),
  89. }
  90. score = record.get("score")
  91. if isinstance(score, (int, float)):
  92. item["score"] = round(float(score), 4)
  93. return item
  94. def _dedup_key(item: dict[str, Any]) -> tuple[str, str]:
  95. """去重键:优先 url,退而用标题 + 摘要前缀。"""
  96. url = item.get("url") or ""
  97. if url:
  98. return ("url", url)
  99. return ("title", f"{item.get('title', '')}|{item.get('snippet', '')[:40]}")
  100. class SourceTracker:
  101. """一次请求的来源台账。"""
  102. def __init__(self) -> None:
  103. self._items: list[dict[str, Any]] = []
  104. self._seen: set[tuple[str, str]] = set()
  105. def note_tool_result(self, tool_name: str, payload: Any) -> list[dict[str, Any]]:
  106. """观测一次工具返回,抽取其中的来源。返回本次**新增**的条目。"""
  107. added: list[dict[str, Any]] = []
  108. for record in _coerce_sources(payload):
  109. item = _normalize(record, tool_name)
  110. if item is None:
  111. continue
  112. key = _dedup_key(item)
  113. if key in self._seen:
  114. continue
  115. self._seen.add(key)
  116. self._items.append(item)
  117. added.append(item)
  118. if len(self._items) >= _MAX_CITATIONS:
  119. logger.warning("引用条目达到上限 %d,后续来源不再收集", _MAX_CITATIONS)
  120. break
  121. return added
  122. def build_citations(self) -> list[dict[str, Any]]:
  123. """产出发给前端的引用列表。按相关度倒序(无分数的排在后面)。"""
  124. return sorted(
  125. self._items,
  126. key=lambda i: i.get("score", -1.0),
  127. reverse=True,
  128. )
  129. @property
  130. def count(self) -> int:
  131. return len(self._items)
  132. def __len__(self) -> int:
  133. return len(self._items)
  134. def is_empty(self) -> bool:
  135. return not self._items
  136. def collect_from_tool_messages(
  137. messages: Iterable[Any], tracker: SourceTracker | None = None
  138. ) -> SourceTracker:
  139. """从一串消息里批量抽取来源(离线场景/补历史时用)。"""
  140. tracker = tracker or SourceTracker()
  141. for message in messages:
  142. name = getattr(message, "name", None) or ""
  143. content = getattr(message, "content", None)
  144. if name:
  145. tracker.note_tool_result(name, content)
  146. return tracker