retrieval_detail_projection.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. from __future__ import annotations
  2. import json
  3. import re
  4. from typing import Any
  5. from .runtime_tool_catalog import (
  6. MAIN_DECISION_READ_TOOLS,
  7. RETRIEVAL_AGENT_LABELS,
  8. business_tool_label,
  9. )
  10. QUERY_TOOL_LABELS = {
  11. "search_script_decode_case": "解构 Case 查询",
  12. "external_search_case": "外部内容查询",
  13. "search_knowledge": "创作知识查询",
  14. }
  15. QUERY_TOOL_NAMES = frozenset(QUERY_TOOL_LABELS)
  16. RETRIEVAL_TOOL_NAMES = frozenset({*QUERY_TOOL_NAMES, *MAIN_DECISION_READ_TOOLS})
  17. RETRIEVAL_RESULT_COLLECTION_KEYS: dict[str, str | None] = {
  18. "search_script_decode_case": "results",
  19. "external_search_case": "data",
  20. "search_knowledge": None,
  21. "get_domain_info": "domain_info",
  22. }
  23. TERMINAL_RUN_STATUSES = frozenset(
  24. {"success", "partial", "failed", "stopped", "completed", "cancelled", "cancel", "cancle"}
  25. )
  26. _CONDITION_LABELS = {
  27. "keyword": "关键词",
  28. "account_name": "账号",
  29. "match_fields": "匹配范围",
  30. "top_k": "最多返回",
  31. "return_field": "返回内容",
  32. "platform_channel": "平台",
  33. "max_count": "最多返回",
  34. "message": "查询需求",
  35. "script_build_id": "构建记录",
  36. }
  37. _HIDDEN_CONDITIONS = {"execution_id", "account_name_guard"}
  38. def is_retrieval_detail_event(event: dict[str, Any]) -> bool:
  39. return (
  40. str(event.get("event_type") or "") == "tool_call"
  41. and (
  42. str(event.get("event_name") or "") in RETRIEVAL_TOOL_NAMES
  43. or str(event.get("agent_role") or "") in RETRIEVAL_AGENT_LABELS
  44. )
  45. )
  46. def summarize_retrieval_output(
  47. event_name: str,
  48. *,
  49. raw_status: Any,
  50. ended_at: Any,
  51. output: Any,
  52. run_status: Any = None,
  53. ) -> dict[str, Any]:
  54. """Return only the bounded status/count needed by the execution view."""
  55. parsed = _parse_jsonish(output)
  56. state, count, error = _outcome(
  57. event_name,
  58. raw_status=raw_status,
  59. ended_at=ended_at,
  60. output=parsed,
  61. run_status=run_status,
  62. )
  63. return {
  64. "state": state,
  65. "count": count,
  66. "error": error,
  67. }
  68. def project_retrieval_event(
  69. event: dict[str, Any], *, run_status: Any = None
  70. ) -> dict[str, Any]:
  71. name = str(event.get("event_name") or "")
  72. source_kind = "direct-tool" if name in MAIN_DECISION_READ_TOOLS else "agent-query"
  73. input_value = _event_side(event, "input")
  74. output_value = _event_side(event, "output")
  75. output_side = event.get("output")
  76. output_truncated = isinstance(output_side, dict) and bool(output_side.get("truncated"))
  77. summary_output = (
  78. _parse_jsonish(event.get("output_preview"))
  79. if output_truncated
  80. else output_value
  81. )
  82. summary = summarize_retrieval_output(
  83. name,
  84. raw_status=event.get("status"),
  85. ended_at=event.get("ended_at"),
  86. output=summary_output,
  87. run_status=run_status,
  88. )
  89. state = summary["state"]
  90. count = summary.get("count")
  91. error = summary.get("error")
  92. items = _result_items(name, output_value) if state == "hit" and not output_truncated else []
  93. completeness = _completeness(event.get("output"), output_value)
  94. return {
  95. "detailKind": "retrieval-event",
  96. "eventId": _integer(event.get("id")) or 0,
  97. "sourceKind": source_kind,
  98. "toolName": name,
  99. "businessLabel": QUERY_TOOL_LABELS.get(name)
  100. or business_tool_label(name),
  101. "queryConditions": _query_conditions(input_value),
  102. "outcome": {
  103. "state": state,
  104. **({"count": count} if count is not None else {}),
  105. "message": _outcome_message(state, count, error, source_kind),
  106. **({"errorKind": _error_kind(error)} if error else {}),
  107. },
  108. "items": items,
  109. "completeness": completeness,
  110. }
  111. def _outcome(
  112. name: str,
  113. *,
  114. raw_status: Any,
  115. ended_at: Any,
  116. output: Any,
  117. run_status: Any,
  118. ) -> tuple[str, int | None, str | None]:
  119. status = str(raw_status or "").lower()
  120. terminal_run = str(run_status or "").lower() in TERMINAL_RUN_STATUSES
  121. if terminal_run and (status == "running" or not ended_at):
  122. return "interrupted", None, None
  123. error = _error_text(output)
  124. if status in {"error", "failed", "failure"} or error:
  125. return "failure", None, error
  126. count = _result_count(name, output)
  127. if count is not None:
  128. return ("hit" if count > 0 else "empty"), count, None
  129. if status == "running" or not ended_at:
  130. return "running", None, None
  131. if name in MAIN_DECISION_READ_TOOLS and output not in (None, "", [], {}):
  132. return "hit", None, None
  133. return "unknown", None, None
  134. def _error_text(value: Any) -> str | None:
  135. if isinstance(value, dict):
  136. success = value.get("success")
  137. if success is False or (isinstance(success, str) and success.lower() == "false"):
  138. candidate = value.get("error") or value.get("message") or "返回结果标记为失败"
  139. return _clean_error(candidate)
  140. error = value.get("error")
  141. if error not in (None, "", False, [], {}):
  142. return _clean_error(error)
  143. status = str(value.get("status") or "").lower()
  144. if status in {"error", "failed", "failure"}:
  145. return _clean_error(value.get("message") or value.get("output") or status)
  146. if isinstance(value, str):
  147. text = value.strip()
  148. if re.match(r"^(?:错误|失败)\s*[::]", text) or text.startswith("❌"):
  149. return _clean_error(text)
  150. return None
  151. def _clean_error(value: Any) -> str:
  152. text = str(value or "").strip()
  153. text = re.sub(r"^(?:错误|失败)\s*[::]\s*", "", text)
  154. text = re.sub(r"^❌\s*", "", text)
  155. return text or "执行失败,未保存更多错误说明"
  156. def _result_count(name: str, value: Any) -> int | None:
  157. if name == "search_knowledge" and isinstance(value, list):
  158. return len(value)
  159. if isinstance(value, list):
  160. return len(value)
  161. if not isinstance(value, dict):
  162. return None
  163. for key in ("count", "returned_count", "result_count"):
  164. count = _integer(value.get(key))
  165. if count is not None:
  166. return count
  167. for key in ("results", "data", "items", "domain_info"):
  168. if isinstance(value.get(key), list):
  169. return len(value[key])
  170. return None
  171. def _result_items(name: str, output: Any) -> list[dict[str, Any]]:
  172. if name == "search_script_decode_case":
  173. values = _result_collection(name, output)
  174. return [_case_item(item, index) for index, item in enumerate(values or [], 1)]
  175. if name == "external_search_case":
  176. values = _result_collection(name, output)
  177. return [_external_item(item, index, output) for index, item in enumerate(values or [], 1)]
  178. if name == "search_knowledge":
  179. return [
  180. _knowledge_item(item, index)
  181. for index, item in enumerate(_result_collection(name, output) or [], 1)
  182. ]
  183. return _direct_items(name, output)
  184. def _result_collection(name: str, output: Any) -> Any:
  185. key = RETRIEVAL_RESULT_COLLECTION_KEYS.get(name)
  186. if key is None:
  187. return output
  188. return output.get(key) if isinstance(output, dict) else None
  189. def _case_item(item: Any, index: int) -> dict[str, Any]:
  190. data = item if isinstance(item, dict) else {"data": item}
  191. account = str(data.get("account") or "未知账号")
  192. return {
  193. "id": str(data.get("post_id") or index),
  194. "title": f"{account} · Case {index}",
  195. "subtitle": str(data.get("post_id") or "来源标识未记录"),
  196. "meta": _meta(
  197. ("匹配分数", data.get("score")),
  198. ("返回内容", data.get("return_field")),
  199. ),
  200. "sections": [{"title": "完整解构内容", "value": data.get("data")}],
  201. }
  202. def _external_item(item: Any, index: int, output: dict[str, Any]) -> dict[str, Any]:
  203. data = item if isinstance(item, dict) else {"body_text": item}
  204. return {
  205. "id": str(data.get("channel_content_id") or index),
  206. "title": str(data.get("title") or f"外部内容 {index}"),
  207. "subtitle": str(data.get("channel") or output.get("platform_channel") or "外部平台"),
  208. "href": data.get("link"),
  209. "meta": _meta(
  210. ("内容类型", data.get("content_type")),
  211. ("点赞", data.get("like_count")),
  212. ("发布时间", data.get("publish_timestamp")),
  213. ),
  214. "sections": [
  215. value
  216. for value in (
  217. _value_section("正文", data.get("body_text")),
  218. _value_section("图片内容理解", data.get("images_text_escape")),
  219. _value_section("图片", data.get("images")),
  220. _value_section("视频", data.get("videos")),
  221. )
  222. if value
  223. ],
  224. }
  225. def _knowledge_item(item: Any, index: int) -> dict[str, Any]:
  226. data = item if isinstance(item, dict) else {"content": item}
  227. return {
  228. "id": str(data.get("id") or index),
  229. "title": str(data.get("title") or f"知识条目 {index}"),
  230. "subtitle": str(data.get("purpose") or ""),
  231. "meta": [],
  232. "sections": [section for section in [_value_section("完整内容", data.get("content"))] if section],
  233. }
  234. def _direct_items(name: str, output: Any) -> list[dict[str, Any]]:
  235. if name == "get_domain_info" and isinstance(output, dict):
  236. values = _result_collection(name, output)
  237. return [
  238. {
  239. "id": str(item.get("id") or index),
  240. "title": f"领域事实 {index}",
  241. "subtitle": str(item.get("note") or ""),
  242. "meta": _meta(("轮次", item.get("round_index"))),
  243. "sections": [
  244. section
  245. for section in (
  246. _value_section("事实内容", item.get("content")),
  247. _value_section("核实说明", item.get("note")),
  248. _value_section("来源", item.get("source")),
  249. )
  250. if section
  251. ],
  252. }
  253. for index, item in enumerate(values or [], 1)
  254. if isinstance(item, dict)
  255. ]
  256. if output in (None, "", [], {}):
  257. return []
  258. data = output if isinstance(output, dict) else {"完整返回": output}
  259. title = {
  260. "get_topic_detail": "选题完整信息",
  261. "get_script_direction": "当前创作方向",
  262. "get_script_snapshot": "当前主脚本快照",
  263. "get_account_script_section_patterns": "账号段落模式",
  264. "get_account_script_persona_points": "账号人设",
  265. }.get(name, business_tool_label(name))
  266. preferred = {
  267. "get_topic_detail": [("选题", "topic"), ("创作点", "points"), ("内容关系", "item_relations")],
  268. "get_script_snapshot": [("脚本概况", "script_build"), ("段落", "paragraphs"), ("元素", "elements"), ("段落元素关联", "paragraph_element_links")],
  269. "get_account_script_section_patterns": [("分段规律摘要", "分段规律摘要"), ("典型分段依据", "典型分段依据"), ("段落结构模式", "段落结构模式列表"), ("分析规模", "分析帖子数")],
  270. }.get(name)
  271. if preferred:
  272. sections = [_value_section(label, data.get(key)) for label, key in preferred]
  273. else:
  274. sections = [_value_section(_condition_label(key), value) for key, value in data.items()]
  275. return [{
  276. "id": name,
  277. "title": title,
  278. "subtitle": "",
  279. "meta": [],
  280. "sections": [section for section in sections if section],
  281. }]
  282. def _query_conditions(value: Any) -> list[dict[str, Any]]:
  283. if not isinstance(value, dict):
  284. if value in (None, ""):
  285. return []
  286. return [{"key": "input", "label": "调用输入", "value": value}]
  287. return [
  288. {"key": str(key), "label": _condition_label(str(key)), "value": item}
  289. for key, item in value.items()
  290. if key not in _HIDDEN_CONDITIONS and item not in (None, "", [], {})
  291. ]
  292. def _condition_label(key: str) -> str:
  293. return _CONDITION_LABELS.get(key, key.replace("_", " "))
  294. def _outcome_message(
  295. state: str, count: int | None, error: str | None, source_kind: str
  296. ) -> str:
  297. if state == "failure":
  298. return f"查询执行失败:{error}" if error else "查询执行失败。"
  299. if state == "empty":
  300. return "查询执行成功,没有匹配数据。"
  301. if state == "hit":
  302. if source_kind == "direct-tool":
  303. return "读取成功。" if count is None else f"读取成功,共返回 {count} 条记录。"
  304. return f"查询成功,返回 {count} 条结果。" if count is not None else "查询成功。"
  305. if state == "interrupted":
  306. return "构建已经结束,但这次活动没有完成。"
  307. if state == "running":
  308. return "查询仍在执行。"
  309. return "现有记录不足以可靠判断查询结果。"
  310. def _error_kind(error: str | None) -> str:
  311. text = str(error or "").lower()
  312. if any(token in text for token in ("参数", "仅支持", "必须", "不能为空", "return_field")):
  313. return "parameter"
  314. if any(token in text for token in ("http", "timeout", "timed out", "connection", "请求失败")):
  315. return "transport"
  316. if any(token in text for token in ("upstream", "openrouter", "403", "502", "503")):
  317. return "upstream"
  318. return "unknown"
  319. def _completeness(side: Any, output: Any) -> dict[str, Any]:
  320. meta = side if isinstance(side, dict) else {}
  321. truncated = bool(meta.get("truncated"))
  322. omitted = _integer(meta.get("omittedCharacters"))
  323. if isinstance(output, str):
  324. match = re.search(r"…\[truncated\s+(\d+)\s+chars\]", output)
  325. if match:
  326. truncated = True
  327. omitted = int(match.group(1))
  328. return {
  329. "bodyAvailable": side is not None and output is not None,
  330. "truncated": truncated,
  331. **({"omittedCharacters": omitted} if omitted is not None else {}),
  332. }
  333. def _event_side(event: dict[str, Any], side: str) -> Any:
  334. wrapped = event.get(side)
  335. if isinstance(wrapped, dict) and "content" in wrapped:
  336. return _parse_jsonish(wrapped.get("content"))
  337. if wrapped is not None:
  338. return _parse_jsonish(wrapped)
  339. fallback = event.get("inputData" if side == "input" else "agentOutputData")
  340. if fallback is not None:
  341. return _parse_jsonish(fallback)
  342. return _parse_jsonish(event.get(f"{side}_preview"))
  343. def _parse_jsonish(value: Any) -> Any:
  344. if not isinstance(value, str):
  345. return value
  346. text = value.strip()
  347. if not text or text[0] not in "[{":
  348. return value
  349. try:
  350. return json.loads(text)
  351. except json.JSONDecodeError:
  352. return value
  353. def _value_section(title: str, value: Any) -> dict[str, Any] | None:
  354. if value in (None, "", [], {}):
  355. return None
  356. return {"title": title, "value": value}
  357. def _meta(*items: tuple[str, Any]) -> list[dict[str, Any]]:
  358. return [
  359. {"label": label, "value": value}
  360. for label, value in items
  361. if value not in (None, "", [], {})
  362. ]
  363. def _integer(value: Any) -> int | None:
  364. if isinstance(value, bool):
  365. return None
  366. try:
  367. return int(value)
  368. except (TypeError, ValueError):
  369. return None