"""find_agent P0 候选硬门槛与基础时效规则。""" from __future__ import annotations import json import re from dataclasses import asdict, dataclass from datetime import date, datetime, time, timedelta from decimal import Decimal from typing import Any, Mapping from zoneinfo import ZoneInfo from supply_infra.config import get_infra_settings @dataclass(frozen=True) class FindAgentGateRules: rule_version: str timezone: str min_duration_seconds: int min_share_count: int min_content_50_plus_ratio: float min_account_50_plus_ratio: float festival_lead_days: int event_max_age_days: int seasonal_max_age_days: int _DAYPART_RULES: tuple[tuple[str, tuple[str, ...], time, time], ...] = ( ("morning", ("早上好", "早安", "晨安", "早晨好"), time(5, 0), time(10, 30)), ("noon", ("午安", "中午好", "午间好"), time(11, 0), time(14, 0)), ("evening", ("晚上好", "晚安", "夜安"), time(18, 0), time(1, 0)), ) _FIXED_FESTIVALS: dict[str, tuple[int, int]] = { "元旦": (1, 1), "情人节": (2, 14), "妇女节": (3, 8), "劳动节": (5, 1), "儿童节": (6, 1), "建党节": (7, 1), "建军节": (8, 1), "教师节": (9, 10), "国庆": (10, 1), "国庆节": (10, 1), "圣诞": (12, 25), "圣诞节": (12, 25), } # 业务运行期内使用的农历节日阳历日期。超出覆盖年份时返回 unknown,不猜测日期。 _LUNAR_FESTIVALS: dict[int, dict[str, tuple[int, int]]] = { 2025: { "春节": (1, 29), "元宵": (2, 12), "元宵节": (2, 12), "端午": (5, 31), "端午节": (5, 31), "七夕": (8, 29), "中秋": (10, 6), "中秋节": (10, 6), "重阳": (10, 29), "重阳节": (10, 29), }, 2026: { "春节": (2, 17), "元宵": (3, 3), "元宵节": (3, 3), "端午": (6, 19), "端午节": (6, 19), "七夕": (8, 19), "中秋": (9, 25), "中秋节": (9, 25), "重阳": (10, 18), "重阳节": (10, 18), }, 2027: { "春节": (2, 6), "元宵": (2, 20), "元宵节": (2, 20), "端午": (6, 9), "端午节": (6, 9), "七夕": (8, 8), "中秋": (9, 15), "中秋节": (9, 15), "重阳": (10, 8), "重阳节": (10, 8), }, } _EVENT_MARKERS = ( "最新消息", "突发", "刚刚", "今日新闻", "赛事结果", "比赛结果", ) _RELATIVE_DATE_MARKERS = ("今天", "今日", "明天", "昨日", "昨天") def get_gate_rules() -> FindAgentGateRules: settings = get_infra_settings() return FindAgentGateRules( rule_version=settings.find_agent_rule_version, timezone=settings.scheduler_timezone, min_duration_seconds=settings.find_agent_min_duration_seconds, min_share_count=settings.find_agent_min_share_count, min_content_50_plus_ratio=settings.find_agent_min_content_50_plus_ratio, min_account_50_plus_ratio=settings.find_agent_min_account_50_plus_ratio, festival_lead_days=settings.find_agent_festival_lead_days, event_max_age_days=settings.find_agent_event_max_age_days, seasonal_max_age_days=settings.find_agent_seasonal_max_age_days, ) def build_rule_snapshot(now: datetime | None = None) -> dict[str, Any]: rules = get_gate_rules() timezone = ZoneInfo(rules.timezone) evaluated_at = now or datetime.now(timezone) if evaluated_at.tzinfo is None: evaluated_at = evaluated_at.replace(tzinfo=timezone) else: evaluated_at = evaluated_at.astimezone(timezone) return { **asdict(rules), "current_datetime": evaluated_at.isoformat(timespec="seconds"), "current_date": evaluated_at.strftime("%Y-%m-%d"), } def load_rule_snapshot(value: Mapping[str, Any] | str | None) -> dict[str, Any]: if isinstance(value, str): try: loaded = json.loads(value) except (TypeError, ValueError): loaded = {} else: loaded = dict(value or {}) fallback = build_rule_snapshot() return {**fallback, **loaded} def parse_datetime_value( value: Any, *, timezone_name: str = "Asia/Shanghai", ) -> datetime | None: if value in (None, "") or isinstance(value, bool): return None timezone = ZoneInfo(timezone_name) if isinstance(value, datetime): parsed = value elif isinstance(value, date): parsed = datetime.combine(value, time.min) elif isinstance(value, (int, float, Decimal)): timestamp = float(value) if timestamp > 10_000_000_000: timestamp /= 1000 try: parsed = datetime.fromtimestamp(timestamp, timezone) except (OSError, OverflowError, ValueError): return None else: text = str(value).strip() if not text: return None if re.fullmatch(r"\d{10,13}", text): return parse_datetime_value(int(text), timezone_name=timezone_name) normalized = text.replace("Z", "+00:00") parsed = None for candidate in ( normalized, normalized.replace("/", "-"), ): try: parsed = datetime.fromisoformat(candidate) break except ValueError: continue if parsed is None: for pattern in ("%Y%m%d", "%Y-%m-%d", "%Y/%m/%d"): try: parsed = datetime.strptime(text, pattern) break except ValueError: continue if parsed is None: return None if parsed.tzinfo is None: return parsed.replace(tzinfo=timezone) return parsed.astimezone(timezone) def normalize_duration_seconds(value: Any, *, unit: str = "seconds") -> Decimal | None: if value in (None, "") or isinstance(value, bool): return None try: number = Decimal(str(value)) except (ArithmeticError, ValueError): return None if unit == "milliseconds": number /= Decimal("1000") if number < 0: return None return number.quantize(Decimal("0.001")) def _candidate_text(candidate: Mapping[str, Any]) -> str: fragments = [str(candidate.get("title") or "")] tags = candidate.get("tags") if tags is None: tags = candidate.get("tags_json") if isinstance(tags, str): try: loaded = json.loads(tags) except (TypeError, ValueError): loaded = tags tags = loaded if isinstance(tags, list): fragments.extend(str(item) for item in tags) elif tags: fragments.append(str(tags)) evidence = candidate.get("temporal_evidence") if evidence is None: evidence = candidate.get("temporal_evidence_json") if evidence: fragments.append(str(evidence)) return " ".join(fragments) def _time_in_window(current: time, start: time, end: time) -> bool: if start <= end: return start <= current <= end return current >= start or current <= end def _festival_dates(year: int) -> dict[str, date]: result = { name: date(year, month, day) for name, (month, day) in _FIXED_FESTIVALS.items() } for name, (month, day) in _LUNAR_FESTIVALS.get(year, {}).items(): result[name] = date(year, month, day) return result def _nearest_festival_date(name: str, current: date) -> date | None: candidates = [ festival_date for year in (current.year - 1, current.year, current.year + 1) if (festival_date := _festival_dates(year).get(name)) is not None ] if not candidates: return None return min(candidates, key=lambda value: abs((value - current).days)) def evaluate_temporal_status( candidate: Mapping[str, Any], rule_snapshot: Mapping[str, Any] | str | None, ) -> dict[str, Any]: rules = load_rule_snapshot(rule_snapshot) timezone_name = str(rules["timezone"]) timezone = ZoneInfo(timezone_name) current = parse_datetime_value( rules.get("current_datetime"), timezone_name=timezone_name, ) or datetime.now(timezone) publish_at = parse_datetime_value( candidate.get("publish_at"), timezone_name=timezone_name, ) text = _candidate_text(candidate) inferred_type = str(candidate.get("temporal_type") or "").strip() or "evergreen" evidence: dict[str, Any] = { "evaluated_at": current.isoformat(timespec="seconds"), "publish_at": publish_at.isoformat(timespec="seconds") if publish_at else None, "matched_terms": [], } if publish_at is None: return { "temporal_type": inferred_type, "status": "unknown", "reason_code": "TEMPORAL_UNKNOWN", "reason": "缺少真实发布时间,无法完成时效校验", "evidence": evidence, } for daypart, terms, start, end in _DAYPART_RULES: matched = [term for term in terms if term in text] if not matched: continue inferred_type = "daypart" evidence["matched_terms"].extend(matched) evidence["valid_time"] = ( f"{start.strftime('%H:%M')}-{end.strftime('%H:%M')}" ) if not _time_in_window(current.timetz().replace(tzinfo=None), start, end): return { "temporal_type": inferred_type, "status": "fail", "reason_code": "DAYPART_EXPIRED", "reason": f"当前时间不在{daypart}内容有效时段", "evidence": evidence, } matched_festivals: list[tuple[str, date]] = [] known_names = set(_FIXED_FESTIVALS) for yearly in _LUNAR_FESTIVALS.values(): known_names.update(yearly) for name in sorted(known_names, key=len, reverse=True): if name not in text: continue festival_date = _nearest_festival_date(name, current.date()) if festival_date is None: return { "temporal_type": "festival", "status": "unknown", "reason_code": "TEMPORAL_UNKNOWN", "reason": f"规则版本未覆盖当前相邻年份的{name}日期", "evidence": {**evidence, "matched_terms": [name]}, } matched_festivals.append((name, festival_date)) break if matched_festivals: inferred_type = "festival" name, festival_date = matched_festivals[0] lead_days = int(rules["festival_lead_days"]) valid_from = festival_date - timedelta(days=lead_days) valid_to = festival_date + timedelta(days=1) evidence.update( { "matched_terms": [name], "valid_from": valid_from.isoformat(), "valid_to": valid_to.isoformat(), } ) if not valid_from <= current.date() <= valid_to: return { "temporal_type": inferred_type, "status": "fail", "reason_code": "FESTIVAL_OUT_OF_WINDOW", "reason": f"{name}内容不在当前有效窗口", "evidence": evidence, } matched_relative = [term for term in _RELATIVE_DATE_MARKERS if term in text] if matched_relative and publish_at.date() != current.date(): evidence["matched_terms"].extend(matched_relative) return { "temporal_type": "event", "status": "fail", "reason_code": "RELATIVE_DATE_EXPIRED", "reason": "内容包含相对日期表述,但并非当天发布", "evidence": evidence, } if any(marker in text for marker in _EVENT_MARKERS): inferred_type = "event" age_days = max(0.0, (current - publish_at).total_seconds() / 86400) evidence["content_age_days"] = round(age_days, 3) if inferred_type == "event" and age_days > int(rules["event_max_age_days"]): return { "temporal_type": inferred_type, "status": "fail", "reason_code": "EVENT_EXPIRED", "reason": "事件型内容超过允许的新鲜度窗口", "evidence": evidence, } if ( inferred_type == "seasonal" and age_days > int(rules["seasonal_max_age_days"]) ): return { "temporal_type": inferred_type, "status": "fail", "reason_code": "SEASONAL_EXPIRED", "reason": "季节型内容超过允许的新鲜度窗口", "evidence": evidence, } explicit_status = str(candidate.get("temporal_status") or "").strip() if explicit_status in {"fail", "unknown"}: reason_code = ( "TEMPORAL_EXPIRED" if explicit_status == "fail" else "TEMPORAL_UNKNOWN" ) return { "temporal_type": inferred_type, "status": explicit_status, "reason_code": reason_code, "reason": "候选补证结果未通过时间有效性判断", "evidence": evidence, } return { "temporal_type": inferred_type, "status": "pass", "reason_code": None, "reason": "发布时间与基础时间语义有效", "evidence": evidence, } def _number(value: Any) -> float | None: if value in (None, "") or isinstance(value, bool): return None try: return float(value) except (TypeError, ValueError): return None def evaluate_candidate_gate( candidate: Mapping[str, Any], rule_snapshot: Mapping[str, Any] | str | None, ) -> dict[str, Any]: rules = load_rule_snapshot(rule_snapshot) temporal = evaluate_temporal_status(candidate, rules) duration = _number(candidate.get("duration_seconds")) shares = _number(candidate.get("share_count")) content_ratio = _number(candidate.get("content_50_plus_ratio")) account_ratio = _number(candidate.get("account_50_plus_ratio")) checks: list[dict[str, Any]] = [ { "name": "temporal", "status": temporal["status"], "reason_code": temporal["reason_code"], "actual": temporal["evidence"], } ] def threshold_check( name: str, actual: float | None, threshold: float, *, missing_code: str, low_code: str, ) -> None: if actual is None: status = "fail" reason_code = missing_code elif actual < threshold: status = "fail" reason_code = low_code else: status = "pass" reason_code = None checks.append( { "name": name, "status": status, "reason_code": reason_code, "actual": actual, "threshold": threshold, } ) threshold_check( "duration_seconds", duration, float(rules["min_duration_seconds"]), missing_code="DURATION_UNKNOWN", low_code="DURATION_TOO_SHORT", ) threshold_check( "share_count", shares, float(rules["min_share_count"]), missing_code="SHARE_COUNT_UNKNOWN", low_code="SHARE_COUNT_TOO_LOW", ) threshold_check( "content_50_plus_ratio", content_ratio, float(rules["min_content_50_plus_ratio"]), missing_code="CONTENT_PORTRAIT_MISSING", low_code="CONTENT_50_PLUS_TOO_LOW", ) failed_codes = [ str(check["reason_code"]) for check in checks if check["status"] != "pass" and check.get("reason_code") ] content_status = ( "missing" if content_ratio is None else ( "pass" if content_ratio >= float(rules["min_content_50_plus_ratio"]) else "fail" ) ) account_status = ( "missing" if account_ratio is None else ( "pass" if account_ratio >= float(rules["min_account_50_plus_ratio"]) else "fail" ) ) portrait_conflict = ( content_status in {"pass", "fail"} and account_status in {"pass", "fail"} and content_status != account_status ) return { "rule_version": str(rules["rule_version"]), "status": "pass" if not failed_codes else "fail", "primary_eligible": not failed_codes, "failed_reason_codes": failed_codes, "checks": checks, "temporal": temporal, "content_portrait_status": content_status, "account_portrait_status": account_status, "portrait_conflict": portrait_conflict, }