age_portrait.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. """find_agent 的确定性年龄画像标准化实现。"""
  2. from __future__ import annotations
  3. import json
  4. import re
  5. from typing import Any
  6. def _number(value: Any) -> float | None:
  7. if value is None or isinstance(value, bool):
  8. return None
  9. text = str(value).strip().replace("%", "")
  10. if not text:
  11. return None
  12. try:
  13. return float(text)
  14. except ValueError:
  15. return None
  16. def _ratio(value: Any) -> float | None:
  17. number = _number(value)
  18. if number is None:
  19. return None
  20. if number > 1:
  21. number /= 100
  22. return min(max(number, 0.0), 1.0)
  23. def _age_dimension(portrait: dict[str, Any] | None) -> dict[str, Any]:
  24. if not isinstance(portrait, dict):
  25. return {}
  26. for wrapper_key in ("portrait_data", "content", "account"):
  27. wrapped = portrait.get(wrapper_key)
  28. if isinstance(wrapped, dict):
  29. wrapped_dimension = _age_dimension(wrapped)
  30. if wrapped_dimension:
  31. return wrapped_dimension
  32. for key in ("年龄", "age", "Age", "年龄分布"):
  33. value = portrait.get(key)
  34. if isinstance(value, dict):
  35. return value
  36. if portrait and all(isinstance(value, dict) for value in portrait.values()):
  37. return portrait
  38. return {}
  39. def _bucket_kind(label: str) -> str:
  40. compact = (
  41. label.strip()
  42. .lower()
  43. .replace("岁", "")
  44. .replace("以上", "+")
  45. .replace("及", "")
  46. .replace(" ", "")
  47. )
  48. if compact in {"50-", "50+", ">=50", "≥50", "51+", "60+", ">=60", "≥60"}:
  49. return "older"
  50. if re.search(r"(?:^|[^0-9])(50|51|60)\+$", compact):
  51. return "older"
  52. numbers = [int(value) for value in re.findall(r"\d+", compact)]
  53. if not numbers:
  54. return "unknown"
  55. if len(numbers) == 1:
  56. if numbers[0] >= 50 and any(
  57. marker in compact for marker in ("+", ">=", "≥", "以上")
  58. ):
  59. return "older"
  60. return "unknown"
  61. lower, upper = min(numbers), max(numbers)
  62. if lower >= 50:
  63. return "older"
  64. if lower >= 40 or upper >= 50:
  65. return "mature"
  66. return "younger"
  67. def _normalize_portrait(portrait: dict[str, Any] | None) -> dict[str, Any]:
  68. dimension = _age_dimension(portrait)
  69. buckets: list[dict[str, Any]] = []
  70. older_ratio = 0.0
  71. mature_ratio = 0.0
  72. older_tgi_values: list[tuple[float, float]] = []
  73. for label, raw_metrics in dimension.items():
  74. metrics = raw_metrics if isinstance(raw_metrics, dict) else {}
  75. percentage = _ratio(
  76. metrics.get("percentage")
  77. if "percentage" in metrics
  78. else metrics.get("ratio")
  79. )
  80. tgi = _number(
  81. metrics.get("preference")
  82. if "preference" in metrics
  83. else metrics.get("tgi")
  84. )
  85. kind = _bucket_kind(str(label))
  86. buckets.append(
  87. {
  88. "label": str(label),
  89. "kind": kind,
  90. "ratio": percentage,
  91. "tgi": tgi,
  92. }
  93. )
  94. if percentage is None:
  95. continue
  96. if kind == "older":
  97. older_ratio += percentage
  98. if tgi is not None:
  99. older_tgi_values.append((tgi, percentage))
  100. elif kind == "mature":
  101. mature_ratio += percentage
  102. weighted_tgi = None
  103. tgi_weight = sum(weight for _, weight in older_tgi_values)
  104. if tgi_weight:
  105. weighted_tgi = sum(tgi * weight for tgi, weight in older_tgi_values) / tgi_weight
  106. if not dimension:
  107. strength = "missing"
  108. elif (
  109. older_ratio >= 0.45
  110. or (older_ratio >= 0.35 and (weighted_tgi or 0) >= 100)
  111. or (older_ratio >= 0.20 and (weighted_tgi or 0) >= 130)
  112. ):
  113. strength = "strong"
  114. elif (
  115. older_ratio >= 0.20
  116. or (older_ratio > 0 and (weighted_tgi or 0) >= 100)
  117. or mature_ratio >= 0.30
  118. ):
  119. strength = "moderate"
  120. else:
  121. strength = "weak"
  122. return {
  123. "has_age_portrait": bool(dimension),
  124. "older_ratio": round(older_ratio, 6),
  125. "older_tgi": round(weighted_tgi, 4) if weighted_tgi is not None else None,
  126. "mature_ratio": round(mature_ratio, 6),
  127. "strength": strength,
  128. "buckets": buckets,
  129. }
  130. def normalize_age_portrait_pair(
  131. content_portrait: dict[str, Any] | None,
  132. account_portrait: dict[str, Any] | None = None,
  133. ) -> dict[str, Any]:
  134. """Return the deterministic two-sided age normalization payload."""
  135. content = _normalize_portrait(content_portrait)
  136. account = _normalize_portrait(account_portrait)
  137. content_has = content["has_age_portrait"]
  138. account_has = account["has_age_portrait"]
  139. if content_has and account_has:
  140. strong_set = {"strong", "moderate"}
  141. consistency = (
  142. "aligned"
  143. if (content["strength"] in strong_set)
  144. == (account["strength"] in strong_set)
  145. else "conflict"
  146. )
  147. cap = 1.0
  148. elif account_has:
  149. consistency = "account_only"
  150. cap = 0.65
  151. elif content_has:
  152. consistency = "content_only"
  153. cap = 1.0
  154. else:
  155. consistency = "missing"
  156. cap = 0.35
  157. return {
  158. "content": content,
  159. "account": account,
  160. "consistency": consistency,
  161. "elder_score_cap": cap,
  162. }
  163. def normalize_age_portraits(
  164. content_portrait: dict[str, Any],
  165. account_portrait: dict[str, Any] | None = None,
  166. ) -> str:
  167. """
  168. 标准化视频点赞画像与作者粉丝画像中的年龄桶。
  169. 能识别接口实际返回的 `50-`,以及 `50+ / 50岁以上 / >=50 / 41-50`
  170. 等表达,统一输出直接老年比例、TGI、成熟人群代理比例和证据强度。
  171. 本工具只标准化证据,不把点赞用户伪装成转发用户。
  172. Args:
  173. content_portrait: 视频 portrait_data,或其中的年龄字典。
  174. account_portrait: 可选作者 portrait_data,或其中的年龄字典。
  175. Returns:
  176. JSON,包含 content、account、consistency 和 elder_score_cap。
  177. """
  178. normalized = normalize_age_portrait_pair(content_portrait, account_portrait)
  179. content = normalized["content"]
  180. account = normalized["account"]
  181. consistency = normalized["consistency"]
  182. cap = normalized["elder_score_cap"]
  183. result = {
  184. "title": "年龄画像标准化",
  185. **normalized,
  186. "output": (
  187. f"视频侧={content['strength']},作者侧={account['strength']},"
  188. f"一致性={consistency},E上限={cap}"
  189. ),
  190. }
  191. return json.dumps(result, ensure_ascii=False)