video_discovery_gates.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  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. if _can_compensate_temporal_unknown(candidate, text=text):
  252. return {
  253. "temporal_type": inferred_type or "evergreen",
  254. "status": "pass",
  255. "reason_code": None,
  256. "reason": "缺少发布时间,但无时效敏感表述且其他指标支持通过",
  257. "evidence": {**evidence, "compensated": True},
  258. }
  259. return {
  260. "temporal_type": inferred_type,
  261. "status": "unknown",
  262. "reason_code": "TEMPORAL_UNKNOWN",
  263. "reason": "缺少真实发布时间,无法完成时效校验",
  264. "evidence": evidence,
  265. }
  266. for daypart, terms, start, end in _DAYPART_RULES:
  267. matched = [term for term in terms if term in text]
  268. if not matched:
  269. continue
  270. inferred_type = "daypart"
  271. evidence["matched_terms"].extend(matched)
  272. evidence["valid_time"] = (
  273. f"{start.strftime('%H:%M')}-{end.strftime('%H:%M')}"
  274. )
  275. if not _time_in_window(current.timetz().replace(tzinfo=None), start, end):
  276. return {
  277. "temporal_type": inferred_type,
  278. "status": "fail",
  279. "reason_code": "DAYPART_EXPIRED",
  280. "reason": f"当前时间不在{daypart}内容有效时段",
  281. "evidence": evidence,
  282. }
  283. matched_festivals: list[tuple[str, date]] = []
  284. known_names = set(_FIXED_FESTIVALS)
  285. for yearly in _LUNAR_FESTIVALS.values():
  286. known_names.update(yearly)
  287. for name in sorted(known_names, key=len, reverse=True):
  288. if name not in text:
  289. continue
  290. festival_date = _nearest_festival_date(name, current.date())
  291. if festival_date is None:
  292. return {
  293. "temporal_type": "festival",
  294. "status": "unknown",
  295. "reason_code": "TEMPORAL_UNKNOWN",
  296. "reason": f"规则版本未覆盖当前相邻年份的{name}日期",
  297. "evidence": {**evidence, "matched_terms": [name]},
  298. }
  299. matched_festivals.append((name, festival_date))
  300. break
  301. if matched_festivals:
  302. inferred_type = "festival"
  303. name, festival_date = matched_festivals[0]
  304. lead_days = int(rules["festival_lead_days"])
  305. valid_from = festival_date - timedelta(days=lead_days)
  306. valid_to = festival_date + timedelta(days=1)
  307. evidence.update(
  308. {
  309. "matched_terms": [name],
  310. "valid_from": valid_from.isoformat(),
  311. "valid_to": valid_to.isoformat(),
  312. }
  313. )
  314. if not valid_from <= current.date() <= valid_to:
  315. return {
  316. "temporal_type": inferred_type,
  317. "status": "fail",
  318. "reason_code": "FESTIVAL_OUT_OF_WINDOW",
  319. "reason": f"{name}内容不在当前有效窗口",
  320. "evidence": evidence,
  321. }
  322. matched_relative = [term for term in _RELATIVE_DATE_MARKERS if term in text]
  323. if matched_relative and publish_at.date() != current.date():
  324. evidence["matched_terms"].extend(matched_relative)
  325. return {
  326. "temporal_type": "event",
  327. "status": "fail",
  328. "reason_code": "RELATIVE_DATE_EXPIRED",
  329. "reason": "内容包含相对日期表述,但并非当天发布",
  330. "evidence": evidence,
  331. }
  332. if any(marker in text for marker in _EVENT_MARKERS):
  333. inferred_type = "event"
  334. age_days = max(0.0, (current - publish_at).total_seconds() / 86400)
  335. evidence["content_age_days"] = round(age_days, 3)
  336. if inferred_type == "event" and age_days > int(rules["event_max_age_days"]):
  337. return {
  338. "temporal_type": inferred_type,
  339. "status": "fail",
  340. "reason_code": "EVENT_EXPIRED",
  341. "reason": "事件型内容超过允许的新鲜度窗口",
  342. "evidence": evidence,
  343. }
  344. if (
  345. inferred_type == "seasonal"
  346. and age_days > int(rules["seasonal_max_age_days"])
  347. ):
  348. return {
  349. "temporal_type": inferred_type,
  350. "status": "fail",
  351. "reason_code": "SEASONAL_EXPIRED",
  352. "reason": "季节型内容超过允许的新鲜度窗口",
  353. "evidence": evidence,
  354. }
  355. explicit_status = str(candidate.get("temporal_status") or "").strip()
  356. if explicit_status in {"fail", "unknown"}:
  357. reason_code = (
  358. "TEMPORAL_EXPIRED" if explicit_status == "fail" else "TEMPORAL_UNKNOWN"
  359. )
  360. return {
  361. "temporal_type": inferred_type,
  362. "status": explicit_status,
  363. "reason_code": reason_code,
  364. "reason": "候选补证结果未通过时间有效性判断",
  365. "evidence": evidence,
  366. }
  367. return {
  368. "temporal_type": inferred_type,
  369. "status": "pass",
  370. "reason_code": None,
  371. "reason": "发布时间与基础时间语义有效",
  372. "evidence": evidence,
  373. }
  374. def _number(value: Any) -> float | None:
  375. if value in (None, "") or isinstance(value, bool):
  376. return None
  377. try:
  378. return float(value)
  379. except (TypeError, ValueError):
  380. return None
  381. def _semantic_score(candidate: Mapping[str, Any], field: str) -> float | None:
  382. return _number(candidate.get(field))
  383. def _has_strong_semantic_scores(candidate: Mapping[str, Any]) -> bool:
  384. """R/E/S 综合优秀,或 V 足够高,可用于缺失字段补偿。"""
  385. relevance = _semantic_score(candidate, "relevance_score")
  386. elder = _semantic_score(candidate, "elder_score")
  387. share = _semantic_score(candidate, "share_score")
  388. value = _semantic_score(candidate, "value_score")
  389. if (
  390. relevance is not None
  391. and elder is not None
  392. and share is not None
  393. and relevance >= 0.70
  394. and elder >= 0.70
  395. and share >= 0.65
  396. ):
  397. return True
  398. return value is not None and value >= 0.65
  399. def _text_has_temporal_markers(text: str) -> bool:
  400. for _, terms, _, _ in _DAYPART_RULES:
  401. if any(term in text for term in terms):
  402. return True
  403. known_names = set(_FIXED_FESTIVALS)
  404. for yearly in _LUNAR_FESTIVALS.values():
  405. known_names.update(yearly)
  406. if any(name in text for name in known_names):
  407. return True
  408. if any(marker in text for marker in _EVENT_MARKERS + _RELATIVE_DATE_MARKERS):
  409. return True
  410. return False
  411. def _can_compensate_missing_portrait(
  412. candidate: Mapping[str, Any],
  413. account_ratio: float | None,
  414. *,
  415. min_account_ratio: float,
  416. ) -> bool:
  417. if account_ratio is not None and account_ratio >= max(min_account_ratio, 0.35):
  418. return True
  419. if account_ratio is not None and account_ratio >= min_account_ratio:
  420. return _has_strong_semantic_scores(candidate)
  421. elder = _semantic_score(candidate, "elder_score")
  422. return elder is not None and elder >= 0.75 and _has_strong_semantic_scores(candidate)
  423. def _can_compensate_missing_duration(
  424. candidate: Mapping[str, Any],
  425. shares: float | None,
  426. *,
  427. min_share_count: float,
  428. ) -> bool:
  429. if shares is not None and shares >= min_share_count * 1.5:
  430. return True
  431. relevance = _semantic_score(candidate, "relevance_score")
  432. return (
  433. relevance is not None
  434. and relevance >= 0.80
  435. and _has_strong_semantic_scores(candidate)
  436. )
  437. def _can_compensate_missing_shares(candidate: Mapping[str, Any]) -> bool:
  438. likes = _number(candidate.get("like_count"))
  439. plays = _number(candidate.get("play_count"))
  440. if likes is not None and likes >= 5000:
  441. return True
  442. if plays is not None and plays >= 50000:
  443. return True
  444. share = _semantic_score(candidate, "share_score")
  445. return share is not None and share >= 0.75 and _has_strong_semantic_scores(candidate)
  446. def _can_compensate_temporal_unknown(
  447. candidate: Mapping[str, Any],
  448. *,
  449. text: str,
  450. ) -> bool:
  451. if _text_has_temporal_markers(text):
  452. return False
  453. if _has_strong_semantic_scores(candidate):
  454. return True
  455. shares = _number(candidate.get("share_count"))
  456. return shares is not None and shares >= 2000
  457. def evaluate_candidate_gate(
  458. candidate: Mapping[str, Any],
  459. rule_snapshot: Mapping[str, Any] | str | None,
  460. ) -> dict[str, Any]:
  461. rules = load_rule_snapshot(rule_snapshot)
  462. temporal = evaluate_temporal_status(candidate, rules)
  463. duration = _number(candidate.get("duration_seconds"))
  464. shares = _number(candidate.get("share_count"))
  465. content_ratio = _number(candidate.get("content_50_plus_ratio"))
  466. account_ratio = _number(candidate.get("account_50_plus_ratio"))
  467. checks: list[dict[str, Any]] = [
  468. {
  469. "name": "temporal",
  470. "status": temporal["status"],
  471. "reason_code": temporal["reason_code"],
  472. "actual": temporal["evidence"],
  473. }
  474. ]
  475. min_duration = float(rules["min_duration_seconds"])
  476. min_shares = float(rules["min_share_count"])
  477. min_content_ratio = float(rules["min_content_50_plus_ratio"])
  478. min_account_ratio = float(rules["min_account_50_plus_ratio"])
  479. def threshold_check(
  480. name: str,
  481. actual: float | None,
  482. threshold: float,
  483. *,
  484. missing_code: str,
  485. low_code: str,
  486. compensate_missing: bool = False,
  487. ) -> None:
  488. compensated = False
  489. if actual is None:
  490. if compensate_missing:
  491. status = "pass"
  492. reason_code = None
  493. compensated = True
  494. else:
  495. status = "fail"
  496. reason_code = missing_code
  497. elif actual < threshold:
  498. status = "fail"
  499. reason_code = low_code
  500. else:
  501. status = "pass"
  502. reason_code = None
  503. checks.append(
  504. {
  505. "name": name,
  506. "status": status,
  507. "reason_code": reason_code,
  508. "actual": actual,
  509. "threshold": threshold,
  510. "compensated": compensated,
  511. }
  512. )
  513. threshold_check(
  514. "duration_seconds",
  515. duration,
  516. min_duration,
  517. missing_code="DURATION_UNKNOWN",
  518. low_code="DURATION_TOO_SHORT",
  519. compensate_missing=_can_compensate_missing_duration(
  520. candidate,
  521. shares,
  522. min_share_count=min_shares,
  523. ),
  524. )
  525. threshold_check(
  526. "share_count",
  527. shares,
  528. min_shares,
  529. missing_code="SHARE_COUNT_UNKNOWN",
  530. low_code="SHARE_COUNT_TOO_LOW",
  531. compensate_missing=_can_compensate_missing_shares(candidate),
  532. )
  533. threshold_check(
  534. "content_50_plus_ratio",
  535. content_ratio,
  536. min_content_ratio,
  537. missing_code="CONTENT_PORTRAIT_MISSING",
  538. low_code="CONTENT_50_PLUS_TOO_LOW",
  539. compensate_missing=_can_compensate_missing_portrait(
  540. candidate,
  541. account_ratio,
  542. min_account_ratio=min_account_ratio,
  543. ),
  544. )
  545. failed_codes = [
  546. str(check["reason_code"])
  547. for check in checks
  548. if check["status"] != "pass" and check.get("reason_code")
  549. ]
  550. content_status = (
  551. "missing"
  552. if content_ratio is None
  553. else (
  554. "pass"
  555. if content_ratio >= float(rules["min_content_50_plus_ratio"])
  556. else "fail"
  557. )
  558. )
  559. account_status = (
  560. "missing"
  561. if account_ratio is None
  562. else (
  563. "pass"
  564. if account_ratio >= float(rules["min_account_50_plus_ratio"])
  565. else "fail"
  566. )
  567. )
  568. portrait_conflict = (
  569. content_status in {"pass", "fail"}
  570. and account_status in {"pass", "fail"}
  571. and content_status != account_status
  572. )
  573. return {
  574. "rule_version": str(rules["rule_version"]),
  575. "status": "pass" if not failed_codes else "fail",
  576. "primary_eligible": not failed_codes,
  577. "failed_reason_codes": failed_codes,
  578. "checks": checks,
  579. "temporal": temporal,
  580. "content_portrait_status": content_status,
  581. "account_portrait_status": account_status,
  582. "portrait_conflict": portrait_conflict,
  583. }