decision_support.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  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. @tool
  196. def audit_video_discovery_process(
  197. searches: list[dict[str, Any]],
  198. candidates: list[dict[str, Any]],
  199. intended_status: str = "finished",
  200. ) -> str:
  201. """
  202. 在结束找片前审计搜索树、翻页、标签扩展、证据完备性和最终分流。
  203. 不校验 R/E/S/V 分数,也不根据分数质疑 decision_bucket;分数由 Agent 原样保存。
  204. Args:
  205. searches: 已执行搜索页。建议包含 search_id、keyword、source_type、
  206. parent_search_id、cursor、page_no、has_more、new_candidate_count。
  207. candidates: 已评估候选。建议包含 aweme_id、decision_bucket、R/E/S 分数、
  208. detail_verified、content_portrait_attempted、account_portrait_attempted、
  209. age_portraits_normalized、expansion_worthy_tags。
  210. intended_status: 准备设置的运行状态,通常为 finished。
  211. Returns:
  212. JSON,包含 can_finish、critical_violations、warnings 和 coverage。
  213. """
  214. critical: list[str] = []
  215. warnings: list[str] = []
  216. coverage_violations: list[str] = []
  217. valid_searches = [item for item in searches if isinstance(item, dict)]
  218. valid_candidates = [item for item in candidates if isinstance(item, dict)]
  219. roots = [
  220. item
  221. for item in valid_searches
  222. if item.get("source_type") in _ROOT_SOURCE_TYPES
  223. and not item.get("parent_search_id")
  224. and int(item.get("page_no") or 1) == 1
  225. ]
  226. root_keywords = {
  227. str(item.get("keyword") or "").strip() for item in roots if item.get("keyword")
  228. }
  229. if len(root_keywords) < 2:
  230. coverage_violations.append("独立根搜索词少于2个")
  231. by_parent = {
  232. int(item["parent_search_id"])
  233. for item in valid_searches
  234. if item.get("parent_search_id") is not None
  235. }
  236. keyword_pages = {
  237. (str(item.get("keyword") or ""), int(item.get("page_no") or 1))
  238. for item in valid_searches
  239. }
  240. for item in valid_searches:
  241. page_no = int(item.get("page_no") or 1)
  242. if (
  243. page_no != 1
  244. or not item.get("has_more")
  245. or int(item.get("new_candidate_count") or 0) <= 0
  246. ):
  247. continue
  248. search_id = item.get("search_id")
  249. keyword = str(item.get("keyword") or "")
  250. followed = (
  251. search_id is not None and int(search_id) in by_parent
  252. ) or (keyword, page_no + 1) in keyword_pages
  253. if not followed:
  254. coverage_violations.append(
  255. f"生产性搜索页未翻页: search_id={search_id}, keyword={keyword}"
  256. )
  257. tag_searches = [
  258. item for item in valid_searches if item.get("source_type") == "tag"
  259. ]
  260. worthy_tags = {
  261. str(tag)
  262. for candidate in valid_candidates
  263. for tag in (candidate.get("expansion_worthy_tags") or [])
  264. if str(tag).strip()
  265. }
  266. if worthy_tags and not tag_searches:
  267. coverage_violations.append("存在值得扩展的标签,但没有 tag 搜索分支")
  268. bucket_counts = {
  269. "primary": 0,
  270. "rejected": 0,
  271. "pending_evaluation": 0,
  272. }
  273. pending_evaluation_messages: list[str] = []
  274. for candidate in valid_candidates:
  275. aweme_id = str(candidate.get("aweme_id") or "unknown")
  276. bucket = str(
  277. candidate.get("decision_bucket") or "pending_evaluation"
  278. )
  279. if bucket == "unreviewed":
  280. bucket = "pending_evaluation"
  281. if bucket not in bucket_counts:
  282. critical.append(f"{aweme_id} 使用了不支持的分池: {bucket}")
  283. continue
  284. bucket_counts[bucket] += 1
  285. if bucket == "primary":
  286. if not candidate.get("detail_verified"):
  287. critical.append(f"{aweme_id} 未核验详情")
  288. if not candidate.get("content_portrait_attempted"):
  289. critical.append(f"{aweme_id} 未尝试视频画像")
  290. if not candidate.get("account_portrait_attempted"):
  291. critical.append(f"{aweme_id} 未尝试作者画像")
  292. if not candidate.get("age_portraits_normalized"):
  293. critical.append(f"{aweme_id} 未标准化年龄画像")
  294. if bucket == "pending_evaluation":
  295. message = f"{aweme_id} 等待 Agent 补证和评估"
  296. if intended_status == "finished":
  297. pending_evaluation_messages.append(message)
  298. else:
  299. warnings.append(message)
  300. retained_count = bucket_counts.get("primary", 0)
  301. if retained_count >= 5 and intended_status == "finished":
  302. warnings.extend(pending_evaluation_messages)
  303. else:
  304. critical.extend(pending_evaluation_messages)
  305. if retained_count < 5:
  306. warnings.append(
  307. f"当前保留 {retained_count} 条,低于优先目标 5 条;"
  308. "若仍有高价值搜索前沿应继续探索,候选确实不足时允许结束"
  309. )
  310. if retained_count > 0:
  311. exploration_note = (
  312. "已达到 5 条优先目标"
  313. if retained_count >= 5
  314. else "尚未达到 5 条优先目标;仅在剩余前沿价值较低时允许结束"
  315. )
  316. warnings.extend(
  317. f"{exploration_note};未继续探索: {item}"
  318. for item in coverage_violations
  319. )
  320. else:
  321. critical.extend(coverage_violations)
  322. if not valid_candidates:
  323. warnings.append("没有候选;应确认是搜索无结果而非提前停止")
  324. can_finish = intended_status != "finished" or not critical
  325. result = {
  326. "title": "视频发现流程审计",
  327. "can_finish": can_finish,
  328. "critical_violations": list(dict.fromkeys(critical)),
  329. "warnings": list(dict.fromkeys(warnings)),
  330. "coverage": {
  331. "search_pages": len(valid_searches),
  332. "root_keywords": sorted(root_keywords),
  333. "tag_search_count": len(tag_searches),
  334. "candidate_count": len(valid_candidates),
  335. "retained_candidate_count": retained_count,
  336. "bucket_counts": bucket_counts,
  337. },
  338. "output": (
  339. f"can_finish={can_finish},严重问题 {len(set(critical))} 个,"
  340. f"警告 {len(set(warnings))} 个"
  341. ),
  342. }
  343. return json.dumps(result, ensure_ascii=False)