flow_ledger_service.py 91 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039
  1. from __future__ import annotations
  2. import copy
  3. import json
  4. import os
  5. import time
  6. from collections import Counter
  7. from typing import Any
  8. from content_agent.dashboard_service import (
  9. DATA_ORIGIN_PRODUCTION_DB,
  10. DATA_ORIGIN_RUNTIME_EXPORT,
  11. DashboardService,
  12. )
  13. from content_agent.integrations.database_runtime import DatabaseRuntimeStore
  14. from content_agent.interfaces import RuntimeStore
  15. FIRST_ROUND_QUERY_METHODS = {
  16. "seed_term",
  17. "piaoquan_topic_point",
  18. "query_seed_point",
  19. "category_leaf_element",
  20. }
  21. EXTENSION_QUERY_METHODS = {
  22. "tag_query",
  23. "author_works",
  24. }
  25. SOURCE_LABELS = {
  26. "seed_term": "从需求单的 pattern 词来",
  27. "piaoquan_topic_point": "票圈帖子具体的点",
  28. "query_seed_point": "票圈帖子具体的点",
  29. "category_leaf_element": "分类叶子元素",
  30. }
  31. QUERY_METHOD_LABELS = {
  32. "seed_term": "从需求单的 pattern 词来",
  33. "piaoquan_topic_point": "票圈帖子具体的点",
  34. "query_seed_point": "票圈帖子具体的点",
  35. "category_leaf_element": "分类叶子元素",
  36. "tag_query": "从视频标签继续游走",
  37. "author_works": "从作者继续游走",
  38. }
  39. ACTION_LABELS = {
  40. "ADD_TO_CONTENT_POOL": "入池",
  41. "KEEP_CONTENT_FOR_REVIEW": "待复看",
  42. "REJECT_CONTENT": "淘汰",
  43. "TECHNICAL_RETRY_REQUIRED": "技术问题",
  44. }
  45. MEDIA_STATUS_LABELS = {
  46. "oss_uploaded": "已保存",
  47. "oss_upload_pending": "待补传",
  48. "oss_upload_failed": "补传失败",
  49. "metadata_only": "尚未拿到视频",
  50. "unavailable": "无可用视频",
  51. }
  52. WALK_EDGE_LABELS = {
  53. "hashtag_to_query": "从视频标签继续游走",
  54. "tag_query": "从视频标签继续游走",
  55. "author_to_works": "从作者继续游走",
  56. "video_to_author": "从作者继续游走",
  57. "author_works": "从作者继续游走",
  58. "path_stop": "到这里停止",
  59. "decision_to_asset": "入池沉淀",
  60. "budget_downgrade": "转入待复看",
  61. "terminal": "到这里停止",
  62. }
  63. REASON_LABELS = {
  64. "content_pattern_recall_required": "内容和本次需求没有形成有效关联",
  65. "blocked_by_rule_decision": "上一步内容未通过判断",
  66. "missing_content_portrait": "缺少判断所需的内容画像",
  67. "category_or_element_binding_required": "没有命中本次需求的类目或要素",
  68. "missing_score": "没有足够信息进入打分",
  69. "v4_query_and_platform_pass": "搜索词和平台表现都符合要求",
  70. "v4_query_or_score_below_threshold": "相关性或平台表现未达到要求",
  71. "v4_score_review_needed": "分数处在复看区间,需要人工确认",
  72. "v4_technical_retry_needed": "系统需要重试后再判断",
  73. "v4_allow_walk_denied": "不适合继续向外扩展",
  74. "budget_exhausted": "达到该类游走次数上限",
  75. "global_action_cap_reached": "已达全 run 游走次数上限",
  76. "edge_blocked_by_platform_profile": "该平台 profile 阻断这种游走",
  77. "portrait_unavailable": "拉不到作者 50+ 画像(热点宝接口失败),无法判定",
  78. "portrait_incomplete": "作者 50+ 画像字段缺失,无法判定",
  79. "query>=65/platform>=65/score>=65": "评分达到继续扩展门槛",
  80. "query>=70/platform>=65/score>=70": "评分达到继续扩展门槛",
  81. "content_decision_reused_for_walk_gate": "复用本条视频的内容判断作为扩展门槛",
  82. "no_decision": "暂未产生判断结果",
  83. }
  84. # 向外游走边(标签/作者),用于统计游走次数(不含 path_stop/decision_to_asset 等终端结局)。
  85. _WALK_EXPANSION_EDGES = {"hashtag_to_query", "author_to_works"}
  86. _FLOW_LEDGER_CACHE: dict[tuple[str, str], tuple[float, dict[str, Any]]] = {}
  87. _DEFAULT_CACHE_TTL_SECONDS = 300
  88. def _reason_label(reason: str, default: str) -> str:
  89. if reason.startswith("query>=") and "/platform>=" in reason and "/score>=" in reason:
  90. return "评分达到继续扩展门槛"
  91. return REASON_LABELS.get(reason, default)
  92. class FlowLedgerService:
  93. def __init__(self, runtime: RuntimeStore) -> None:
  94. dashboard = DashboardService.from_runtime(runtime)
  95. self.runtime = dashboard.runtime
  96. self.export_runtime = dashboard.export_runtime
  97. self.data_origin = (
  98. DATA_ORIGIN_PRODUCTION_DB
  99. if isinstance(self.runtime, DatabaseRuntimeStore)
  100. else DATA_ORIGIN_RUNTIME_EXPORT
  101. )
  102. def ledger(self, run_id: str, *, debug: bool = False) -> dict[str, Any]:
  103. cache_key = ("ledger", run_id)
  104. if not debug:
  105. cached = _cache_get(cache_key)
  106. if cached is not None:
  107. return cached
  108. bundle = self._bundle(run_id, cache=not debug)
  109. walk_out = _walk_out_counter(bundle)
  110. rows = [
  111. self._ledger_row(query, bundle, walk_out, debug=debug)
  112. for query in bundle["queries"]
  113. if _query_method(query) in FIRST_ROUND_QUERY_METHODS
  114. ]
  115. extension_queries = [
  116. self._extension_query(query, bundle, debug=debug)
  117. for query in bundle["queries"]
  118. if _query_method(query) in EXTENSION_QUERY_METHODS
  119. ]
  120. media_counts = Counter(_media_status(media) for media in bundle["media"])
  121. action_counts = Counter(_decision_action(decision) for decision in bundle["decisions"])
  122. gemini_counts = Counter(_text(evidence.get("decode_status") or evidence.get("gemini_status"), "unknown") for evidence in bundle["evidence"])
  123. payload = {
  124. "run_id": run_id,
  125. "data_origin": self.data_origin,
  126. "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
  127. "summary": self._summary(bundle, rows, extension_queries, media_counts, action_counts, gemini_counts),
  128. "demand_summary": _demand_summary(bundle),
  129. "rows": rows,
  130. "extension_queries": extension_queries,
  131. "debug": bundle if debug else None,
  132. }
  133. if not debug:
  134. _cache_set(cache_key, payload)
  135. return payload
  136. def query_videos(self, run_id: str, query_id: str, *, debug: bool = False) -> dict[str, Any]:
  137. bundle = self._bundle(run_id, cache=not debug)
  138. query = self._query_by_id(bundle, query_id)
  139. related = self._query_content(query_id, bundle, first_round_only=True)
  140. videos = [self._video(content, bundle, debug=debug) for content in related]
  141. return {
  142. "run_id": run_id,
  143. "query": self._query_summary(query, bundle, debug=debug) if query else None,
  144. "videos": videos,
  145. "summary": self._video_summary(videos),
  146. "data_origin": self.data_origin,
  147. "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
  148. }
  149. def query_walk(self, run_id: str, query_id: str, *, debug: bool = False) -> dict[str, Any]:
  150. bundle = self._bundle(run_id, cache=not debug)
  151. query = self._query_by_id(bundle, query_id)
  152. related_ids = self._query_related_content_ids(query_id, bundle)
  153. related_decision_ids = self._query_related_decision_ids(query_id, bundle)
  154. actions = [
  155. self._walk_action(action, query, bundle, debug=debug)
  156. for action in bundle["walk_actions"]
  157. if _walk_action_related(action, query_id, related_ids)
  158. or self._walk_action_related_to_decisions(action, related_decision_ids)
  159. ]
  160. lanes: dict[str, list[dict[str, Any]]] = {key: [] for key in ["tag_query", "author_works", "path_stop"]}
  161. for action in actions:
  162. lanes[_walk_lane(action["edge_id"])].append(action)
  163. target_query_ids = {_text(_record(action.get("target_query")).get("id")) for action in actions}
  164. extension_queries = [
  165. self._extension_query(item, bundle, debug=debug)
  166. for item in bundle["queries"]
  167. if _query_method(item) in EXTENSION_QUERY_METHODS
  168. and (
  169. _text(item.get("search_query_id")) in target_query_ids
  170. or _text(_record(item.get("raw_payload")).get("parent_search_query_id") or item.get("llm_variant_of")) == query_id
  171. )
  172. ]
  173. return {
  174. "run_id": run_id,
  175. "query": self._query_summary(query, bundle, debug=debug) if query else None,
  176. "actions": actions,
  177. "lanes": lanes,
  178. "extension_queries": extension_queries,
  179. "summary": {
  180. "total_actions": len(actions),
  181. "success_count": sum(1 for item in actions if item["status"] == "success"),
  182. "skipped_count": sum(1 for item in actions if item["status"] == "skipped"),
  183. "extension_query_count": len(extension_queries),
  184. },
  185. "data_origin": self.data_origin,
  186. "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
  187. }
  188. def run_walk(self, run_id: str, *, debug: bool = False) -> dict[str, Any]:
  189. # 全 run 游走图:不按 query 过滤,富化全部 walk_actions(full=True 不采样带回视频),并给出游走预算总览。
  190. bundle = self._bundle(run_id, cache=not debug)
  191. actions = [self._walk_action(action, None, bundle, debug=debug, full=True) for action in bundle["walk_actions"]]
  192. lanes: dict[str, list[dict[str, Any]]] = {key: [] for key in ["tag_query", "author_works", "path_stop"]}
  193. for action in actions:
  194. lanes[_walk_lane(action["edge_id"])].append(action)
  195. extension_queries = [
  196. self._extension_query(item, bundle, debug=debug)
  197. for item in bundle["queries"]
  198. if _query_method(item) in EXTENSION_QUERY_METHODS
  199. ]
  200. summary = _walk_budget_overview(bundle, actions, extension_queries)
  201. # 全run游走图按所属搜索词分组用:首轮 query id → 文案。
  202. summary["query_labels"] = {
  203. _text(item.get("search_query_id")): _text(item.get("search_query"), "搜索词待确认")
  204. for item in bundle["queries"]
  205. if _query_method(item) in FIRST_ROUND_QUERY_METHODS and _text(item.get("search_query_id"))
  206. }
  207. return {
  208. "run_id": run_id,
  209. "actions": actions,
  210. "lanes": lanes,
  211. "extension_queries": extension_queries,
  212. "summary": summary,
  213. "data_origin": self.data_origin,
  214. "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
  215. }
  216. def video_walk(self, run_id: str, content_id: str, *, debug: bool = False) -> dict[str, Any]:
  217. # 单视频游走子树:从该视频出发 BFS,只保留从它向外走出去的那一支(沿 source_video.id → target_videos.id 递归)。
  218. bundle = self._bundle(run_id, cache=not debug)
  219. root_content = next(
  220. (
  221. item
  222. for item in bundle["content_items"]
  223. if content_id in {_text(item.get("platform_content_id")), _text(item.get("content_discovery_id"))}
  224. ),
  225. None,
  226. )
  227. origin_label = "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存"
  228. if not root_content:
  229. return {
  230. "run_id": run_id,
  231. "root_video": None,
  232. "actions": [],
  233. "summary": {"total_actions": 0, "success_count": 0, "skipped_count": 0},
  234. "data_origin": self.data_origin,
  235. "data_origin_label": origin_label,
  236. }
  237. root_video = self._video(root_content, bundle, debug=debug)
  238. enriched = [self._walk_action(action, None, bundle, debug=debug, full=True) for action in bundle["walk_actions"]]
  239. by_source: dict[str, list[dict[str, Any]]] = {}
  240. for item in enriched:
  241. source_id = _text(_record(item.get("source_video")).get("id"))
  242. if source_id:
  243. by_source.setdefault(source_id, []).append(item)
  244. root_id = _text(root_video.get("id"))
  245. seen_videos = {root_id}
  246. seen_actions: set[str] = set()
  247. subtree: list[dict[str, Any]] = []
  248. frontier = [root_id]
  249. while frontier:
  250. nxt: list[str] = []
  251. for video_id in frontier:
  252. for item in by_source.get(video_id, []):
  253. action_id = _text(item.get("id")) or str(id(item))
  254. if action_id in seen_actions:
  255. continue
  256. seen_actions.add(action_id)
  257. subtree.append(item)
  258. for target in item.get("target_videos") or []:
  259. target_id = _text(_record(target).get("id"))
  260. if target_id and target_id not in seen_videos:
  261. seen_videos.add(target_id)
  262. nxt.append(target_id)
  263. frontier = nxt
  264. statuses = Counter(_text(item.get("status"), "unknown") for item in subtree)
  265. return {
  266. "run_id": run_id,
  267. "root_video": root_video,
  268. "actions": subtree,
  269. "summary": {
  270. "total_actions": len(subtree),
  271. "success_count": statuses["success"],
  272. "skipped_count": statuses["skipped"],
  273. },
  274. "data_origin": self.data_origin,
  275. "data_origin_label": origin_label,
  276. }
  277. def video_detail(self, run_id: str, content_id: str, *, debug: bool = False) -> dict[str, Any]:
  278. bundle = self._bundle(run_id, cache=not debug)
  279. content = next(
  280. (
  281. item
  282. for item in bundle["content_items"]
  283. if content_id in {
  284. _text(item.get("platform_content_id")),
  285. _text(item.get("content_discovery_id")),
  286. }
  287. ),
  288. None,
  289. )
  290. if not content:
  291. return {
  292. "run_id": run_id,
  293. "video": None,
  294. "data_origin": self.data_origin,
  295. "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
  296. }
  297. video = self._video(content, bundle, debug=debug)
  298. platform_id = _text(content.get("platform_content_id"))
  299. decision = _decision_for_content(content, bundle["decisions"])
  300. source_paths = [
  301. path
  302. for path in bundle["source_paths"]
  303. if platform_id and platform_id in {_text(path.get("from_node_id")), _text(path.get("to_node_id")), _text(path.get("decision_target_id"))}
  304. ]
  305. return {
  306. "run_id": run_id,
  307. "video": video,
  308. "source_paths": [_source_path(path, debug=debug) for path in source_paths],
  309. "decision": self._decision_summary(decision, debug=debug) if decision else None,
  310. "data_origin": self.data_origin,
  311. "data_origin_label": "生产数据库" if self.data_origin == DATA_ORIGIN_PRODUCTION_DB else "本地调试缓存",
  312. }
  313. def _bundle(self, run_id: str, *, cache: bool = True) -> dict[str, Any]:
  314. cache_key = ("bundle", run_id)
  315. if cache:
  316. cached = _cache_get(cache_key)
  317. if cached is not None:
  318. return cached
  319. payload = {
  320. "source_context": self._read_json(run_id, "source_context.json") or {},
  321. "pattern_seed_pack": self._read_json(run_id, "pattern_seed_pack.json") or {},
  322. "queries": self._read_jsonl(run_id, "search_queries.jsonl"),
  323. "content_items": self._read_jsonl(run_id, "discovered_content_items.jsonl"),
  324. "media": self._read_jsonl(run_id, "content_media_records.jsonl"),
  325. "evidence": self._read_jsonl(run_id, "pattern_recall_evidence.jsonl"),
  326. "decisions": self._read_jsonl(run_id, "rule_decisions.jsonl"),
  327. "walk_actions": self._read_jsonl(run_id, "walk_actions.jsonl"),
  328. "source_paths": self._read_jsonl(run_id, "source_path_records.jsonl"),
  329. "final_output": self._read_json(run_id, "final_output.json") or {},
  330. }
  331. if cache:
  332. _cache_set(cache_key, payload)
  333. return payload
  334. def _read_json(self, run_id: str, filename: str) -> dict[str, Any] | None:
  335. try:
  336. return self.runtime.read_json(run_id, filename)
  337. except Exception:
  338. if self.export_runtime:
  339. try:
  340. return self.export_runtime.read_json(run_id, filename)
  341. except Exception:
  342. return None
  343. return None
  344. def _read_jsonl(self, run_id: str, filename: str) -> list[dict[str, Any]]:
  345. try:
  346. return self.runtime.read_jsonl(run_id, filename)
  347. except Exception:
  348. if self.export_runtime:
  349. try:
  350. return self.export_runtime.read_jsonl(run_id, filename)
  351. except Exception:
  352. return []
  353. return []
  354. def _query_by_id(self, bundle: dict[str, Any], query_id: str) -> dict[str, Any] | None:
  355. return next((query for query in bundle["queries"] if _text(query.get("search_query_id")) == query_id), None)
  356. def _ledger_row(self, query: dict[str, Any], bundle: dict[str, Any], walk_out: dict[str, int], *, debug: bool) -> dict[str, Any]:
  357. query_id = _text(query.get("search_query_id"))
  358. contents = self._query_content(query_id, bundle, first_round_only=True)
  359. videos = [self._video(content, bundle, debug=debug) for content in contents]
  360. # 每条首轮视频是否真正向外走过(success 扩展边),供首页「看扩展过程」按钮判断显示。
  361. for video in videos:
  362. video["walk_out_count"] = walk_out.get(_text(video.get("platform_content_id")), 0)
  363. decisions = [
  364. decision
  365. for decision in (_decision_for_content(content, bundle["decisions"]) for content in contents)
  366. if decision
  367. ]
  368. related_ids = self._query_related_content_ids(query_id, bundle)
  369. actions = [action for action in bundle["walk_actions"] if _walk_action_related(action, query_id, related_ids)]
  370. source = self._source_summary(query, bundle, debug=debug)
  371. return {
  372. "id": query_id,
  373. "source": source,
  374. "query": self._query_summary(query, bundle, debug=debug),
  375. "first_round_videos": videos,
  376. "first_round_video_total": len(videos),
  377. "video_stats": self._video_summary(videos),
  378. "rule_summary": self._rule_summary(decisions, debug=debug),
  379. "walk_summary": self._walk_summary(actions),
  380. "asset_summary": self._asset_summary(decisions, bundle["final_output"]),
  381. "debug": {"query": query} if debug else None,
  382. }
  383. def _query_content(self, query_id: str, bundle: dict[str, Any], *, first_round_only: bool) -> list[dict[str, Any]]:
  384. items = [item for item in bundle["content_items"] if _content_belongs_to_query(item, query_id)]
  385. if first_round_only:
  386. walk_query_ids = _walk_query_ids(bundle)
  387. items = [item for item in items if _is_first_round_content(item, walk_query_ids)]
  388. return items
  389. def _query_related_content_ids(self, query_id: str, bundle: dict[str, Any]) -> set[str]:
  390. ids: set[str] = set()
  391. for item in self._query_content(query_id, bundle, first_round_only=True):
  392. ids.update(_content_ids(item))
  393. return {item for item in ids if item}
  394. def _query_related_decision_ids(self, query_id: str, bundle: dict[str, Any]) -> set[str]:
  395. ids: set[str] = set()
  396. for content in self._query_content(query_id, bundle, first_round_only=True):
  397. decision = _decision_for_content(content, bundle["decisions"])
  398. if decision:
  399. ids.add(_text(decision.get("decision_id")))
  400. return {item for item in ids if item}
  401. def _walk_action_related_to_decisions(self, action: dict[str, Any], decision_ids: set[str]) -> bool:
  402. if not decision_ids:
  403. return False
  404. raw = _record(action.get("raw_payload"))
  405. values = {
  406. _text(action.get("decision_id")),
  407. _text(raw.get("decision_id")),
  408. _text(action.get("from_node_id")),
  409. _text(raw.get("from_node_id")),
  410. }
  411. return bool(values & decision_ids)
  412. def _source_summary(self, query: dict[str, Any], bundle: dict[str, Any], *, debug: bool) -> dict[str, Any]:
  413. method = _query_method(query)
  414. seed_ref = _record(query.get("pattern_seed_ref"))
  415. raw_payload = _record(query.get("raw_payload"))
  416. refs = _list(raw_payload.get("query_source_refs")) or _list(seed_ref.get("query_source_refs"))
  417. source_text = (
  418. _text(seed_ref.get("query_source_text"))
  419. or _text(seed_ref.get("seed_term"))
  420. or _text(query.get("search_query"))
  421. )
  422. terms = _text_list(query.get("query_source_terms"))
  423. source_ref = _query_source_ref(seed_ref, refs)
  424. details = _source_details(method, source_text, seed_ref, source_ref, bundle)
  425. return {
  426. "type": method,
  427. "label": SOURCE_LABELS.get(method, "来源待确认"),
  428. "evidence": source_text or (terms[0] if terms else "来源证据待确认"),
  429. "source_ref": _source_ref_label(method, seed_ref, source_ref, bundle),
  430. "terms": terms,
  431. "details": details,
  432. "query_source_refs": refs,
  433. "debug": {"pattern_seed_ref": seed_ref} if debug else None,
  434. }
  435. def _query_summary(self, query: dict[str, Any] | None, bundle: dict[str, Any], *, debug: bool) -> dict[str, Any]:
  436. if not query:
  437. return {
  438. "id": "",
  439. "text": "搜索词待确认",
  440. "method": "unknown",
  441. "method_label": "生成方式待确认",
  442. "source_terms": [],
  443. "effect_label": "处理状态待确认",
  444. }
  445. method = _query_method(query)
  446. return {
  447. "id": _text(query.get("search_query_id")),
  448. "text": _text(query.get("search_query"), "搜索词待确认"),
  449. "method": method,
  450. "method_label": QUERY_METHOD_LABELS.get(method, "生成方式待确认"),
  451. "source_terms": _text_list(query.get("query_source_terms")),
  452. "effect_status": _text(query.get("search_query_effect_status"), "pending"),
  453. "effect_label": _effect_label(_text(query.get("search_query_effect_status"), "pending")),
  454. "debug": {"query": query} if debug else None,
  455. }
  456. def _extension_query(self, query: dict[str, Any], bundle: dict[str, Any], *, debug: bool) -> dict[str, Any]:
  457. summary = self._query_summary(query, bundle, debug=debug)
  458. raw_payload = _record(query.get("raw_payload"))
  459. summary.update({
  460. "parent_query_id": _text(raw_payload.get("parent_search_query_id") or query.get("llm_variant_of")),
  461. "trigger": _text(raw_payload.get("hashtag") or raw_payload.get("author_id") or raw_payload.get("cursor")),
  462. })
  463. return summary
  464. def _video(self, content: dict[str, Any], bundle: dict[str, Any], *, debug: bool) -> dict[str, Any]:
  465. platform_id = _text(content.get("platform_content_id"))
  466. media = _media_for_content(content, bundle["media"])
  467. evidence = _evidence_for_content(content, bundle["evidence"])
  468. decision = _decision_for_content(content, bundle["decisions"])
  469. status = _media_status(media)
  470. stats = _record(content.get("statistics"))
  471. decision_summary = self._decision_summary(decision, debug=debug) if decision else None
  472. technical_retry_detail = _technical_retry_detail(decision, evidence, media)
  473. # 内容审核拦截从"技术问题"里单独分出来:重试无效、原因不同,展示成独立类别。
  474. if (
  475. decision_summary
  476. and decision_summary.get("action") == "TECHNICAL_RETRY_REQUIRED"
  477. and _is_content_inspection_blocked(technical_retry_detail, evidence)
  478. ):
  479. decision_summary = {
  480. **decision_summary,
  481. "label": "模型内容审核拦截",
  482. "reason": "content_inspection_blocked",
  483. "reason_label": "视频被大模型内容安全审核拦截(非系统故障,重试无效)",
  484. "content_inspection_blocked": True,
  485. }
  486. return {
  487. "id": platform_id or _text(content.get("content_discovery_id"), "content"),
  488. "content_discovery_id": _text(content.get("content_discovery_id")),
  489. "platform_content_id": platform_id,
  490. "search_query_id": _text(content.get("search_query_id")),
  491. "progressive_batch_kind": _text(content.get("progressive_batch_kind") or _record(content.get("raw_payload")).get("progressive_batch_kind")),
  492. "progressive_page_number": _number(content.get("progressive_page_number") or _record(content.get("raw_payload")).get("progressive_page_number")),
  493. "progressive_item_rank_in_page": _number(content.get("progressive_item_rank_in_page") or _record(content.get("raw_payload")).get("progressive_item_rank_in_page")),
  494. "title": _text(content.get("description") or content.get("title"), "无标题视频"),
  495. "author": _text(content.get("author_display_name") or content.get("platform_author_id"), "未知作者"),
  496. "platform": _text(content.get("platform"), "unknown"),
  497. "platform_label": _platform_label(_text(content.get("platform"), "unknown")),
  498. "platform_url": _platform_content_url(content),
  499. "tags": _text_list(content.get("tags")),
  500. "statistics": {key: _number(value) for key, value in stats.items()},
  501. "published_at": _text(content.get("published_at") or content.get("publish_time")),
  502. "oss_url": _text(media.get("oss_url")) if media else "",
  503. "playable_url": _text(media.get("oss_url")) if media else "",
  504. "media_status": status,
  505. "media_status_label": MEDIA_STATUS_LABELS.get(status, "视频状态待确认"),
  506. "media_failure_reason": _media_failure_reason(media),
  507. "decision": decision_summary,
  508. "gemini": self._evidence_summary(evidence, debug=debug) if evidence else None,
  509. "technical_retry_detail": technical_retry_detail,
  510. "debug": {"content": content, "media": media, "evidence": evidence} if debug else None,
  511. }
  512. def _decision_summary(self, decision: dict[str, Any] | None, *, debug: bool) -> dict[str, Any]:
  513. if not decision:
  514. return {
  515. "action": "NOT_JUDGED",
  516. "label": "未进入判断",
  517. "reason": "no_decision",
  518. "reason_label": REASON_LABELS["no_decision"],
  519. }
  520. reason = _text(decision.get("decision_reason_code"), "no_decision")
  521. scorecard = _record(decision.get("scorecard"))
  522. score = decision.get("score") if decision.get("score") is not None else scorecard.get("total_score")
  523. action = _decision_action(decision)
  524. return {
  525. "id": _text(decision.get("decision_id")),
  526. "action": action,
  527. "label": ACTION_LABELS.get(action, "待判断"),
  528. "reason": reason,
  529. "reason_label": _reason_label(reason, "系统原因待补充"),
  530. "score": score,
  531. "score_label": f"{round(float(score))} 分" if isinstance(score, (int, float)) else "",
  532. "score_items": _decision_score_items(scorecard, score),
  533. "score_thresholds": _record(scorecard.get("score_thresholds")) or None,
  534. "allow_walk": bool(_record(decision.get("decision_replay_data")).get("allow_walk")),
  535. "v4_explanation": _record(decision.get("v4_explanation") or _record(decision.get("raw_payload")).get("v4_explanation")),
  536. "debug": {"decision": decision} if debug else None,
  537. }
  538. def _evidence_summary(self, evidence: dict[str, Any], *, debug: bool) -> dict[str, Any]:
  539. status = _text(evidence.get("decode_status") or evidence.get("gemini_status"), "unknown")
  540. return {
  541. "status": status,
  542. "label": "Gemini 已看过" if status in {"success", "ok", "passed"} else "Gemini 未完成判断",
  543. "summary": _text(evidence.get("reason") or evidence.get("evidence_summary") or evidence.get("summary")),
  544. "debug": {"evidence": evidence} if debug else None,
  545. }
  546. def _rule_summary(self, decisions: list[dict[str, Any]], *, debug: bool) -> dict[str, Any]:
  547. actions = Counter(_decision_action(item) for item in decisions)
  548. reasons = Counter(_text(item.get("decision_reason_code") or item.get("reason"), "no_decision") for item in decisions)
  549. primary_reason = reasons.most_common(1)[0][0] if reasons else "no_decision"
  550. return {
  551. "total": len(decisions),
  552. "pool_count": actions["ADD_TO_CONTENT_POOL"],
  553. "review_count": actions["KEEP_CONTENT_FOR_REVIEW"],
  554. "technical_retry_count": actions["TECHNICAL_RETRY_REQUIRED"],
  555. "reject_count": actions["REJECT_CONTENT"],
  556. "primary_reason": primary_reason,
  557. "primary_reason_label": _reason_label(primary_reason, "系统原因待补充"),
  558. "score_summary": _score_summary(decisions),
  559. "headline": _rule_headline(actions, len(decisions)),
  560. "debug": {"decisions": decisions} if debug else None,
  561. }
  562. def _walk_summary(self, actions: list[dict[str, Any]]) -> dict[str, Any]:
  563. edges = Counter(_walk_lane_id(action) for action in actions)
  564. reasons = Counter(_text(action.get("reason_code")) for action in actions if _text(action.get("reason_code")))
  565. statuses = Counter(_text(action.get("walk_status"), "unknown") for action in actions)
  566. expansion = [a for a in actions if _text(a.get("edge_id")) in _WALK_EXPANSION_EDGES]
  567. return {
  568. "total": len(actions),
  569. "success_count": statuses["success"],
  570. "skipped_count": statuses["skipped"],
  571. "expansion_count": len(expansion),
  572. "expansion_success": sum(1 for a in expansion if _text(a.get("walk_status")) == "success"),
  573. "max_depth": max((_number(a.get("depth")) or 0 for a in actions), default=0),
  574. "edge_counts": dict(edges),
  575. "edge_labels": {key: WALK_EDGE_LABELS.get(key, "系统原因待补充") for key in edges},
  576. "stop_reason": reasons.most_common(1)[0][0] if reasons else "",
  577. "stop_reason_label": _reason_label(reasons.most_common(1)[0][0], "本轮没有继续扩展") if reasons else "本轮没有继续扩展",
  578. "headline": f"{len(actions)} 次扩展动作" if actions else "本轮没有继续扩展",
  579. }
  580. def _asset_summary(self, decisions: list[dict[str, Any]], final_output: dict[str, Any]) -> dict[str, Any]:
  581. decision_ids = {_text(item.get("decision_id") or item.get("id")) for item in decisions}
  582. if not decision_ids:
  583. return {
  584. "total": 0,
  585. "pool_count": 0,
  586. "review_count": 0,
  587. "technical_retry_count": 0,
  588. "reject_count": 0,
  589. "headline": _asset_headline(0, 0, 0, 0),
  590. }
  591. sections = {
  592. "content_assets": "入池",
  593. "review_records": "待复看",
  594. "technical_retry_records": "技术问题",
  595. "reject_records": "淘汰",
  596. }
  597. counts = {}
  598. total = 0
  599. for section, label in sections.items():
  600. rows = [
  601. item
  602. for item in _list(final_output.get(section))
  603. if _text(item.get("decision_id")) in decision_ids
  604. ]
  605. counts[label] = len(rows)
  606. total += len(rows)
  607. return {
  608. "total": total,
  609. "pool_count": counts.get("入池", 0),
  610. "review_count": counts.get("待复看", 0),
  611. "technical_retry_count": counts.get("技术问题", 0),
  612. "reject_count": counts.get("淘汰", 0),
  613. "headline": _asset_headline(
  614. counts.get("入池", 0),
  615. counts.get("待复看", 0),
  616. counts.get("技术问题", 0),
  617. counts.get("淘汰", 0),
  618. ),
  619. }
  620. def _video_summary(self, videos: list[dict[str, Any]]) -> dict[str, Any]:
  621. actions = Counter(_record(video.get("decision")).get("action") or "NOT_JUDGED" for video in videos)
  622. media = Counter(video.get("media_status") or "unknown" for video in videos)
  623. return {
  624. "total": len(videos),
  625. "pool_count": actions["ADD_TO_CONTENT_POOL"],
  626. "review_count": actions["KEEP_CONTENT_FOR_REVIEW"],
  627. "technical_retry_count": actions["TECHNICAL_RETRY_REQUIRED"],
  628. "reject_count": actions["REJECT_CONTENT"],
  629. "not_judged_count": actions["NOT_JUDGED"],
  630. "oss_uploaded_count": media["oss_uploaded"],
  631. "oss_pending_count": media["oss_upload_pending"],
  632. "oss_failed_count": media["oss_upload_failed"],
  633. "unavailable_count": media["unavailable"] + media["metadata_only"] + media["oss_upload_failed"],
  634. }
  635. def _walk_action(self, action: dict[str, Any], query: dict[str, Any] | None, bundle: dict[str, Any], *, debug: bool, full: bool = False) -> dict[str, Any]:
  636. edge = _walk_lane_id(action)
  637. reason = _text(action.get("reason_code"))
  638. status = _text(action.get("walk_status"), "unknown")
  639. gate_reason = _text(action.get("walk_gate_reason_code") or action.get("allow_walk_reason") or reason)
  640. source_content = _content_for_action(action, bundle)
  641. source_video = self._video(source_content, bundle, debug=debug) if source_content else None
  642. target_query = _target_query_for_action(action, bundle)
  643. target_query_summary = self._query_summary(target_query, bundle, debug=debug) if target_query else None
  644. target_contents = _target_contents_for_action(action, bundle, source_content, target_query)
  645. # full=True(全run游走图 / 单视频子树)不再采样,展示全部带回视频;per-query 视图仍取前 4 条。
  646. shown_contents = target_contents if full else target_contents[:4]
  647. target_videos = [self._video(content, bundle, debug=debug) for content in shown_contents]
  648. trigger_label = _walk_trigger_label(action, source_content, target_query_summary)
  649. from_label = _walk_from_label(action, query, bundle, source_content)
  650. score_items = _walk_score_items(action)
  651. result_label = _walk_result_label(action, edge, status, reason, source_video, target_query_summary, target_videos)
  652. reason_label = _walk_reason_label(action, reason)
  653. operator_display_status = _walk_operator_display_status(status, reason)
  654. return {
  655. "id": _text(action.get("walk_action_id")),
  656. "edge_id": edge,
  657. "edge_label": WALK_EDGE_LABELS.get(edge, "系统原因待补充"),
  658. "depth": _number(action.get("depth")),
  659. "source_video": source_video,
  660. "target_query": target_query_summary,
  661. "target_videos": target_videos,
  662. "trigger_label": trigger_label,
  663. "status": status,
  664. "status_label": _walk_status_label(status),
  665. "operator_display_status": operator_display_status,
  666. "from_label": from_label,
  667. "result": result_label,
  668. "reason": reason,
  669. "reason_label": reason_label,
  670. "impact": _walk_impact(status, reason, gate_reason),
  671. "score_items": score_items,
  672. "summary": _walk_step_summary(edge, status, source_video, trigger_label, result_label, reason_label),
  673. "detail_lines": _walk_detail_lines(action, edge, from_label, trigger_label, score_items, result_label),
  674. "debug": {"action": action} if debug else None,
  675. }
  676. def _summary(
  677. self,
  678. bundle: dict[str, Any],
  679. rows: list[dict[str, Any]],
  680. extension_queries: list[dict[str, Any]],
  681. media_counts: Counter[str],
  682. action_counts: Counter[str],
  683. gemini_counts: Counter[str],
  684. ) -> dict[str, Any]:
  685. query_failures = _query_failures(bundle["queries"])
  686. # 游走指标(run 级):游走带回视频、其中入池、向外游走次数、最深层数。
  687. walk_query_ids = _walk_query_ids(bundle)
  688. walk_items = [c for c in bundle["content_items"] if not _is_first_round_content(c, walk_query_ids)]
  689. pooled_targets = {
  690. _text(d.get("decision_target_id"))
  691. for d in bundle["decisions"]
  692. if _decision_action(d) == "ADD_TO_CONTENT_POOL"
  693. }
  694. walk_pooled = sum(1 for c in walk_items if _text(c.get("platform_content_id")) in pooled_targets)
  695. expansion = [a for a in bundle["walk_actions"] if _text(a.get("edge_id")) in _WALK_EXPANSION_EDGES]
  696. walk_max_depth = max((_number(a.get("depth")) or 0 for a in bundle["walk_actions"]), default=0)
  697. return {
  698. "first_round_query_count": len(rows),
  699. "walk_video_total": len(walk_items),
  700. "walk_pooled_count": walk_pooled,
  701. "walk_action_total": len(expansion),
  702. "walk_action_success": sum(1 for a in expansion if _text(a.get("walk_status")) == "success"),
  703. "walk_max_depth": walk_max_depth,
  704. "extension_query_count": len(extension_queries),
  705. "all_query_count": len(bundle["queries"]),
  706. "query_failure_count": len(query_failures),
  707. "query_failure_examples": [
  708. {
  709. "search_query_id": _text(query.get("search_query_id")),
  710. "search_query": _text(query.get("search_query")),
  711. "message": _text(_query_failure(query).get("message")),
  712. "error_code": _text(_query_failure(query).get("error_code")),
  713. }
  714. for query in query_failures[:5]
  715. ],
  716. "content_count": len(bundle["content_items"]),
  717. "oss_uploaded_count": media_counts["oss_uploaded"],
  718. "oss_pending_count": media_counts["oss_upload_pending"],
  719. "oss_failed_count": media_counts["oss_upload_failed"],
  720. "gemini_completed_count": gemini_counts["ok"] + gemini_counts["success"] + gemini_counts["passed"],
  721. "gemini_failed_count": gemini_counts["failed"],
  722. "pool_count": action_counts["ADD_TO_CONTENT_POOL"],
  723. "review_count": action_counts["KEEP_CONTENT_FOR_REVIEW"],
  724. "technical_retry_count": action_counts["TECHNICAL_RETRY_REQUIRED"],
  725. "reject_count": action_counts["REJECT_CONTENT"],
  726. }
  727. def _query_method(query: dict[str, Any]) -> str:
  728. return _text(query.get("search_query_generation_method"), "unknown")
  729. def _query_failure(query: dict[str, Any]) -> dict[str, Any]:
  730. failure = query.get("query_failure")
  731. if isinstance(failure, dict):
  732. return failure
  733. raw_failure = _record(query.get("raw_payload")).get("query_failure")
  734. return raw_failure if isinstance(raw_failure, dict) else {}
  735. def _query_failures(queries: list[dict[str, Any]]) -> list[dict[str, Any]]:
  736. return [query for query in queries if _query_failure(query)]
  737. def _content_belongs_to_query(content: dict[str, Any], query_id: str) -> bool:
  738. if not query_id:
  739. return False
  740. if _text(content.get("search_query_id")) == query_id:
  741. return True
  742. if query_id in _text_list(content.get("matched_search_query_ids")):
  743. return True
  744. raw_payload = _record(content.get("raw_payload"))
  745. if query_id in _text_list(raw_payload.get("matched_search_query_ids")):
  746. return True
  747. for source in _list(content.get("query_sources")) + _list(raw_payload.get("query_sources")):
  748. if _text(source.get("search_query_id")) == query_id:
  749. return True
  750. return False
  751. def _is_first_round_content(content: dict[str, Any], walk_query_ids: set[str] | None = None) -> bool:
  752. # 优先用「内容所属 query 是不是扩展(标签/作者)query」判定——这条 query→内容 的关联即使在
  753. # labeling 修复前的旧 run 里也正确(旧 run 把 previous_discovery_step 误标成 search_query_direct)。
  754. if walk_query_ids and _text(content.get("search_query_id")) in walk_query_ids:
  755. return False
  756. previous = _text(content.get("previous_discovery_step") or _record(content.get("raw_payload")).get("previous_discovery_step"))
  757. if not previous:
  758. return True
  759. return not any(token in previous for token in ["tag", "hashtag", "author", "next_page", "walk", "pagination"])
  760. def _walk_query_ids(bundle: dict[str, Any]) -> set[str]:
  761. return {
  762. _text(query.get("search_query_id"))
  763. for query in bundle["queries"]
  764. if _query_method(query) in EXTENSION_QUERY_METHODS and _text(query.get("search_query_id"))
  765. }
  766. def _content_ids(content: dict[str, Any]) -> set[str]:
  767. return {
  768. _text(content.get("content_discovery_id")),
  769. _text(content.get("platform_content_id")),
  770. _text(content.get("decision_target_id")),
  771. }
  772. def _decision_for_content(content: dict[str, Any], decisions: list[dict[str, Any]]) -> dict[str, Any] | None:
  773. ids = _content_ids(content)
  774. return next((decision for decision in decisions if _text(decision.get("decision_target_id")) in ids), None)
  775. def _media_for_content(content: dict[str, Any], media_rows: list[dict[str, Any]]) -> dict[str, Any] | None:
  776. platform_id = _text(content.get("platform_content_id"))
  777. return next((media for media in media_rows if _text(media.get("platform_content_id")) == platform_id), None)
  778. def _evidence_for_content(content: dict[str, Any], evidence_rows: list[dict[str, Any]]) -> dict[str, Any] | None:
  779. platform_id = _text(content.get("platform_content_id"))
  780. discovery_id = _text(content.get("content_discovery_id"))
  781. return next(
  782. (
  783. evidence
  784. for evidence in evidence_rows
  785. if _text(evidence.get("platform_content_id")) == platform_id
  786. or _text(evidence.get("content_discovery_id")) == discovery_id
  787. ),
  788. None,
  789. )
  790. def _walk_out_counter(bundle: dict[str, Any]) -> dict[str, int]:
  791. # 每个源视频 platform_content_id → 它成功向外游走(标签/作者)的次数;0 表示这条没真正走过。
  792. counter: Counter[str] = Counter()
  793. for action in bundle["walk_actions"]:
  794. if _text(action.get("edge_id")) not in _WALK_EXPANSION_EDGES:
  795. continue
  796. if _text(action.get("walk_status")) != "success":
  797. continue
  798. source = _content_for_action(action, bundle)
  799. platform_id = _text((source or {}).get("platform_content_id"))
  800. if platform_id:
  801. counter[platform_id] += 1
  802. return dict(counter)
  803. def _load_walk_caps() -> dict[str, Any]:
  804. # 游走预算上限(best-effort,只读 walk_policy.json;env 覆盖时数值可能与真实 run 略有出入)。
  805. path = os.path.join("tech_documents", "数据接口与来源", "walk_policy.json")
  806. try:
  807. with open(path, encoding="utf-8") as handle:
  808. data = json.load(handle)
  809. except Exception:
  810. return {}
  811. def _value(node: Any) -> Any:
  812. return node.get("value") if isinstance(node, dict) else node
  813. global_caps = data.get("global", {}) if isinstance(data.get("global"), dict) else {}
  814. edges = {
  815. _text(edge.get("edge_id")): edge
  816. for edge in data.get("edge_budgets", [])
  817. if isinstance(edge, dict)
  818. }
  819. return {
  820. "max_depth": _value(global_caps.get("max_depth")),
  821. "hashtag_to_query": (edges.get("hashtag_to_query") or {}).get("max_total_actions"),
  822. "author_to_works": (edges.get("author_to_works") or {}).get("max_total_actions"),
  823. }
  824. def _walk_budget_overview(
  825. bundle: dict[str, Any],
  826. actions: list[dict[str, Any]],
  827. extension_queries: list[dict[str, Any]],
  828. ) -> dict[str, Any]:
  829. caps = _load_walk_caps()
  830. statuses = Counter(_text(action.get("status"), "unknown") for action in actions)
  831. tag_used = sum(1 for a in actions if a.get("edge_id") == "hashtag_to_query" and a.get("status") == "success")
  832. author_used = sum(1 for a in actions if a.get("edge_id") == "author_to_works" and a.get("status") == "success")
  833. walk_query_ids = _walk_query_ids(bundle)
  834. walk_items = [c for c in bundle["content_items"] if not _is_first_round_content(c, walk_query_ids)]
  835. return {
  836. "total_actions": len(actions),
  837. "success_count": statuses["success"],
  838. "skipped_count": statuses["skipped"],
  839. "extension_query_count": len(extension_queries),
  840. "tag_used": tag_used,
  841. "tag_cap": caps.get("hashtag_to_query"),
  842. "author_used": author_used,
  843. "author_cap": caps.get("author_to_works"),
  844. "max_depth_reached": max((_number(a.get("depth")) or 0 for a in actions), default=0),
  845. "max_depth_cap": caps.get("max_depth"),
  846. "walk_video_total": len(walk_items),
  847. }
  848. def _walk_action_related(action: dict[str, Any], query_id: str, related_ids: set[str]) -> bool:
  849. values = {
  850. _text(action.get("from_node_id")),
  851. _text(action.get("to_node_id")),
  852. _text(action.get("decision_target_id")),
  853. }
  854. raw = _record(action.get("raw_payload"))
  855. values.update({
  856. _text(raw.get("parent_search_query_id")),
  857. _text(raw.get("search_query_id")),
  858. _text(raw.get("decision_target_id")),
  859. })
  860. return query_id in values or bool(values & related_ids)
  861. def _walk_lane_id(action: dict[str, Any]) -> str:
  862. return _text(action.get("edge_id") or action.get("edge_type"), "unknown")
  863. def _walk_lane(edge: str) -> str:
  864. if edge in {"hashtag_to_query", "tag_query"}:
  865. return "tag_query"
  866. if edge in {"author_to_works", "video_to_author", "author_works"}:
  867. return "author_works"
  868. return "path_stop"
  869. def _source_path(path: dict[str, Any], *, debug: bool) -> dict[str, Any]:
  870. return {
  871. "type": _text(path.get("source_path_type")),
  872. "label": "内容来源路径",
  873. "status": _text(path.get("status"), "unknown"),
  874. "debug": {"source_path": path} if debug else None,
  875. }
  876. def _decision_action(decision: dict[str, Any]) -> str:
  877. action = _text(decision.get("decision_action") or decision.get("action"), "NOT_JUDGED")
  878. if action == "ADD_CONTENT_TO_POOL":
  879. return "ADD_TO_CONTENT_POOL"
  880. return action
  881. def _media_status(media: dict[str, Any] | None) -> str:
  882. if not media:
  883. return "unavailable"
  884. return _text(media.get("content_media_status"), "metadata_only")
  885. def _media_failure_reason(media: dict[str, Any] | None) -> str:
  886. if not media:
  887. return ""
  888. raw = _record(media.get("raw_payload"))
  889. return _text(media.get("failure_reason") or raw.get("upload_failure_reason") or raw.get("failure_reason"))
  890. def _is_content_inspection_blocked(
  891. detail: dict[str, Any] | None,
  892. evidence: dict[str, Any] | None = None,
  893. ) -> bool:
  894. # 内容审核拦截:新数据 failure_type=content_inspection_blocked;旧数据从响应体文本兜底(DataInspectionFailed)。
  895. if isinstance(detail, dict) and _text(detail.get("failure_type")) == "content_inspection_blocked":
  896. return True
  897. evidence_raw = _record((evidence or {}).get("raw_payload"))
  898. if _text(evidence_raw.get("failure_type")) == "content_inspection_blocked":
  899. return True
  900. texts = [
  901. _text(_record(evidence_raw.get("response_body_summary")).get("text_excerpt")),
  902. _text(_record(_record((detail or {}).get("openrouter")).get("response_summary")).get("text_excerpt")),
  903. ]
  904. blob = " ".join(texts).lower()
  905. return "datainspection" in blob or "inappropriate content" in blob
  906. def _technical_retry_detail(
  907. decision: dict[str, Any] | None,
  908. evidence: dict[str, Any] | None,
  909. media: dict[str, Any] | None,
  910. ) -> dict[str, Any] | None:
  911. if _decision_action(decision or {}) != "TECHNICAL_RETRY_REQUIRED":
  912. return None
  913. scorecard = _record((decision or {}).get("scorecard"))
  914. replay_data = _record((decision or {}).get("decision_replay_data"))
  915. evidence_summary = _record((evidence or {}).get("evidence_summary"))
  916. evidence_raw = _record((evidence or {}).get("raw_payload"))
  917. media_raw = _record((media or {}).get("raw_payload"))
  918. merged = {**scorecard, **replay_data, **evidence_summary, **evidence_raw}
  919. failure_type = _text(
  920. merged.get("failure_type")
  921. or media_raw.get("oss_archive_last_error")
  922. or media_raw.get("failure_reason")
  923. or _media_failure_reason(media)
  924. )
  925. if not failure_type:
  926. return None
  927. timing = _record(merged.get("timing_metrics"))
  928. video_fetch = _record(timing.get("video_fetch"))
  929. gemini_request = _record(timing.get("gemini_request"))
  930. attempts = [
  931. {
  932. "attempt": item.get("attempt"),
  933. "status": _text(item.get("status")),
  934. "duration_seconds": _seconds(item.get("duration_ms")),
  935. "failure_type": _text(item.get("failure_type")),
  936. "exception_type": _text(item.get("exception_type")),
  937. "http_status_code": item.get("http_status_code"),
  938. "response_body_summary": _record(item.get("response_body_summary")),
  939. }
  940. for item in _list(gemini_request.get("attempts"))
  941. ]
  942. detail = {
  943. "brief_reason": _technical_retry_brief_reason(failure_type, merged, media_raw),
  944. "stage": _technical_retry_stage(failure_type),
  945. "stage_label": _technical_retry_stage_label(failure_type),
  946. "failure_type": failure_type,
  947. "failure_label": _technical_retry_failure_label(failure_type),
  948. "exception_type": _text(merged.get("exception_type")),
  949. "http_status_code": merged.get("http_status_code"),
  950. "error_message": _text(merged.get("error_message")),
  951. "retry_count": merged.get("retry_count"),
  952. "video_source": _text(video_fetch.get("gemini_video_source")),
  953. "timings": {
  954. "download_seconds": _seconds(video_fetch.get("download_duration_ms")),
  955. "ffmpeg_seconds": _seconds(video_fetch.get("ffmpeg_duration_ms")),
  956. "video_fetch_total_seconds": _seconds(video_fetch.get("total_duration_ms")),
  957. "gemini_seconds": _seconds(gemini_request.get("total_duration_ms")),
  958. },
  959. "sizes": {
  960. "downloaded_mb": _megabytes(video_fetch.get("downloaded_bytes")),
  961. "compressed_mb": _megabytes(video_fetch.get("compressed_bytes")),
  962. },
  963. "attempts": attempts,
  964. "openrouter": {
  965. "response_summary": _record(merged.get("response_body_summary")),
  966. },
  967. "oss": {
  968. "status": _text((media or {}).get("content_media_status")),
  969. "last_error": _text(media_raw.get("oss_archive_last_error") or media_raw.get("failure_reason")),
  970. "last_exception_type": _text(media_raw.get("oss_archive_last_exception_type")),
  971. "last_http_status_code": media_raw.get("oss_archive_last_http_status_code"),
  972. "attempt_count": media_raw.get("oss_archive_attempt_count"),
  973. "duration_seconds": _seconds(_record(media_raw.get("oss_timing_metrics")).get("oss_upload_duration_ms")),
  974. "payload_mode": _text(media_raw.get("oss_payload_mode")),
  975. "response_summary": _record(media_raw.get("oss_response_summary")),
  976. },
  977. "video_url": {
  978. "selected_path": _text(media_raw.get("selected_video_url_path")),
  979. "selected_host": _text(media_raw.get("selected_video_url_host")),
  980. "probe_status": _text(media_raw.get("selected_video_url_probe_status")),
  981. "content_type": _text(media_raw.get("selected_video_url_content_type")),
  982. "content_range": _text(media_raw.get("selected_video_url_content_range")),
  983. "candidate_counts": _record(media_raw.get("video_url_candidate_counts")),
  984. "reject_reasons": _record(media_raw.get("video_url_reject_reasons")),
  985. },
  986. }
  987. return detail
  988. def _technical_retry_brief_reason(
  989. failure_type: str,
  990. payload: dict[str, Any],
  991. media_raw: dict[str, Any],
  992. ) -> str:
  993. if failure_type == "content_inspection_blocked":
  994. return "视频被大模型内容安全审核拦截(非系统故障,重试无效)"
  995. if failure_type in {"portrait_unavailable", "portrait_incomplete"}:
  996. reason = _text(payload.get("fifty_plus_failure_reason"))
  997. base = "拉不到作者 50+ 画像(热点宝)" if failure_type == "portrait_unavailable" else "作者 50+ 画像数据不全"
  998. return f"{base}:{reason}" if reason else base
  999. if failure_type == "no_valid_play_url":
  1000. return "平台结果未找到可用正片 URL"
  1001. if failure_type == "no_play_url":
  1002. return "平台结果没有返回可用 play_url"
  1003. if failure_type == "qwen_client_timeout":
  1004. return "通义千问请求写入超时"
  1005. if failure_type == "qwen_http_error":
  1006. status = payload.get("http_status_code")
  1007. return f"通义千问返回 HTTP {status}" if status else "通义千问请求失败"
  1008. if failure_type == "qwen_response_invalid":
  1009. return "通义千问返回格式无法解析"
  1010. if failure_type == "gemini_client_timeout":
  1011. return "OpenRouter/Gemini 请求写入超时"
  1012. if failure_type == "gemini_http_error":
  1013. status = payload.get("http_status_code")
  1014. return f"OpenRouter/Gemini 返回 HTTP {status}" if status else "OpenRouter/Gemini 请求失败"
  1015. if failure_type == "gemini_response_invalid":
  1016. return "OpenRouter/Gemini 返回格式无法解析"
  1017. if failure_type == "video_fetch_failed":
  1018. return "视频下载或压缩失败"
  1019. if failure_type == "video_judge_timeout":
  1020. return "视频判定 worker 超时未返回(已超兜底上限,跳过本条)"
  1021. if failure_type == "oss_worker_timeout":
  1022. return "OSS 归档 worker 超时未返回(已超兜底上限,跳过本条)"
  1023. if failure_type.startswith("oss_"):
  1024. return "OSS 转存未拿到可用视频地址"
  1025. if media_raw.get("oss_archive_last_error"):
  1026. return "OSS 归档失败"
  1027. return failure_type
  1028. def _technical_retry_stage(failure_type: str) -> str:
  1029. if failure_type == "content_inspection_blocked":
  1030. return "content_inspection"
  1031. if failure_type == "video_judge_timeout":
  1032. return "video_judge"
  1033. if failure_type.startswith("portrait"):
  1034. return "portrait"
  1035. if failure_type.startswith("oss_"):
  1036. return "oss"
  1037. if failure_type in {"no_play_url", "no_valid_play_url"}:
  1038. return "play_url"
  1039. if "ffmpeg" in failure_type:
  1040. return "ffmpeg"
  1041. if failure_type in {"video_fetch_failed", "video_download_failed"}:
  1042. return "video_download"
  1043. if failure_type.startswith("qwen_"):
  1044. return "dashscope"
  1045. if failure_type.startswith("gemini_"):
  1046. return "openrouter"
  1047. return "unknown"
  1048. def _technical_retry_stage_label(failure_type: str) -> str:
  1049. return {
  1050. "oss": "OSS",
  1051. "play_url": "平台 play_url",
  1052. "video_download": "视频下载",
  1053. "ffmpeg": "ffmpeg",
  1054. "dashscope": "通义千问",
  1055. "openrouter": "OpenRouter/Gemini",
  1056. "content_inspection": "内容审核拦截",
  1057. "portrait": "热点宝画像",
  1058. "video_judge": "视频判定调度",
  1059. "unknown": "未知阶段",
  1060. }[_technical_retry_stage(failure_type)]
  1061. def _technical_retry_failure_label(failure_type: str) -> str:
  1062. return {
  1063. "content_inspection_blocked": "模型内容审核拦截",
  1064. "qwen_client_timeout": "通义千问 客户端超时",
  1065. "qwen_http_error": "通义千问 HTTP 错误",
  1066. "qwen_response_invalid": "通义千问 响应无法解析",
  1067. "gemini_client_timeout": "Gemini 客户端超时",
  1068. "gemini_http_error": "Gemini HTTP 错误",
  1069. "gemini_response_invalid": "Gemini 响应无法解析",
  1070. "video_fetch_failed": "视频获取失败",
  1071. "no_play_url": "缺少 play_url",
  1072. "no_valid_play_url": "未找到可用正片 URL",
  1073. "oss_upload_response_invalid": "OSS 响应无效",
  1074. "oss_upload_http_error": "OSS HTTP 错误",
  1075. "oss_worker_timeout": "OSS 归档 worker 超时",
  1076. "portrait_unavailable": "拉不到作者 50+ 画像",
  1077. "portrait_incomplete": "作者 50+ 画像数据不全",
  1078. "video_judge_timeout": "视频判定调度超时",
  1079. }.get(failure_type, failure_type)
  1080. def _seconds(value: Any) -> float | None:
  1081. try:
  1082. return round(float(value) / 1000.0, 3)
  1083. except (TypeError, ValueError):
  1084. return None
  1085. def _megabytes(value: Any) -> float | None:
  1086. try:
  1087. return round(float(value) / 1024.0 / 1024.0, 2)
  1088. except (TypeError, ValueError):
  1089. return None
  1090. def _rule_headline(actions: Counter[str], total: int) -> str:
  1091. if not total:
  1092. return "首轮内容暂未进入判断"
  1093. if actions["ADD_TO_CONTENT_POOL"]:
  1094. return f"有 {actions['ADD_TO_CONTENT_POOL']} 条内容入池"
  1095. if actions["KEEP_CONTENT_FOR_REVIEW"]:
  1096. return f"有 {actions['KEEP_CONTENT_FOR_REVIEW']} 条内容待复看"
  1097. if actions["TECHNICAL_RETRY_REQUIRED"]:
  1098. return f"有 {actions['TECHNICAL_RETRY_REQUIRED']} 条内容出现技术问题"
  1099. return "本轮没有可沉淀内容"
  1100. def _asset_headline(
  1101. pool_count: int,
  1102. review_count: int,
  1103. technical_retry_count: int,
  1104. reject_count: int,
  1105. ) -> str:
  1106. parts = []
  1107. if pool_count:
  1108. parts.append(f"入池 {pool_count} 条")
  1109. if review_count:
  1110. parts.append(f"待复看 {review_count} 条")
  1111. if technical_retry_count:
  1112. parts.append(f"技术问题 {technical_retry_count} 条")
  1113. if parts:
  1114. return ",".join(parts)
  1115. if reject_count:
  1116. return f"没有入池,淘汰 {reject_count} 条"
  1117. return "暂无可沉淀内容"
  1118. def _effect_label(status: str) -> str:
  1119. return {
  1120. "success": "已找到可用结果",
  1121. "rule_blocked": "找到结果,但未通过内容判断",
  1122. "pending": "处理中",
  1123. "failed": "搜索失败",
  1124. "skipped": "已跳过",
  1125. }.get(status, "处理状态待确认")
  1126. def _walk_status_label(status: str) -> str:
  1127. return {
  1128. "success": "已执行",
  1129. "skipped": "未继续",
  1130. "failed": "执行失败",
  1131. "pending": "处理中",
  1132. }.get(status, "状态待确认")
  1133. def _action_value(action: dict[str, Any], key: str) -> Any:
  1134. raw = _record(action.get("raw_payload"))
  1135. return action.get(key) if action.get(key) not in (None, "") else raw.get(key)
  1136. def _find_content_by_id(content_id: str, bundle: dict[str, Any]) -> dict[str, Any] | None:
  1137. if not content_id:
  1138. return None
  1139. return next((item for item in bundle["content_items"] if content_id in _content_ids(item)), None)
  1140. def _decision_by_id(decision_id: str, bundle: dict[str, Any]) -> dict[str, Any] | None:
  1141. if not decision_id:
  1142. return None
  1143. return next((item for item in bundle["decisions"] if _text(item.get("decision_id")) == decision_id), None)
  1144. def _decision_for_action(action: dict[str, Any], bundle: dict[str, Any]) -> dict[str, Any] | None:
  1145. decision_id = _text(_action_value(action, "decision_id"))
  1146. if not decision_id and _text(_action_value(action, "from_node_type")).lower() == "ruledecision":
  1147. decision_id = _text(_action_value(action, "from_node_id"))
  1148. return _decision_by_id(decision_id, bundle)
  1149. def _content_for_action(action: dict[str, Any], bundle: dict[str, Any]) -> dict[str, Any] | None:
  1150. candidate_ids = [
  1151. _text(_action_value(action, "decision_target_id")),
  1152. _text(_action_value(action, "to_node_id")),
  1153. _text(_action_value(action, "from_node_id")),
  1154. ]
  1155. for candidate_id in candidate_ids:
  1156. content = _find_content_by_id(candidate_id, bundle)
  1157. if content:
  1158. return content
  1159. decision = _decision_for_action(action, bundle)
  1160. if decision:
  1161. return _find_content_by_id(_text(decision.get("decision_target_id")), bundle)
  1162. return None
  1163. def _target_query_for_action(action: dict[str, Any], bundle: dict[str, Any]) -> dict[str, Any] | None:
  1164. query_id = _text(_action_value(action, "to_node_id"))
  1165. return next((item for item in bundle["queries"] if _text(item.get("search_query_id")) == query_id), None)
  1166. def _target_contents_for_action(
  1167. action: dict[str, Any],
  1168. bundle: dict[str, Any],
  1169. source_content: dict[str, Any] | None,
  1170. target_query: dict[str, Any] | None,
  1171. ) -> list[dict[str, Any]]:
  1172. if target_query:
  1173. query_id = _text(target_query.get("search_query_id"))
  1174. return [item for item in bundle["content_items"] if _content_belongs_to_query(item, query_id)]
  1175. edge = _walk_lane_id(action)
  1176. if edge in {"author_to_works", "video_to_author", "author_works"}:
  1177. author_id = _text(_action_value(action, "author_id") or _action_value(action, "from_node_id") or (source_content or {}).get("platform_author_id"))
  1178. if not author_id:
  1179. return []
  1180. return [
  1181. item
  1182. for item in bundle["content_items"]
  1183. if _text(item.get("platform_author_id")) == author_id
  1184. and "author" in _text(item.get("previous_discovery_step") or _record(item.get("raw_payload")).get("previous_discovery_step"))
  1185. ]
  1186. target_content = _find_content_by_id(_text(_action_value(action, "to_node_id")), bundle)
  1187. return [target_content] if target_content else []
  1188. def _walk_from_label(
  1189. action: dict[str, Any],
  1190. query: dict[str, Any] | None,
  1191. bundle: dict[str, Any],
  1192. source_content: dict[str, Any] | None = None,
  1193. ) -> str:
  1194. from_type = _text(action.get("from_node_type")).lower()
  1195. from_node_id = _text(action.get("from_node_id"))
  1196. edge = _walk_lane_id(action)
  1197. if edge in {"hashtag_to_query", "tag_query", "author_to_works", "video_to_author", "author_works"} and source_content:
  1198. title = _text(source_content.get("description") or source_content.get("title"))
  1199. return f"从视频《{_short_text(title, 34)}》触发" if title else "从视频内容触发"
  1200. if "query" in from_type:
  1201. source_query = next((item for item in bundle["queries"] if _text(item.get("search_query_id")) == from_node_id), None) or query
  1202. return f"从搜索词《{_text((source_query or {}).get('search_query'), '搜索词待确认')}》继续"
  1203. if "ruledecision" in from_type:
  1204. content = _content_for_action(action, bundle)
  1205. title = _text((content or {}).get("description") or (content or {}).get("title"))
  1206. return f"判断视频《{_short_text(title, 34)}》后触发" if title else "由内容判断结果触发"
  1207. if "content" in from_type or "video" in from_type or source_content:
  1208. title = _text((source_content or {}).get("description") or (source_content or {}).get("title"))
  1209. return f"从视频《{_short_text(title, 34)}》触发" if title else "从视频内容触发"
  1210. if "author" in from_type:
  1211. content = _content_for_action(action, bundle)
  1212. author = _text((content or {}).get("author_display_name") or from_node_id, "作者待确认")
  1213. title = _text((content or {}).get("description") or (content or {}).get("title"))
  1214. return f"从作者《{author}》触发,来源视频《{_short_text(title, 24)}》" if title else f"从作者《{author}》触发"
  1215. if "tag" in from_type:
  1216. return "从视频标签触发"
  1217. return "从上一环节出发"
  1218. def _walk_impact(status: str, reason: str, gate_reason: str) -> str:
  1219. if reason == "blocked_by_rule_decision" or gate_reason == "v4_allow_walk_denied":
  1220. return "这条内容不会继续带出更多视频。"
  1221. if reason == "budget_exhausted":
  1222. return "达到游走次数上限,这一步不会继续新增搜索词。"
  1223. if status == "success":
  1224. return "这一步已经执行,结果会进入后续判断。"
  1225. if status == "skipped":
  1226. return "这一步没有继续执行,不影响最终沉淀。"
  1227. return "这一步暂不影响最终沉淀。"
  1228. def _walk_operator_display_status(status: str, reason: str) -> str:
  1229. return "budget_blocked" if status == "skipped" and reason == "budget_exhausted" else status
  1230. def _walk_trigger_label(
  1231. action: dict[str, Any],
  1232. source_content: dict[str, Any] | None = None,
  1233. target_query: dict[str, Any] | None = None,
  1234. ) -> str:
  1235. hashtag = _text(_action_value(action, "hashtag"))
  1236. if hashtag:
  1237. return f"标签:#{hashtag.lstrip('#')}"
  1238. edge = _walk_lane_id(action)
  1239. if edge in {"hashtag_to_query", "tag_query"} and target_query:
  1240. return f"标签搜索词:{_text(target_query.get('text'), '搜索词待确认')}"
  1241. if edge in {"hashtag_to_query", "tag_query"} and source_content:
  1242. tags = _text_list(source_content.get("tags"))
  1243. return f"候选标签:{' / '.join(tags[:3])}" if tags else "标签未生成:这条视频未达到扩展门槛"
  1244. author = _text(_action_value(action, "author_id") or _action_value(action, "platform_author_id"))
  1245. if not author and edge in {"author_to_works", "video_to_author", "author_works"} and source_content:
  1246. author = _text(source_content.get("author_display_name") or source_content.get("platform_author_id"))
  1247. if author:
  1248. return f"作者:{author}"
  1249. if edge in {"author_to_works", "video_to_author", "author_works"}:
  1250. return "作者:未进入作者作品扩展"
  1251. cursor = _text(_action_value(action, "page_cursor") or _action_value(action, "cursor"))
  1252. if cursor:
  1253. return f"翻下一页:游标 {cursor}"
  1254. to_node = _text(_action_value(action, "to_node_id"))
  1255. if source_content and edge in {"path_stop", "decision_to_asset", "budget_downgrade"}:
  1256. return f"视频:{_short_text(_text(source_content.get('description') or source_content.get('title'), '视频待确认'), 34)}"
  1257. if to_node and to_node not in {"tag_query_skipped", "author_works_skipped"}:
  1258. return f"结果对象:{to_node}"
  1259. return "触发对象待确认"
  1260. def _walk_reason_label(action: dict[str, Any], reason: str) -> str:
  1261. gate_reason = _text(_action_value(action, "walk_gate_reason_code") or _action_value(action, "allow_walk_reason"))
  1262. if gate_reason == "v4_allow_walk_denied":
  1263. allow_reason = _text(_action_value(action, "allow_walk_reason"))
  1264. if allow_reason and allow_reason != gate_reason:
  1265. return f"不适合继续向外扩展:{_reason_label(allow_reason, '评分未达到继续扩展门槛')}"
  1266. return "不适合继续向外扩展"
  1267. if reason:
  1268. return _reason_label(reason, "这一步没有记录额外原因")
  1269. return "这一步没有记录额外原因"
  1270. def _walk_result_label(
  1271. action: dict[str, Any],
  1272. edge: str,
  1273. status: str,
  1274. reason: str,
  1275. source_video: dict[str, Any] | None,
  1276. target_query: dict[str, Any] | None,
  1277. target_videos: list[dict[str, Any]],
  1278. ) -> str:
  1279. if edge == "decision_to_asset":
  1280. return "这条视频已进入内容池。"
  1281. if edge == "path_stop":
  1282. return "这条视频到这里停止,不再向外扩展。"
  1283. if edge == "budget_downgrade":
  1284. return "这条视频进入待复看,先不作为强信号继续扩展。"
  1285. if status == "skipped" and reason == "budget_exhausted":
  1286. return "内容达到扩展门槛,但已经达到游走次数上限。"
  1287. if status == "skipped":
  1288. return "没有继续扩展。"
  1289. if edge in {"hashtag_to_query", "tag_query"}:
  1290. query_text = _text((target_query or {}).get("text"))
  1291. return f"已生成标签搜索词《{query_text}》,带回 {len(target_videos)} 条视频。" if query_text else f"已从视频标签继续游走,带回 {len(target_videos)} 条视频。"
  1292. if edge in {"author_to_works", "video_to_author", "author_works"}:
  1293. author = _text((source_video or {}).get("author"), "该作者")
  1294. return f"已查看作者《{author}》更多作品,带回 {len(target_videos)} 条视频。"
  1295. return "这一步已经执行,结果会进入后续判断。"
  1296. def _walk_step_summary(
  1297. edge: str,
  1298. status: str,
  1299. source_video: dict[str, Any] | None,
  1300. trigger_label: str,
  1301. result_label: str,
  1302. reason_label: str,
  1303. ) -> str:
  1304. title = _text((source_video or {}).get("title"))
  1305. if edge in {"hashtag_to_query", "tag_query"}:
  1306. action = "从视频标签继续游走"
  1307. elif edge in {"author_to_works", "video_to_author", "author_works"}:
  1308. action = "从作者继续游走"
  1309. elif edge == "decision_to_asset":
  1310. action = "入池沉淀"
  1311. elif edge == "budget_downgrade":
  1312. action = "转入待复看"
  1313. elif edge == "path_stop":
  1314. action = "停止这条线"
  1315. else:
  1316. action = WALK_EDGE_LABELS.get(edge, "扩展动作")
  1317. prefix = f"从视频《{_short_text(title, 28)}》" if title else "从当前搜索词"
  1318. if status == "success":
  1319. return f"{prefix}{action}:{result_label}"
  1320. return f"{prefix}{action}:{reason_label}"
  1321. def _walk_detail_lines(
  1322. action: dict[str, Any],
  1323. edge: str,
  1324. from_label: str,
  1325. trigger_label: str,
  1326. score_items: list[dict[str, Any]],
  1327. result_label: str,
  1328. ) -> list[str]:
  1329. lines = [
  1330. f"动作:{WALK_EDGE_LABELS.get(edge, '扩展动作')}",
  1331. f"起点:{from_label}",
  1332. ]
  1333. if trigger_label != "触发对象待确认":
  1334. lines.append(f"扩展对象:{trigger_label}")
  1335. if score_items:
  1336. lines.append("扩展门槛:" + " / ".join(f"{item['label']} {item['score_label']}" for item in score_items[:3]))
  1337. reason = _walk_reason_label(action, _text(_action_value(action, "reason_code")))
  1338. if reason != "这一步没有记录额外原因":
  1339. lines.append(f"判断:{reason}")
  1340. if result_label:
  1341. lines.append(f"结果:{result_label}")
  1342. return lines
  1343. def _walk_score_items(action: dict[str, Any]) -> list[dict[str, Any]]:
  1344. snapshot = _record(action.get("walk_gate_snapshot") or _record(action.get("raw_payload")).get("walk_gate_snapshot"))
  1345. return _score_items_from_values(
  1346. total=snapshot.get("score"),
  1347. query=snapshot.get("query_relevance_score"),
  1348. platform=snapshot.get("platform_performance_score"),
  1349. components=[],
  1350. )
  1351. def _decision_score_items(scorecard: dict[str, Any], score: Any) -> list[dict[str, Any]]:
  1352. weights, fifty = _v4_display_weights(scorecard)
  1353. return _score_items_from_values(
  1354. total=score if score is not None else scorecard.get("total_score"),
  1355. query=scorecard.get("query_relevance_score"),
  1356. platform=scorecard.get("platform_performance_score"),
  1357. components=_list(scorecard.get("platform_performance_components")),
  1358. weights=weights,
  1359. fifty=fifty,
  1360. )
  1361. def _v4_display_weights(scorecard: dict[str, Any]) -> tuple[dict[str, float], dict[str, Any] | None]:
  1362. """展示 evaluator 已落在 scorecard 的权重;旧记录无 score_weights 时保留历史推断。"""
  1363. weights = _display_weights_from_scorecard(scorecard)
  1364. if "fifty_plus_status" not in scorecard:
  1365. return weights or {"query": 0.5, "platform": 0.5}, None
  1366. status = scorecard.get("fifty_plus_status")
  1367. fifty: dict[str, Any] = {"status": status}
  1368. if status == "ok":
  1369. fifty["score"] = scorecard.get("fifty_plus_score")
  1370. fifty["components"] = _record(scorecard.get("fifty_plus_components"))
  1371. return weights or {"query": 0.35, "platform": 0.35, "fifty": 0.30}, fifty
  1372. def _display_weights_from_scorecard(scorecard: dict[str, Any]) -> dict[str, float]:
  1373. weights = scorecard.get("score_weights")
  1374. if not isinstance(weights, dict):
  1375. return {}
  1376. display = {}
  1377. mapping = {
  1378. "query_relevance": "query",
  1379. "platform_performance": "platform",
  1380. "fifty_plus": "fifty",
  1381. }
  1382. for source_key, target_key in mapping.items():
  1383. value = weights.get(source_key)
  1384. if isinstance(value, (int, float)) and not isinstance(value, bool):
  1385. display[target_key] = float(value)
  1386. return display
  1387. def _fifty_status_note(status: Any) -> str:
  1388. return {
  1389. "not_attempted": "相关性未达标(query<65),未拉取画像",
  1390. "unavailable": "作者画像拉取失败,未评估",
  1391. "incomplete": "作者画像数据不全,未评估",
  1392. }.get(str(status), "未评估")
  1393. def _score_summary(decisions: list[dict[str, Any]]) -> dict[str, Any]:
  1394. scorecards = [_record(item.get("scorecard")) for item in decisions]
  1395. return {
  1396. "items": [
  1397. _avg_score_item("总分", [item.get("score") if item.get("score") is not None else _record(item.get("scorecard")).get("total_score") for item in decisions]),
  1398. _avg_score_item("是否契合需求", [item.get("query_relevance_score") for item in scorecards]),
  1399. _avg_score_item("平台表现", [item.get("platform_performance_score") for item in scorecards]),
  1400. ],
  1401. }
  1402. def _score_items_from_values(
  1403. total: Any,
  1404. query: Any,
  1405. platform: Any,
  1406. components: list[dict[str, Any]],
  1407. *,
  1408. weights: dict[str, float] | None = None,
  1409. fifty: dict[str, Any] | None = None,
  1410. ) -> list[dict[str, Any]]:
  1411. weights = weights or {}
  1412. items = [
  1413. _score_item("总分", total, "最终用于入池 / 复看 / 淘汰的总分"),
  1414. _score_item("是否契合需求", query, "视频内容和本次搜索词、需求的匹配程度", weight=weights.get("query"), group="main"),
  1415. _score_item("平台表现", platform, "点赞、评论、转发、收藏等互动表现", weight=weights.get("platform"), group="main"),
  1416. ]
  1417. if fifty is not None:
  1418. # 50+老年 维度卡(仅抖音):status=ok 出真实分,否则出灰卡「未评估」。
  1419. if fifty.get("status") == "ok":
  1420. items.append(_score_item(
  1421. "50+老年受众", fifty.get("score"), "作者粉丝 50+ 画像(TGI × 占比)",
  1422. weight=weights.get("fifty"), group="main", status="ok",
  1423. ))
  1424. else:
  1425. items.append(_score_item(
  1426. "50+老年受众", None, _fifty_status_note(fifty.get("status")),
  1427. weight=weights.get("fifty"), group="main", status=fifty.get("status"), allow_none=True,
  1428. ))
  1429. for component in components:
  1430. if component.get("type"):
  1431. # M11:新打分 component 自带 label(中文)+ type(absolute/ratio)
  1432. label = _text(component.get("label")) or _platform_component_label(_text(component.get("field")))
  1433. detail = _platform_component_detail(component)
  1434. else:
  1435. # 旧 run 的 component(无 type):沿用绝对值展示
  1436. field = _text(component.get("field"))
  1437. label = _platform_component_label(field)
  1438. detail = _platform_component_raw_text(field, component.get("value"))
  1439. items.append(_score_item(
  1440. label,
  1441. component.get("normalized_score"),
  1442. detail,
  1443. weight=_float_or_none(component.get("weight")),
  1444. group="platform",
  1445. ))
  1446. if fifty is not None and fifty.get("status") == "ok":
  1447. # 50+老年怎么算:TGI 表现(60%)+ 占比表现(40%),取自 fifty_plus_components。
  1448. comps = _record(fifty.get("components"))
  1449. items.append(_score_item(
  1450. "TGI 表现", comps.get("tgi_score"),
  1451. f"50+ 粉丝 TGI≈{_num_text(comps.get('band_tgi'))}(>100 即老年粉丝偏好高于大盘;TGI 200=满分,按此折算得分)",
  1452. weight=0.6, group="fifty",
  1453. ))
  1454. items.append(_score_item(
  1455. "占比表现", comps.get("percent_score"), f"50+ 粉丝占比≈{_num_text(comps.get('band_percent'))}%",
  1456. weight=0.4, group="fifty",
  1457. ))
  1458. return [item for item in items if item]
  1459. def _score_item(
  1460. label: str,
  1461. value: Any,
  1462. detail: str,
  1463. *,
  1464. weight: Any = None,
  1465. group: str | None = None,
  1466. status: Any = None,
  1467. allow_none: bool = False,
  1468. ) -> dict[str, Any]:
  1469. score = _float_or_none(value)
  1470. if score is None and not allow_none:
  1471. return {}
  1472. item: dict[str, Any] = {
  1473. "label": label,
  1474. "score": round(score, 2) if score is not None else None,
  1475. "score_label": f"{round(score, 1):g} 分" if score is not None else "未评估",
  1476. "detail": detail,
  1477. }
  1478. weight_num = _float_or_none(weight)
  1479. if weight_num is not None:
  1480. item["weight"] = round(weight_num, 4)
  1481. if group:
  1482. item["group"] = group
  1483. if status is not None:
  1484. item["status"] = str(status)
  1485. return item
  1486. def _num_text(value: Any) -> str:
  1487. number = _float_or_none(value)
  1488. return f"{round(number):g}" if number is not None else "—"
  1489. def _avg_score_item(label: str, values: list[Any]) -> dict[str, Any]:
  1490. nums = [value for value in (_float_or_none(item) for item in values) if value is not None]
  1491. if not nums:
  1492. return {"label": label, "score": None, "score_label": "暂无评分"}
  1493. avg = sum(nums) / len(nums)
  1494. return {
  1495. "label": label,
  1496. "score": round(avg, 2),
  1497. "score_label": f"平均 {round(avg, 1):g} 分",
  1498. }
  1499. def _platform_component_label(field: str) -> str:
  1500. return {
  1501. "statistics.digg_count": "点赞表现",
  1502. "statistics.like_count": "点赞表现",
  1503. "statistics.comment_count": "评论表现",
  1504. "statistics.share_count": "转发表现",
  1505. "statistics.collect_count": "收藏表现",
  1506. "statistics.play_count": "播放表现",
  1507. }.get(field, field or "互动表现")
  1508. def _platform_component_raw_text(field: str, value: Any) -> str:
  1509. """该视频这项指标的原始量,自然语言(如「该视频原始 1784 赞」)。"""
  1510. number = _num_text(value)
  1511. unit = {
  1512. "statistics.digg_count": "赞",
  1513. "statistics.like_count": "赞",
  1514. "statistics.comment_count": "条评论",
  1515. "statistics.share_count": "次转发",
  1516. "statistics.collect_count": "次收藏",
  1517. "statistics.play_count": "次播放",
  1518. }.get(field)
  1519. return f"该视频原始 {number} {unit}" if unit else f"该视频原始值 {number}"
  1520. def _metric_unit(field: str) -> str:
  1521. """指标字段 → 中文短词(用于 ratio detail,如 转发/赞/播放)。"""
  1522. return {
  1523. "digg_count": "赞",
  1524. "like_count": "赞",
  1525. "share_count": "转发",
  1526. "collect_count": "收藏",
  1527. "comment_count": "评论",
  1528. "play_count": "播放",
  1529. }.get(_text(field).removeprefix("statistics."), _text(field))
  1530. def _ratio_pct(value: Any) -> str:
  1531. num = _float_or_none(value)
  1532. return f"{round(num * 100, 1):g}%" if num is not None else "—"
  1533. def _platform_component_detail(component: dict[str, Any]) -> str:
  1534. """M11 平台分项的展示文案:ratio 出「转发 783 / 赞 1532 = 51% · 收缩后 X% · 目标 27%」;
  1535. absolute 出体量/原始量。"""
  1536. if _text(component.get("type")) == "ratio":
  1537. nu = _metric_unit(component.get("numerator_field"))
  1538. du = _metric_unit(component.get("denominator_field"))
  1539. return (
  1540. f"{nu} {_num_text(component.get('numerator_value'))} / {du} {_num_text(component.get('denominator_value'))}"
  1541. f" = {_ratio_pct(component.get('raw_ratio'))}"
  1542. f" · 收缩后 {_ratio_pct(component.get('shrunk_ratio'))}"
  1543. f" · 目标 {_ratio_pct(component.get('target'))}"
  1544. )
  1545. field = _text(component.get("field"))
  1546. value = component.get("value")
  1547. if field == "total_interaction":
  1548. return f"内容体量(赞+评+转+藏 共 {_num_text(value)})"
  1549. unit = _metric_unit(field)
  1550. return f"该视频原始 {_num_text(value)} {unit}" if unit else f"该视频原始值 {_num_text(value)}"
  1551. def _percent_text(value: Any) -> str:
  1552. number = _float_or_none(value)
  1553. if number is None:
  1554. return "待确认"
  1555. return f"{round(number * 100):g}%"
  1556. def _float_or_none(value: Any) -> float | None:
  1557. try:
  1558. if value is None or value == "":
  1559. return None
  1560. return float(value)
  1561. except (TypeError, ValueError):
  1562. return None
  1563. def _short_text(value: str, limit: int) -> str:
  1564. if len(value) <= limit:
  1565. return value
  1566. return value[: limit - 1] + "…"
  1567. def _platform_label(platform: str) -> str:
  1568. return {
  1569. "douyin": "抖音",
  1570. "tiktok": "TikTok",
  1571. "bilibili": "B 站",
  1572. "kuaishou": "快手",
  1573. "xhs": "小红书",
  1574. }.get(platform.lower(), platform or "平台待确认")
  1575. def _platform_content_url(content: dict[str, Any]) -> str:
  1576. raw = _record(content.get("raw_payload"))
  1577. platform_raw = _record(content.get("platform_raw_payload") or raw.get("platform_raw_payload"))
  1578. direct = _text(
  1579. content.get("platform_content_url")
  1580. or content.get("share_url")
  1581. or content.get("url")
  1582. or content.get("aweme_url")
  1583. or raw.get("platform_content_url")
  1584. or raw.get("share_url")
  1585. or raw.get("url")
  1586. or raw.get("aweme_url")
  1587. )
  1588. if direct:
  1589. return direct
  1590. platform = _text(content.get("platform") or raw.get("platform")).lower()
  1591. content_id = _text(
  1592. content.get("platform_content_id")
  1593. or raw.get("platform_content_id")
  1594. or platform_raw.get("aweme_id")
  1595. )
  1596. if platform == "douyin" and content_id:
  1597. return f"https://www.douyin.com/video/{content_id}"
  1598. return ""
  1599. def _query_source_ref(seed_ref: dict[str, Any], refs: list[dict[str, Any]]) -> dict[str, Any]:
  1600. for ref in refs:
  1601. nested = _record(ref.get("source_ref"))
  1602. if nested:
  1603. return nested
  1604. if ref:
  1605. return ref
  1606. return _record(seed_ref.get("source_ref"))
  1607. def _source_details(
  1608. method: str,
  1609. source_text: str,
  1610. seed_ref: dict[str, Any],
  1611. source_ref: dict[str, Any],
  1612. bundle: dict[str, Any],
  1613. ) -> list[str]:
  1614. if method == "seed_term":
  1615. return _dedupe_texts([
  1616. _detail("需求单号", _demand_id(bundle)),
  1617. _detail("种子词", source_text),
  1618. _detail("来源帖子", _source_post_id(seed_ref, source_ref, bundle)),
  1619. ])
  1620. if method in {"piaoquan_topic_point", "query_seed_point"}:
  1621. point_text = _text(source_ref.get("point_text") or source_ref.get("cleaned_text") or source_text)
  1622. return _dedupe_texts([
  1623. _detail("帖子 ID", source_ref.get("post_id") or _source_post_id(seed_ref, source_ref, bundle)),
  1624. _detail("内容点 ID", source_ref.get("point_id") or source_ref.get("topic_point_id")),
  1625. _detail("内容点类型", source_ref.get("point_type")),
  1626. _detail("内容点", point_text),
  1627. ])
  1628. if method == "category_leaf_element":
  1629. binding = _category_binding(bundle, source_ref)
  1630. category_path = _text(
  1631. source_ref.get("category_path")
  1632. or binding.get("category_full_path")
  1633. or binding.get("category_path")
  1634. )
  1635. category_level = _text(source_ref.get("category_level") or binding.get("category_level")) or _level_from_path(category_path)
  1636. execution_id = _text(
  1637. seed_ref.get("pattern_execution_id")
  1638. or _evidence_pack(bundle).get("pattern_execution_id")
  1639. or _record(bundle.get("pattern_seed_pack")).get("pattern_execution_id")
  1640. )
  1641. category_id = _text(source_ref.get("category_id") or binding.get("category_id"))
  1642. element_name = _text(source_ref.get("element_name") or binding.get("element_name") or source_text)
  1643. return _dedupe_texts([
  1644. f"分类树:Pattern 执行 {execution_id}" if execution_id else "分类树:当前运行记录未写入执行编号",
  1645. _detail("分类路径", category_path),
  1646. f"所在层级:第 {category_level} 层" if category_level else "所在层级:当前运行记录未写入,需要接分类树快照反查",
  1647. _detail("叶子元素", element_name),
  1648. _detail("分类节点", category_id),
  1649. ])
  1650. return _dedupe_texts([_detail("来源", source_text)])
  1651. def _source_ref_label(
  1652. method: str,
  1653. seed_ref: dict[str, Any],
  1654. source_ref: dict[str, Any],
  1655. bundle: dict[str, Any],
  1656. ) -> str:
  1657. if method == "seed_term":
  1658. return _text(_demand_id(bundle) or _source_post_id(seed_ref, source_ref, bundle), "来源编号待确认")
  1659. if method in {"piaoquan_topic_point", "query_seed_point"}:
  1660. return _text(source_ref.get("post_id") or _source_post_id(seed_ref, source_ref, bundle), "帖子编号待确认")
  1661. if method == "category_leaf_element":
  1662. binding = _category_binding(bundle, source_ref)
  1663. path = _text(source_ref.get("category_path") or binding.get("category_full_path") or binding.get("category_path"))
  1664. return path or _text(source_ref.get("category_id") or binding.get("category_id"), "分类节点待确认")
  1665. return _text(seed_ref.get("source_post_id") or seed_ref.get("pattern_execution_id"), "来源编号待确认")
  1666. def _demand_id(bundle: dict[str, Any]) -> str:
  1667. source_context = _record(bundle.get("source_context"))
  1668. return _text(source_context.get("demand_content_id") or source_context.get("id") or _evidence_pack(bundle).get("demand_content_id"))
  1669. def _demand_summary(bundle: dict[str, Any]) -> dict[str, Any]:
  1670. source_context = _record(bundle.get("source_context"))
  1671. raw = _record(source_context.get("raw_demand_content")) or source_context
  1672. ext_data = _record(raw.get("ext_data")) or _record(source_context.get("ext_data"))
  1673. evidence = _evidence_pack(bundle)
  1674. category_paths = _dedupe_texts([
  1675. _text(item.get("category_full_path") or item.get("category_path"))
  1676. for item in _list(evidence.get("category_bindings") or evidence.get("itemset_items"))
  1677. ])
  1678. extracted_points: list[str] = []
  1679. for binding in _list(evidence.get("element_bindings")):
  1680. for sample in _list(_record(binding).get("sample_elements")):
  1681. sample_record = _record(sample)
  1682. point_text = _text(sample_record.get("point_text") or sample_record.get("name"))
  1683. if not point_text:
  1684. continue
  1685. post_id = _text(sample_record.get("post_id"))
  1686. point_type = _text(sample_record.get("point_type"))
  1687. suffix = " / ".join(item for item in [point_type, f"帖子 {post_id}" if post_id else ""] if item)
  1688. extracted_points.append(f"{point_text}({suffix})" if suffix else point_text)
  1689. # 分类叶子来源的候选搜索词(element_bindings 元素名)。前端用它对比首轮 query,
  1690. # 讲清"分类来源的某词与前面哪条来源的同名词重复、被去重未单独显示"。
  1691. category_leaf_terms: list[str] = []
  1692. for binding in _list(evidence.get("element_bindings")):
  1693. for sample in _list(_record(binding).get("sample_elements")):
  1694. name = _text(_record(sample).get("name"))
  1695. if name:
  1696. category_leaf_terms.append(name)
  1697. return {
  1698. "id": _demand_id(bundle),
  1699. "name": _text(raw.get("name") or source_context.get("name")),
  1700. "description": _text(ext_data.get("desc") or raw.get("suggestion") or source_context.get("suggestion")),
  1701. "reason": _text(ext_data.get("reason") or raw.get("reason") or source_context.get("reason")),
  1702. "source": _text(raw.get("merge_leve2") or source_context.get("merge_leve2")),
  1703. "score": raw.get("score") if raw.get("score") is not None else source_context.get("score"),
  1704. "date": _text(raw.get("dt") or source_context.get("dt")),
  1705. "type": _text(ext_data.get("type") or evidence.get("source_kind")),
  1706. "itemset_ids": _text_list(evidence.get("itemset_ids")),
  1707. "seed_terms": _text_list(evidence.get("seed_terms")),
  1708. "source_post_id": _text(evidence.get("source_post_id")),
  1709. "support": evidence.get("absolute_support") if evidence.get("absolute_support") is not None else evidence.get("support"),
  1710. "pattern_execution_id": _text(evidence.get("pattern_execution_id")),
  1711. "mining_config_id": _text(evidence.get("mining_config_id")),
  1712. "category_paths": category_paths,
  1713. "extracted_points": _dedupe_texts(extracted_points),
  1714. "category_leaf_terms": _dedupe_texts(category_leaf_terms),
  1715. }
  1716. def _source_post_id(seed_ref: dict[str, Any], source_ref: dict[str, Any], bundle: dict[str, Any]) -> str:
  1717. return _text(
  1718. source_ref.get("post_id")
  1719. or seed_ref.get("source_post_id")
  1720. or _evidence_pack(bundle).get("source_post_id")
  1721. or _record(bundle.get("pattern_seed_pack")).get("source_post_id")
  1722. )
  1723. def _evidence_pack(bundle: dict[str, Any]) -> dict[str, Any]:
  1724. source_context = _record(bundle.get("source_context"))
  1725. ext_data = _record(source_context.get("ext_data"))
  1726. return _record(ext_data.get("evidence_pack") or source_context.get("evidence_pack"))
  1727. def _category_binding(bundle: dict[str, Any], source_ref: dict[str, Any]) -> dict[str, Any]:
  1728. category_id = _text(source_ref.get("category_id"))
  1729. itemset_item_id = _text(source_ref.get("itemset_item_id"))
  1730. element_name = _text(source_ref.get("element_name"))
  1731. evidence = _evidence_pack(bundle)
  1732. for key in ("category_bindings", "itemset_items"):
  1733. matched = _find_category_record(_list(evidence.get(key)), category_id, itemset_item_id, element_name)
  1734. if matched:
  1735. return matched
  1736. seed_pack = _record(bundle.get("pattern_seed_pack"))
  1737. matched = _find_category_record(_list(seed_pack.get("category_bindings")), category_id, itemset_item_id, element_name)
  1738. if matched:
  1739. return matched
  1740. for binding in _list(seed_pack.get("element_bindings")) + _list(evidence.get("element_bindings")):
  1741. matched = _find_category_record([binding], category_id, itemset_item_id, element_name)
  1742. if matched:
  1743. return binding
  1744. for sample in _list(binding.get("sample_elements")):
  1745. matched = _find_category_record([sample], category_id, "", element_name)
  1746. if matched:
  1747. sample_with_binding = dict(sample)
  1748. sample_with_binding.setdefault("itemset_item_id", binding.get("itemset_item_id"))
  1749. sample_with_binding.setdefault("category_path", binding.get("category_path"))
  1750. return sample_with_binding
  1751. return {}
  1752. def _find_category_record(
  1753. rows: list[dict[str, Any]],
  1754. category_id: str,
  1755. itemset_item_id: str,
  1756. element_name: str,
  1757. ) -> dict[str, Any]:
  1758. for row in rows:
  1759. if itemset_item_id and _text(row.get("itemset_item_id")) == itemset_item_id:
  1760. return row
  1761. if category_id and _text(row.get("category_id")) == category_id:
  1762. return row
  1763. if element_name and _text(row.get("element_name") or row.get("name")) == element_name:
  1764. return row
  1765. return {}
  1766. def _level_from_path(path: str) -> str:
  1767. parts = [part for part in path.split("/") if part.strip()]
  1768. return str(len(parts)) if parts else ""
  1769. def _detail(label: str, value: Any) -> str:
  1770. text = _text(value)
  1771. return f"{label}:{text}" if text else ""
  1772. def _dedupe_texts(values: list[str]) -> list[str]:
  1773. result: list[str] = []
  1774. seen: set[str] = set()
  1775. for value in values:
  1776. text = _text(value)
  1777. if text and text not in seen:
  1778. seen.add(text)
  1779. result.append(text)
  1780. return result
  1781. def _text(value: Any, default: str = "") -> str:
  1782. if value is None:
  1783. return default
  1784. text = str(value).strip()
  1785. return text or default
  1786. def _text_list(value: Any) -> list[str]:
  1787. if isinstance(value, list):
  1788. return [_text(item) for item in value if _text(item)]
  1789. if isinstance(value, str) and value.strip():
  1790. return [value.strip()]
  1791. return []
  1792. def _record(value: Any) -> dict[str, Any]:
  1793. return value if isinstance(value, dict) else {}
  1794. def _list(value: Any) -> list[dict[str, Any]]:
  1795. return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
  1796. def _number(value: Any) -> int:
  1797. try:
  1798. return int(float(value))
  1799. except (TypeError, ValueError):
  1800. return 0
  1801. def _cache_ttl_seconds() -> int:
  1802. raw = os.environ.get("CONTENT_AGENT_FLOW_LEDGER_CACHE_TTL_SECONDS")
  1803. if raw is None:
  1804. return _DEFAULT_CACHE_TTL_SECONDS
  1805. try:
  1806. return max(0, int(raw))
  1807. except ValueError:
  1808. return _DEFAULT_CACHE_TTL_SECONDS
  1809. def _cache_get(key: tuple[str, str]) -> dict[str, Any] | None:
  1810. ttl = _cache_ttl_seconds()
  1811. if ttl <= 0:
  1812. return None
  1813. cached = _FLOW_LEDGER_CACHE.get(key)
  1814. if not cached:
  1815. return None
  1816. expires_at, payload = cached
  1817. if expires_at <= time.monotonic():
  1818. _FLOW_LEDGER_CACHE.pop(key, None)
  1819. return None
  1820. return copy.deepcopy(payload)
  1821. def _cache_set(key: tuple[str, str], payload: dict[str, Any]) -> None:
  1822. ttl = _cache_ttl_seconds()
  1823. if ttl <= 0:
  1824. return
  1825. _FLOW_LEDGER_CACHE[key] = (time.monotonic() + ttl, copy.deepcopy(payload))