decision_support.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. """find_agent 的确定性年龄画像标准化与流程审计工具。"""
  2. from __future__ import annotations
  3. import json
  4. import re
  5. from typing import Any
  6. from supply_agent.tools import tool
  7. _ROOT_SOURCE_TYPES = {"demand", "seed", "point", "mixed"}
  8. def _number(value: Any) -> float | None:
  9. if value is None or isinstance(value, bool):
  10. return None
  11. text = str(value).strip().replace("%", "")
  12. if not text:
  13. return None
  14. try:
  15. return float(text)
  16. except ValueError:
  17. return None
  18. def _ratio(value: Any) -> float | None:
  19. number = _number(value)
  20. if number is None:
  21. return None
  22. if number > 1:
  23. number /= 100
  24. return min(max(number, 0.0), 1.0)
  25. def _age_dimension(portrait: dict[str, Any] | None) -> dict[str, Any]:
  26. if not isinstance(portrait, dict):
  27. return {}
  28. for wrapper_key in ("portrait_data", "content", "account"):
  29. wrapped = portrait.get(wrapper_key)
  30. if isinstance(wrapped, dict):
  31. wrapped_dimension = _age_dimension(wrapped)
  32. if wrapped_dimension:
  33. return wrapped_dimension
  34. for key in ("年龄", "age", "Age", "年龄分布"):
  35. value = portrait.get(key)
  36. if isinstance(value, dict):
  37. return value
  38. if portrait and all(isinstance(value, dict) for value in portrait.values()):
  39. return portrait
  40. return {}
  41. def _bucket_kind(label: str) -> str:
  42. compact = (
  43. label.strip()
  44. .lower()
  45. .replace("岁", "")
  46. .replace("以上", "+")
  47. .replace("及", "")
  48. .replace(" ", "")
  49. )
  50. if compact in {"50-", "50+", ">=50", "≥50", "51+", "60+", ">=60", "≥60"}:
  51. return "older"
  52. if re.search(r"(?:^|[^0-9])(50|51|60)\+$", compact):
  53. return "older"
  54. numbers = [int(value) for value in re.findall(r"\d+", compact)]
  55. if not numbers:
  56. return "unknown"
  57. if len(numbers) == 1:
  58. if numbers[0] >= 50 and any(
  59. marker in compact for marker in ("+", ">=", "≥", "以上")
  60. ):
  61. return "older"
  62. return "unknown"
  63. lower, upper = min(numbers), max(numbers)
  64. if lower >= 50:
  65. return "older"
  66. if lower >= 40 or upper >= 50:
  67. return "mature"
  68. return "younger"
  69. def _normalize_portrait(portrait: dict[str, Any] | None) -> dict[str, Any]:
  70. dimension = _age_dimension(portrait)
  71. buckets: list[dict[str, Any]] = []
  72. older_ratio = 0.0
  73. mature_ratio = 0.0
  74. older_tgi_values: list[tuple[float, float]] = []
  75. for label, raw_metrics in dimension.items():
  76. metrics = raw_metrics if isinstance(raw_metrics, dict) else {}
  77. percentage = _ratio(
  78. metrics.get("percentage")
  79. if "percentage" in metrics
  80. else metrics.get("ratio")
  81. )
  82. tgi = _number(
  83. metrics.get("preference")
  84. if "preference" in metrics
  85. else metrics.get("tgi")
  86. )
  87. kind = _bucket_kind(str(label))
  88. buckets.append(
  89. {
  90. "label": str(label),
  91. "kind": kind,
  92. "ratio": percentage,
  93. "tgi": tgi,
  94. }
  95. )
  96. if percentage is None:
  97. continue
  98. if kind == "older":
  99. older_ratio += percentage
  100. if tgi is not None:
  101. older_tgi_values.append((tgi, percentage))
  102. elif kind == "mature":
  103. mature_ratio += percentage
  104. weighted_tgi = None
  105. tgi_weight = sum(weight for _, weight in older_tgi_values)
  106. if tgi_weight:
  107. weighted_tgi = sum(tgi * weight for tgi, weight in older_tgi_values) / tgi_weight
  108. if not dimension:
  109. strength = "missing"
  110. elif (
  111. older_ratio >= 0.45
  112. or (older_ratio >= 0.35 and (weighted_tgi or 0) >= 100)
  113. or (older_ratio >= 0.20 and (weighted_tgi or 0) >= 130)
  114. ):
  115. strength = "strong"
  116. elif (
  117. older_ratio >= 0.20
  118. or (older_ratio > 0 and (weighted_tgi or 0) >= 100)
  119. or mature_ratio >= 0.30
  120. ):
  121. strength = "moderate"
  122. else:
  123. strength = "weak"
  124. return {
  125. "has_age_portrait": bool(dimension),
  126. "older_ratio": round(older_ratio, 6),
  127. "older_tgi": round(weighted_tgi, 4) if weighted_tgi is not None else None,
  128. "mature_ratio": round(mature_ratio, 6),
  129. "strength": strength,
  130. "buckets": buckets,
  131. }
  132. def normalize_age_portrait_pair(
  133. content_portrait: dict[str, Any] | None,
  134. account_portrait: dict[str, Any] | None = None,
  135. ) -> dict[str, Any]:
  136. """Return the deterministic two-sided age normalization payload."""
  137. content = _normalize_portrait(content_portrait)
  138. account = _normalize_portrait(account_portrait)
  139. content_has = content["has_age_portrait"]
  140. account_has = account["has_age_portrait"]
  141. if content_has and account_has:
  142. strong_set = {"strong", "moderate"}
  143. consistency = (
  144. "aligned"
  145. if (content["strength"] in strong_set)
  146. == (account["strength"] in strong_set)
  147. else "conflict"
  148. )
  149. cap = 1.0
  150. elif account_has:
  151. consistency = "account_only"
  152. cap = 0.65
  153. elif content_has:
  154. consistency = "content_only"
  155. cap = 1.0
  156. else:
  157. consistency = "missing"
  158. cap = 0.35
  159. return {
  160. "content": content,
  161. "account": account,
  162. "consistency": consistency,
  163. "elder_score_cap": cap,
  164. }
  165. @tool
  166. def normalize_age_portraits(
  167. content_portrait: dict[str, Any],
  168. account_portrait: dict[str, Any] | None = None,
  169. ) -> str:
  170. """
  171. 标准化视频点赞画像与作者粉丝画像中的年龄桶。
  172. 能识别接口实际返回的 `50-`,以及 `50+ / 50岁以上 / >=50 / 41-50`
  173. 等表达,统一输出直接老年比例、TGI、成熟人群代理比例和证据强度。
  174. 本工具只标准化证据,不把点赞用户伪装成转发用户。
  175. Args:
  176. content_portrait: 视频 portrait_data,或其中的年龄字典。
  177. account_portrait: 可选作者 portrait_data,或其中的年龄字典。
  178. Returns:
  179. JSON,包含 content、account、consistency 和 elder_score_cap。
  180. """
  181. normalized = normalize_age_portrait_pair(content_portrait, account_portrait)
  182. content = normalized["content"]
  183. account = normalized["account"]
  184. consistency = normalized["consistency"]
  185. cap = normalized["elder_score_cap"]
  186. result = {
  187. "title": "年龄画像标准化",
  188. **normalized,
  189. "output": (
  190. f"视频侧={content['strength']},作者侧={account['strength']},"
  191. f"一致性={consistency},E上限={cap}"
  192. ),
  193. }
  194. return json.dumps(result, ensure_ascii=False)
  195. def _float_score(candidate: dict[str, Any], key: str) -> float | None:
  196. value = candidate.get(key)
  197. try:
  198. number = float(value)
  199. except (TypeError, ValueError):
  200. return None
  201. return number if 0 <= number <= 1 else None
  202. @tool
  203. def audit_video_discovery_process(
  204. searches: list[dict[str, Any]],
  205. candidates: list[dict[str, Any]],
  206. intended_status: str = "finished",
  207. ) -> str:
  208. """
  209. 在结束找片前审计搜索树、翻页、标签扩展、证据完备性和双池分流。
  210. Args:
  211. searches: 已执行搜索页。建议包含 search_id、keyword、source_type、
  212. parent_search_id、cursor、page_no、has_more、new_candidate_count。
  213. candidates: 已评估候选。建议包含 aweme_id、decision_bucket、R/E/S 分数、
  214. detail_verified、content_portrait_attempted、account_portrait_attempted、
  215. content_analysis_verified、has_video_url、expansion_worthy_tags。
  216. intended_status: 准备设置的运行状态,通常为 finished。
  217. Returns:
  218. JSON,包含 can_finish、critical_violations、warnings 和 coverage。
  219. """
  220. critical: list[str] = []
  221. warnings: list[str] = []
  222. coverage_violations: list[str] = []
  223. valid_searches = [item for item in searches if isinstance(item, dict)]
  224. valid_candidates = [item for item in candidates if isinstance(item, dict)]
  225. roots = [
  226. item
  227. for item in valid_searches
  228. if item.get("source_type") in _ROOT_SOURCE_TYPES
  229. and not item.get("parent_search_id")
  230. and int(item.get("page_no") or 1) == 1
  231. ]
  232. root_keywords = {
  233. str(item.get("keyword") or "").strip() for item in roots if item.get("keyword")
  234. }
  235. if len(root_keywords) < 2:
  236. coverage_violations.append("独立根搜索词少于2个")
  237. by_parent = {
  238. int(item["parent_search_id"])
  239. for item in valid_searches
  240. if item.get("parent_search_id") is not None
  241. }
  242. keyword_pages = {
  243. (str(item.get("keyword") or ""), int(item.get("page_no") or 1))
  244. for item in valid_searches
  245. }
  246. for item in valid_searches:
  247. page_no = int(item.get("page_no") or 1)
  248. if (
  249. page_no != 1
  250. or not item.get("has_more")
  251. or int(item.get("new_candidate_count") or 0) <= 0
  252. ):
  253. continue
  254. search_id = item.get("search_id")
  255. keyword = str(item.get("keyword") or "")
  256. followed = (
  257. search_id is not None and int(search_id) in by_parent
  258. ) or (keyword, page_no + 1) in keyword_pages
  259. if not followed:
  260. coverage_violations.append(
  261. f"生产性搜索页未翻页: search_id={search_id}, keyword={keyword}"
  262. )
  263. tag_searches = [
  264. item for item in valid_searches if item.get("source_type") == "tag"
  265. ]
  266. worthy_tags = {
  267. str(tag)
  268. for candidate in valid_candidates
  269. for tag in (candidate.get("expansion_worthy_tags") or [])
  270. if str(tag).strip()
  271. }
  272. if worthy_tags and not tag_searches:
  273. coverage_violations.append("存在值得扩展的标签,但没有 tag 搜索分支")
  274. bucket_counts = {
  275. "primary": 0,
  276. "backup": 0,
  277. "rejected": 0,
  278. "pending_evaluation": 0,
  279. }
  280. for candidate in valid_candidates:
  281. aweme_id = str(candidate.get("aweme_id") or "unknown")
  282. bucket = str(
  283. candidate.get("decision_bucket") or "pending_evaluation"
  284. )
  285. if bucket == "unreviewed":
  286. bucket = "pending_evaluation"
  287. bucket_counts[bucket] = bucket_counts.get(bucket, 0) + 1
  288. relevance = _float_score(candidate, "relevance_score")
  289. elder = _float_score(candidate, "elder_score")
  290. share = _float_score(candidate, "share_score")
  291. if bucket in {"primary", "backup"}:
  292. if not candidate.get("detail_verified"):
  293. critical.append(f"{aweme_id} 未核验详情")
  294. if not candidate.get("content_portrait_attempted"):
  295. critical.append(f"{aweme_id} 未尝试视频画像")
  296. if not candidate.get("account_portrait_attempted"):
  297. critical.append(f"{aweme_id} 未尝试作者画像")
  298. if not candidate.get("age_portraits_normalized"):
  299. critical.append(f"{aweme_id} 未标准化年龄画像")
  300. if candidate.get("has_video_url") and not candidate.get(
  301. "content_analysis_verified"
  302. ):
  303. critical.append(f"{aweme_id} 有播放地址但未核验视频内容")
  304. if bucket == "primary" and not (
  305. relevance is not None
  306. and relevance >= 0.55
  307. and elder is not None
  308. and elder >= 0.55
  309. and share is not None
  310. and share >= 0.50
  311. ):
  312. critical.append(f"{aweme_id} 不满足 primary 阈值")
  313. meets_primary = (
  314. relevance is not None
  315. and relevance >= 0.55
  316. and elder is not None
  317. and elder >= 0.55
  318. and share is not None
  319. and share >= 0.50
  320. )
  321. meets_backup = (
  322. not meets_primary
  323. and elder is not None
  324. and elder >= 0.50
  325. and share is not None
  326. and share >= 0.50
  327. )
  328. if bucket == "backup" and not meets_backup:
  329. critical.append(f"{aweme_id} 不满足 backup 阈值")
  330. if bucket == "rejected" and (meets_primary or meets_backup):
  331. critical.append(f"{aweme_id} 达到保留线却被错误淘汰")
  332. if bucket == "pending_evaluation":
  333. warnings.append(f"{aweme_id} 等待 Agent 补证和评估")
  334. retained_count = (
  335. bucket_counts.get("primary", 0)
  336. + bucket_counts.get("backup", 0)
  337. )
  338. if retained_count < 5:
  339. warnings.append(
  340. f"当前保留 {retained_count} 条,低于优先目标 5 条;"
  341. "若仍有高价值搜索前沿应继续探索,候选确实不足时允许结束"
  342. )
  343. if retained_count > 0:
  344. exploration_note = (
  345. "已达到 5 条优先目标"
  346. if retained_count >= 5
  347. else "尚未达到 5 条优先目标;仅在剩余前沿价值较低时允许结束"
  348. )
  349. warnings.extend(
  350. f"{exploration_note};未继续探索: {item}"
  351. for item in coverage_violations
  352. )
  353. else:
  354. critical.extend(coverage_violations)
  355. if not valid_candidates:
  356. warnings.append("没有候选;应确认是搜索无结果而非提前停止")
  357. can_finish = intended_status != "finished" or not critical
  358. result = {
  359. "title": "视频发现流程审计",
  360. "can_finish": can_finish,
  361. "critical_violations": list(dict.fromkeys(critical)),
  362. "warnings": list(dict.fromkeys(warnings)),
  363. "coverage": {
  364. "search_pages": len(valid_searches),
  365. "root_keywords": sorted(root_keywords),
  366. "tag_search_count": len(tag_searches),
  367. "candidate_count": len(valid_candidates),
  368. "retained_candidate_count": retained_count,
  369. "bucket_counts": bucket_counts,
  370. },
  371. "output": (
  372. f"can_finish={can_finish},严重问题 {len(set(critical))} 个,"
  373. f"警告 {len(set(warnings))} 个"
  374. ),
  375. }
  376. return json.dumps(result, ensure_ascii=False)