gates.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  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 evaluate_candidate_gate(candidate: Mapping[str, Any], rule_snapshot: Mapping[str, Any] | str | None) -> dict[str, Any]:
  79. rules = _rules(rule_snapshot)
  80. duration = _number(candidate.get("duration_seconds"))
  81. shares = _number(candidate.get("share_count"))
  82. content_ratio = _number(candidate.get("content_50_plus_ratio"))
  83. account_ratio = _number(candidate.get("account_50_plus_ratio"))
  84. checks: list[dict[str, Any]] = []
  85. def threshold(name: str, actual: float | None, minimum: float, missing: str, low: str) -> None:
  86. if actual is None:
  87. status, reason = "fail", missing
  88. else:
  89. status, reason = ("pass", None) if actual >= minimum else ("fail", low)
  90. checks.append({"name": name, "status": status, "reason_code": reason, "actual": actual, "threshold": minimum, "compensated": False})
  91. min_duration = float(rules["min_duration_seconds"])
  92. min_shares = float(rules["min_share_count"])
  93. threshold("duration_seconds", duration, min_duration, "DURATION_UNKNOWN", "DURATION_TOO_SHORT")
  94. threshold("share_count", shares, min_shares, "SHARE_COUNT_UNKNOWN", "SHARE_COUNT_TOO_LOW")
  95. min_content = float(rules["min_content_50_plus_ratio"])
  96. min_account = float(rules["min_account_50_plus_ratio"])
  97. portrait_pass = bool(content_ratio is not None and content_ratio >= min_content or account_ratio is not None and account_ratio >= min_account)
  98. portrait_compensated = False
  99. checks.append({
  100. "name": "elder_portrait", "status": "pass" if portrait_pass or portrait_compensated else "fail",
  101. "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",
  102. "actual": {"content_50_plus_ratio": content_ratio, "account_50_plus_ratio": account_ratio},
  103. "threshold": {"min_content_50_plus_ratio": min_content, "min_account_50_plus_ratio": min_account},
  104. "compensated": portrait_compensated,
  105. })
  106. failed = [str(check["reason_code"]) for check in checks if check.get("status") != "pass" and check.get("reason_code")]
  107. content_status = "missing" if content_ratio is None else "pass" if content_ratio >= min_content else "fail"
  108. account_status = "missing" if account_ratio is None else "pass" if account_ratio >= min_account else "fail"
  109. return {
  110. "rule_version": str(rules["rule_version"]), "status": "pass" if not failed else "fail",
  111. "primary_eligible": not failed, "failed_reason_codes": failed, "checks": checks,
  112. "content_portrait_status": content_status,
  113. "account_portrait_status": account_status,
  114. "portrait_conflict": content_status in {"pass", "fail"} and account_status in {"pass", "fail"} and content_status != account_status,
  115. }