gates.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. """Deterministic candidate gates owned by find_agent_v2."""
  2. from __future__ import annotations
  3. import json
  4. import os
  5. import re
  6. from datetime import date, datetime, time
  7. from decimal import Decimal
  8. from typing import Any, Mapping
  9. from zoneinfo import ZoneInfo
  10. def _env_number(name: str, default: float) -> float:
  11. try:
  12. return float(os.getenv(name, str(default)))
  13. except ValueError:
  14. return default
  15. def build_rule_snapshot(now: datetime | None = None) -> dict[str, Any]:
  16. timezone_name = os.getenv("FIND_AGENT_V2_TIMEZONE", "Asia/Shanghai")
  17. timezone = ZoneInfo(timezone_name)
  18. current = now or datetime.now(timezone)
  19. current = current.replace(tzinfo=timezone) if current.tzinfo is None else current.astimezone(timezone)
  20. return {
  21. "rule_version": os.getenv("FIND_AGENT_V2_RULE_VERSION", "find-agent-v2-gate-v1"),
  22. "timezone": timezone_name,
  23. "min_duration_seconds": int(_env_number("FIND_AGENT_V2_MIN_DURATION_SECONDS", 30)),
  24. "min_share_count": int(_env_number("FIND_AGENT_V2_MIN_SHARE_COUNT", 1000)),
  25. "min_content_50_plus_ratio": _env_number("FIND_AGENT_V2_MIN_CONTENT_50_PLUS_RATIO", 0.20),
  26. "min_account_50_plus_ratio": _env_number("FIND_AGENT_V2_MIN_ACCOUNT_50_PLUS_RATIO", 0.20),
  27. "event_max_age_days": int(_env_number("FIND_AGENT_V2_EVENT_MAX_AGE_DAYS", 7)),
  28. "seasonal_max_age_days": int(_env_number("FIND_AGENT_V2_SEASONAL_MAX_AGE_DAYS", 180)),
  29. "current_datetime": current.isoformat(timespec="seconds"),
  30. "current_date": current.strftime("%Y-%m-%d"),
  31. }
  32. def _rules(value: Mapping[str, Any] | str | None) -> dict[str, Any]:
  33. if isinstance(value, str):
  34. try:
  35. loaded = json.loads(value)
  36. except (TypeError, ValueError):
  37. loaded = {}
  38. else:
  39. loaded = dict(value or {})
  40. return {**build_rule_snapshot(), **loaded}
  41. def parse_datetime_value(value: Any, *, timezone_name: str = "Asia/Shanghai") -> datetime | None:
  42. if value in (None, "") or isinstance(value, bool):
  43. return None
  44. timezone = ZoneInfo(timezone_name)
  45. if isinstance(value, datetime):
  46. parsed = value
  47. elif isinstance(value, date):
  48. parsed = datetime.combine(value, time.min)
  49. elif isinstance(value, (int, float, Decimal)):
  50. number = float(value)
  51. if number > 10_000_000_000:
  52. number /= 1000
  53. try:
  54. parsed = datetime.fromtimestamp(number, timezone)
  55. except (OSError, OverflowError, ValueError):
  56. return None
  57. else:
  58. text = str(value).strip()
  59. if re.fullmatch(r"\d{10,13}", text):
  60. return parse_datetime_value(int(text), timezone_name=timezone_name)
  61. parsed = None
  62. for candidate in (text.replace("Z", "+00:00"), text.replace("/", "-")):
  63. try:
  64. parsed = datetime.fromisoformat(candidate)
  65. break
  66. except ValueError:
  67. continue
  68. if parsed is None:
  69. return None
  70. return parsed.replace(tzinfo=timezone) if parsed.tzinfo is None else parsed.astimezone(timezone)
  71. def _number(value: Any) -> float | None:
  72. if value in (None, "") or isinstance(value, bool):
  73. return None
  74. try:
  75. return float(value)
  76. except (TypeError, ValueError):
  77. return None
  78. def _strong(candidate: Mapping[str, Any]) -> bool:
  79. scores = [_number(candidate.get(key)) for key in ("relevance_score", "elder_score", "share_score")]
  80. value = _number(candidate.get("value_score"))
  81. return value is not None and value >= 0.65 or all(score is not None and score >= floor for score, floor in zip(scores, (0.70, 0.70, 0.65), strict=True))
  82. def _temporal(candidate: Mapping[str, Any], rules: Mapping[str, Any]) -> dict[str, Any]:
  83. timezone_name = str(rules["timezone"])
  84. current = parse_datetime_value(rules.get("current_datetime"), timezone_name=timezone_name) or datetime.now(ZoneInfo(timezone_name))
  85. published = parse_datetime_value(candidate.get("publish_at"), timezone_name=timezone_name)
  86. title = str(candidate.get("title") or "")
  87. temporal_type = str(candidate.get("temporal_type") or "evergreen")
  88. if published is None:
  89. compensated = _strong(candidate) and not any(word in title for word in ("今天", "今日", "明天", "刚刚", "突发"))
  90. return {"status": "pass" if compensated else "unknown", "reason_code": None if compensated else "TEMPORAL_UNKNOWN", "temporal_type": temporal_type, "evidence": {"publish_at": None, "compensated": compensated}}
  91. age_days = max(0.0, (current - published).total_seconds() / 86400)
  92. reason_code = None
  93. if any(word in title for word in ("今天", "今日", "明天", "昨日", "昨天")) and published.date() != current.date():
  94. reason_code = "RELATIVE_DATE_EXPIRED"
  95. elif temporal_type == "event" and age_days > int(rules["event_max_age_days"]):
  96. reason_code = "EVENT_EXPIRED"
  97. elif temporal_type == "seasonal" and age_days > int(rules["seasonal_max_age_days"]):
  98. reason_code = "SEASONAL_EXPIRED"
  99. return {"status": "fail" if reason_code else "pass", "reason_code": reason_code, "temporal_type": temporal_type, "evidence": {"publish_at": published.isoformat(timespec="seconds"), "content_age_days": round(age_days, 3)}}
  100. def evaluate_candidate_gate(candidate: Mapping[str, Any], rule_snapshot: Mapping[str, Any] | str | None) -> dict[str, Any]:
  101. rules = _rules(rule_snapshot)
  102. temporal = _temporal(candidate, rules)
  103. duration = _number(candidate.get("duration_seconds"))
  104. shares = _number(candidate.get("share_count"))
  105. content_ratio = _number(candidate.get("content_50_plus_ratio"))
  106. account_ratio = _number(candidate.get("account_50_plus_ratio"))
  107. checks: list[dict[str, Any]] = [{"name": "temporal", **temporal}]
  108. def threshold(name: str, actual: float | None, minimum: float, missing: str, low: str, compensate: bool = False) -> None:
  109. if actual is None:
  110. status, reason = ("pass", None) if compensate else ("fail", missing)
  111. else:
  112. status, reason = ("pass", None) if actual >= minimum else ("fail", low)
  113. checks.append({"name": name, "status": status, "reason_code": reason, "actual": actual, "threshold": minimum, "compensated": actual is None and compensate})
  114. min_duration = float(rules["min_duration_seconds"])
  115. min_shares = float(rules["min_share_count"])
  116. threshold("duration_seconds", duration, min_duration, "DURATION_UNKNOWN", "DURATION_TOO_SHORT", compensate=_strong(candidate) or shares is not None and shares >= min_shares * 1.5)
  117. likes, plays = _number(candidate.get("like_count")), _number(candidate.get("play_count"))
  118. threshold("share_count", shares, min_shares, "SHARE_COUNT_UNKNOWN", "SHARE_COUNT_TOO_LOW", compensate=_strong(candidate) or bool(likes and likes >= 5000) or bool(plays and plays >= 50000))
  119. min_content = float(rules["min_content_50_plus_ratio"])
  120. min_account = float(rules["min_account_50_plus_ratio"])
  121. portrait_pass = bool(content_ratio is not None and content_ratio >= min_content or account_ratio is not None and account_ratio >= min_account)
  122. portrait_compensated = content_ratio is None and account_ratio is None and _strong(candidate)
  123. checks.append({
  124. "name": "elder_portrait", "status": "pass" if portrait_pass or portrait_compensated else "fail",
  125. "reason_code": None if portrait_pass or portrait_compensated else "CONTENT_PORTRAIT_MISSING" if content_ratio is None and account_ratio is None else "PORTRAIT_50_PLUS_TOO_LOW",
  126. "actual": {"content_50_plus_ratio": content_ratio, "account_50_plus_ratio": account_ratio},
  127. "threshold": {"min_content_50_plus_ratio": min_content, "min_account_50_plus_ratio": min_account},
  128. "compensated": portrait_compensated,
  129. })
  130. failed = [str(check["reason_code"]) for check in checks if check.get("status") != "pass" and check.get("reason_code")]
  131. content_status = "missing" if content_ratio is None else "pass" if content_ratio >= min_content else "fail"
  132. account_status = "missing" if account_ratio is None else "pass" if account_ratio >= min_account else "fail"
  133. return {
  134. "rule_version": str(rules["rule_version"]), "status": "pass" if not failed else "fail",
  135. "primary_eligible": not failed, "failed_reason_codes": failed, "checks": checks,
  136. "temporal": temporal, "content_portrait_status": content_status,
  137. "account_portrait_status": account_status,
  138. "portrait_conflict": content_status in {"pass", "fail"} and account_status in {"pass", "fail"} and content_status != account_status,
  139. }