video_discovery_gates.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  1. """find_agent P0 候选硬门槛与基础时效规则。"""
  2. from __future__ import annotations
  3. import json
  4. import re
  5. from dataclasses import asdict, dataclass
  6. from datetime import date, datetime, time, timedelta
  7. from decimal import Decimal
  8. from typing import Any, Mapping
  9. from zoneinfo import ZoneInfo
  10. from supply_infra.config import get_infra_settings
  11. @dataclass(frozen=True)
  12. class FindAgentGateRules:
  13. rule_version: str
  14. timezone: str
  15. min_duration_seconds: int
  16. min_share_count: int
  17. min_content_50_plus_ratio: float
  18. min_account_50_plus_ratio: float
  19. festival_lead_days: int
  20. event_max_age_days: int
  21. seasonal_max_age_days: int
  22. _DAYPART_RULES: tuple[tuple[str, tuple[str, ...], time, time], ...] = (
  23. ("morning", ("早上好", "早安", "晨安", "早晨好"), time(5, 0), time(10, 30)),
  24. ("noon", ("午安", "中午好", "午间好"), time(11, 0), time(14, 0)),
  25. ("evening", ("晚上好", "晚安", "夜安"), time(18, 0), time(1, 0)),
  26. )
  27. _FIXED_FESTIVALS: dict[str, tuple[int, int]] = {
  28. "元旦": (1, 1),
  29. "情人节": (2, 14),
  30. "妇女节": (3, 8),
  31. "劳动节": (5, 1),
  32. "儿童节": (6, 1),
  33. "建党节": (7, 1),
  34. "建军节": (8, 1),
  35. "教师节": (9, 10),
  36. "国庆": (10, 1),
  37. "国庆节": (10, 1),
  38. "圣诞": (12, 25),
  39. "圣诞节": (12, 25),
  40. }
  41. # 业务运行期内使用的农历节日阳历日期。超出覆盖年份时返回 unknown,不猜测日期。
  42. _LUNAR_FESTIVALS: dict[int, dict[str, tuple[int, int]]] = {
  43. 2025: {
  44. "春节": (1, 29),
  45. "元宵": (2, 12),
  46. "元宵节": (2, 12),
  47. "端午": (5, 31),
  48. "端午节": (5, 31),
  49. "七夕": (8, 29),
  50. "中秋": (10, 6),
  51. "中秋节": (10, 6),
  52. "重阳": (10, 29),
  53. "重阳节": (10, 29),
  54. },
  55. 2026: {
  56. "春节": (2, 17),
  57. "元宵": (3, 3),
  58. "元宵节": (3, 3),
  59. "端午": (6, 19),
  60. "端午节": (6, 19),
  61. "七夕": (8, 19),
  62. "中秋": (9, 25),
  63. "中秋节": (9, 25),
  64. "重阳": (10, 18),
  65. "重阳节": (10, 18),
  66. },
  67. 2027: {
  68. "春节": (2, 6),
  69. "元宵": (2, 20),
  70. "元宵节": (2, 20),
  71. "端午": (6, 9),
  72. "端午节": (6, 9),
  73. "七夕": (8, 8),
  74. "中秋": (9, 15),
  75. "中秋节": (9, 15),
  76. "重阳": (10, 8),
  77. "重阳节": (10, 8),
  78. },
  79. }
  80. _EVENT_MARKERS = (
  81. "最新消息",
  82. "突发",
  83. "刚刚",
  84. "今日新闻",
  85. "赛事结果",
  86. "比赛结果",
  87. )
  88. _RELATIVE_DATE_MARKERS = ("今天", "今日", "明天", "昨日", "昨天")
  89. def get_gate_rules() -> FindAgentGateRules:
  90. settings = get_infra_settings()
  91. return FindAgentGateRules(
  92. rule_version=settings.find_agent_rule_version,
  93. timezone=settings.scheduler_timezone,
  94. min_duration_seconds=settings.find_agent_min_duration_seconds,
  95. min_share_count=settings.find_agent_min_share_count,
  96. min_content_50_plus_ratio=settings.find_agent_min_content_50_plus_ratio,
  97. min_account_50_plus_ratio=settings.find_agent_min_account_50_plus_ratio,
  98. festival_lead_days=settings.find_agent_festival_lead_days,
  99. event_max_age_days=settings.find_agent_event_max_age_days,
  100. seasonal_max_age_days=settings.find_agent_seasonal_max_age_days,
  101. )
  102. def build_rule_snapshot(now: datetime | None = None) -> dict[str, Any]:
  103. rules = get_gate_rules()
  104. timezone = ZoneInfo(rules.timezone)
  105. evaluated_at = now or datetime.now(timezone)
  106. if evaluated_at.tzinfo is None:
  107. evaluated_at = evaluated_at.replace(tzinfo=timezone)
  108. else:
  109. evaluated_at = evaluated_at.astimezone(timezone)
  110. return {
  111. **asdict(rules),
  112. "current_datetime": evaluated_at.isoformat(timespec="seconds"),
  113. "current_date": evaluated_at.strftime("%Y-%m-%d"),
  114. }
  115. def load_rule_snapshot(value: Mapping[str, Any] | str | None) -> dict[str, Any]:
  116. if isinstance(value, str):
  117. try:
  118. loaded = json.loads(value)
  119. except (TypeError, ValueError):
  120. loaded = {}
  121. else:
  122. loaded = dict(value or {})
  123. fallback = build_rule_snapshot()
  124. return {**fallback, **loaded}
  125. def parse_datetime_value(
  126. value: Any,
  127. *,
  128. timezone_name: str = "Asia/Shanghai",
  129. ) -> datetime | None:
  130. if value in (None, "") or isinstance(value, bool):
  131. return None
  132. timezone = ZoneInfo(timezone_name)
  133. if isinstance(value, datetime):
  134. parsed = value
  135. elif isinstance(value, date):
  136. parsed = datetime.combine(value, time.min)
  137. elif isinstance(value, (int, float, Decimal)):
  138. timestamp = float(value)
  139. if timestamp > 10_000_000_000:
  140. timestamp /= 1000
  141. try:
  142. parsed = datetime.fromtimestamp(timestamp, timezone)
  143. except (OSError, OverflowError, ValueError):
  144. return None
  145. else:
  146. text = str(value).strip()
  147. if not text:
  148. return None
  149. if re.fullmatch(r"\d{10,13}", text):
  150. return parse_datetime_value(int(text), timezone_name=timezone_name)
  151. normalized = text.replace("Z", "+00:00")
  152. parsed = None
  153. for candidate in (
  154. normalized,
  155. normalized.replace("/", "-"),
  156. ):
  157. try:
  158. parsed = datetime.fromisoformat(candidate)
  159. break
  160. except ValueError:
  161. continue
  162. if parsed is None:
  163. for pattern in ("%Y%m%d", "%Y-%m-%d", "%Y/%m/%d"):
  164. try:
  165. parsed = datetime.strptime(text, pattern)
  166. break
  167. except ValueError:
  168. continue
  169. if parsed is None:
  170. return None
  171. if parsed.tzinfo is None:
  172. return parsed.replace(tzinfo=timezone)
  173. return parsed.astimezone(timezone)
  174. def normalize_duration_seconds(value: Any, *, unit: str = "seconds") -> Decimal | None:
  175. if value in (None, "") or isinstance(value, bool):
  176. return None
  177. try:
  178. number = Decimal(str(value))
  179. except (ArithmeticError, ValueError):
  180. return None
  181. if unit == "milliseconds":
  182. number /= Decimal("1000")
  183. if number < 0:
  184. return None
  185. return number.quantize(Decimal("0.001"))
  186. def _candidate_text(candidate: Mapping[str, Any]) -> str:
  187. fragments = [str(candidate.get("title") or "")]
  188. tags = candidate.get("tags")
  189. if tags is None:
  190. tags = candidate.get("tags_json")
  191. if isinstance(tags, str):
  192. try:
  193. loaded = json.loads(tags)
  194. except (TypeError, ValueError):
  195. loaded = tags
  196. tags = loaded
  197. if isinstance(tags, list):
  198. fragments.extend(str(item) for item in tags)
  199. elif tags:
  200. fragments.append(str(tags))
  201. evidence = candidate.get("temporal_evidence")
  202. if evidence is None:
  203. evidence = candidate.get("temporal_evidence_json")
  204. if evidence:
  205. fragments.append(str(evidence))
  206. return " ".join(fragments)
  207. def _time_in_window(current: time, start: time, end: time) -> bool:
  208. if start <= end:
  209. return start <= current <= end
  210. return current >= start or current <= end
  211. def _festival_dates(year: int) -> dict[str, date]:
  212. result = {
  213. name: date(year, month, day)
  214. for name, (month, day) in _FIXED_FESTIVALS.items()
  215. }
  216. for name, (month, day) in _LUNAR_FESTIVALS.get(year, {}).items():
  217. result[name] = date(year, month, day)
  218. return result
  219. def _nearest_festival_date(name: str, current: date) -> date | None:
  220. candidates = [
  221. festival_date
  222. for year in (current.year - 1, current.year, current.year + 1)
  223. if (festival_date := _festival_dates(year).get(name)) is not None
  224. ]
  225. if not candidates:
  226. return None
  227. return min(candidates, key=lambda value: abs((value - current).days))
  228. def evaluate_temporal_status(
  229. candidate: Mapping[str, Any],
  230. rule_snapshot: Mapping[str, Any] | str | None,
  231. ) -> dict[str, Any]:
  232. rules = load_rule_snapshot(rule_snapshot)
  233. timezone_name = str(rules["timezone"])
  234. timezone = ZoneInfo(timezone_name)
  235. current = parse_datetime_value(
  236. rules.get("current_datetime"),
  237. timezone_name=timezone_name,
  238. ) or datetime.now(timezone)
  239. publish_at = parse_datetime_value(
  240. candidate.get("publish_at"),
  241. timezone_name=timezone_name,
  242. )
  243. text = _candidate_text(candidate)
  244. inferred_type = str(candidate.get("temporal_type") or "").strip() or "evergreen"
  245. evidence: dict[str, Any] = {
  246. "evaluated_at": current.isoformat(timespec="seconds"),
  247. "publish_at": publish_at.isoformat(timespec="seconds") if publish_at else None,
  248. "matched_terms": [],
  249. }
  250. if publish_at is None:
  251. return {
  252. "temporal_type": inferred_type,
  253. "status": "unknown",
  254. "reason_code": "TEMPORAL_UNKNOWN",
  255. "reason": "缺少真实发布时间,无法完成时效校验",
  256. "evidence": evidence,
  257. }
  258. for daypart, terms, start, end in _DAYPART_RULES:
  259. matched = [term for term in terms if term in text]
  260. if not matched:
  261. continue
  262. inferred_type = "daypart"
  263. evidence["matched_terms"].extend(matched)
  264. evidence["valid_time"] = (
  265. f"{start.strftime('%H:%M')}-{end.strftime('%H:%M')}"
  266. )
  267. if not _time_in_window(current.timetz().replace(tzinfo=None), start, end):
  268. return {
  269. "temporal_type": inferred_type,
  270. "status": "fail",
  271. "reason_code": "DAYPART_EXPIRED",
  272. "reason": f"当前时间不在{daypart}内容有效时段",
  273. "evidence": evidence,
  274. }
  275. matched_festivals: list[tuple[str, date]] = []
  276. known_names = set(_FIXED_FESTIVALS)
  277. for yearly in _LUNAR_FESTIVALS.values():
  278. known_names.update(yearly)
  279. for name in sorted(known_names, key=len, reverse=True):
  280. if name not in text:
  281. continue
  282. festival_date = _nearest_festival_date(name, current.date())
  283. if festival_date is None:
  284. return {
  285. "temporal_type": "festival",
  286. "status": "unknown",
  287. "reason_code": "TEMPORAL_UNKNOWN",
  288. "reason": f"规则版本未覆盖当前相邻年份的{name}日期",
  289. "evidence": {**evidence, "matched_terms": [name]},
  290. }
  291. matched_festivals.append((name, festival_date))
  292. break
  293. if matched_festivals:
  294. inferred_type = "festival"
  295. name, festival_date = matched_festivals[0]
  296. lead_days = int(rules["festival_lead_days"])
  297. valid_from = festival_date - timedelta(days=lead_days)
  298. valid_to = festival_date + timedelta(days=1)
  299. evidence.update(
  300. {
  301. "matched_terms": [name],
  302. "valid_from": valid_from.isoformat(),
  303. "valid_to": valid_to.isoformat(),
  304. }
  305. )
  306. if not valid_from <= current.date() <= valid_to:
  307. return {
  308. "temporal_type": inferred_type,
  309. "status": "fail",
  310. "reason_code": "FESTIVAL_OUT_OF_WINDOW",
  311. "reason": f"{name}内容不在当前有效窗口",
  312. "evidence": evidence,
  313. }
  314. matched_relative = [term for term in _RELATIVE_DATE_MARKERS if term in text]
  315. if matched_relative and publish_at.date() != current.date():
  316. evidence["matched_terms"].extend(matched_relative)
  317. return {
  318. "temporal_type": "event",
  319. "status": "fail",
  320. "reason_code": "RELATIVE_DATE_EXPIRED",
  321. "reason": "内容包含相对日期表述,但并非当天发布",
  322. "evidence": evidence,
  323. }
  324. if any(marker in text for marker in _EVENT_MARKERS):
  325. inferred_type = "event"
  326. age_days = max(0.0, (current - publish_at).total_seconds() / 86400)
  327. evidence["content_age_days"] = round(age_days, 3)
  328. if inferred_type == "event" and age_days > int(rules["event_max_age_days"]):
  329. return {
  330. "temporal_type": inferred_type,
  331. "status": "fail",
  332. "reason_code": "EVENT_EXPIRED",
  333. "reason": "事件型内容超过允许的新鲜度窗口",
  334. "evidence": evidence,
  335. }
  336. if (
  337. inferred_type == "seasonal"
  338. and age_days > int(rules["seasonal_max_age_days"])
  339. ):
  340. return {
  341. "temporal_type": inferred_type,
  342. "status": "fail",
  343. "reason_code": "SEASONAL_EXPIRED",
  344. "reason": "季节型内容超过允许的新鲜度窗口",
  345. "evidence": evidence,
  346. }
  347. explicit_status = str(candidate.get("temporal_status") or "").strip()
  348. if explicit_status in {"fail", "unknown"}:
  349. reason_code = (
  350. "TEMPORAL_EXPIRED" if explicit_status == "fail" else "TEMPORAL_UNKNOWN"
  351. )
  352. return {
  353. "temporal_type": inferred_type,
  354. "status": explicit_status,
  355. "reason_code": reason_code,
  356. "reason": "候选补证结果未通过时间有效性判断",
  357. "evidence": evidence,
  358. }
  359. return {
  360. "temporal_type": inferred_type,
  361. "status": "pass",
  362. "reason_code": None,
  363. "reason": "发布时间与基础时间语义有效",
  364. "evidence": evidence,
  365. }
  366. def _number(value: Any) -> float | None:
  367. if value in (None, "") or isinstance(value, bool):
  368. return None
  369. try:
  370. return float(value)
  371. except (TypeError, ValueError):
  372. return None
  373. def evaluate_candidate_gate(
  374. candidate: Mapping[str, Any],
  375. rule_snapshot: Mapping[str, Any] | str | None,
  376. ) -> dict[str, Any]:
  377. rules = load_rule_snapshot(rule_snapshot)
  378. temporal = evaluate_temporal_status(candidate, rules)
  379. duration = _number(candidate.get("duration_seconds"))
  380. shares = _number(candidate.get("share_count"))
  381. content_ratio = _number(candidate.get("content_50_plus_ratio"))
  382. account_ratio = _number(candidate.get("account_50_plus_ratio"))
  383. checks: list[dict[str, Any]] = [
  384. {
  385. "name": "temporal",
  386. "status": temporal["status"],
  387. "reason_code": temporal["reason_code"],
  388. "actual": temporal["evidence"],
  389. }
  390. ]
  391. def threshold_check(
  392. name: str,
  393. actual: float | None,
  394. threshold: float,
  395. *,
  396. missing_code: str,
  397. low_code: str,
  398. ) -> None:
  399. if actual is None:
  400. status = "fail"
  401. reason_code = missing_code
  402. elif actual < threshold:
  403. status = "fail"
  404. reason_code = low_code
  405. else:
  406. status = "pass"
  407. reason_code = None
  408. checks.append(
  409. {
  410. "name": name,
  411. "status": status,
  412. "reason_code": reason_code,
  413. "actual": actual,
  414. "threshold": threshold,
  415. }
  416. )
  417. threshold_check(
  418. "duration_seconds",
  419. duration,
  420. float(rules["min_duration_seconds"]),
  421. missing_code="DURATION_UNKNOWN",
  422. low_code="DURATION_TOO_SHORT",
  423. )
  424. threshold_check(
  425. "share_count",
  426. shares,
  427. float(rules["min_share_count"]),
  428. missing_code="SHARE_COUNT_UNKNOWN",
  429. low_code="SHARE_COUNT_TOO_LOW",
  430. )
  431. threshold_check(
  432. "content_50_plus_ratio",
  433. content_ratio,
  434. float(rules["min_content_50_plus_ratio"]),
  435. missing_code="CONTENT_PORTRAIT_MISSING",
  436. low_code="CONTENT_50_PLUS_TOO_LOW",
  437. )
  438. failed_codes = [
  439. str(check["reason_code"])
  440. for check in checks
  441. if check["status"] != "pass" and check.get("reason_code")
  442. ]
  443. content_status = (
  444. "missing"
  445. if content_ratio is None
  446. else (
  447. "pass"
  448. if content_ratio >= float(rules["min_content_50_plus_ratio"])
  449. else "fail"
  450. )
  451. )
  452. account_status = (
  453. "missing"
  454. if account_ratio is None
  455. else (
  456. "pass"
  457. if account_ratio >= float(rules["min_account_50_plus_ratio"])
  458. else "fail"
  459. )
  460. )
  461. portrait_conflict = (
  462. content_status in {"pass", "fail"}
  463. and account_status in {"pass", "fail"}
  464. and content_status != account_status
  465. )
  466. return {
  467. "rule_version": str(rules["rule_version"]),
  468. "status": "pass" if not failed_codes else "fail",
  469. "primary_eligible": not failed_codes,
  470. "failed_reason_codes": failed_codes,
  471. "checks": checks,
  472. "temporal": temporal,
  473. "content_portrait_status": content_status,
  474. "account_portrait_status": account_status,
  475. "portrait_conflict": portrait_conflict,
  476. }