xueyiming 6 ساعت پیش
والد
کامیت
b6d1ebf1db

+ 54 - 13
agents/find_agent/prompt/system_prompt.md

@@ -57,11 +57,38 @@
    `content_50_plus_ratio >= min_content_50_plus_ratio`。
 
 搜索工具会过滤已知时长不足 30 秒的结果;未知时长仍可能进入待补证候选,但必须通过
-`douyin_detail` 补齐后才能保存为 `primary`。分享数、发布时间或视频侧 50+ 占比缺失
-均属于证据不足,不能进入 `primary`。
-
-`batch_update_video_discovery_candidates` 会在保存层重新执行硬门槛。不得通过提高
-`R/E/S/V` 分数、使用账号画像或在理由中声称“综合表现较好”绕过门槛。
+`douyin_detail` 补齐后才能保存为 `primary`。
+
+## P0 与缺失数据的判断原则
+
+P0 门槛是底线,不是机械 checklist。判断时**必须参考**用户消息中的 `quality_gate_rules`
+快照(`min_duration_seconds`、`min_share_count`、`min_content_50_plus_ratio`、时效规则等),
+再结合本次需求、候选整体质量和可观测证据做综合判断。经验判断不能脱离给定条件,
+也不能把给定条件当成无需理解的死板公式。
+
+- 缺失不等于不合格。接口失败、字段未返回、画像拉取失败都应视为“未知”,降低置信度,
+  但不能把未知直接写成“不适合”或“证据不足必须拒绝”。
+- 当视频侧画像缺失但账号侧明显偏老、互动规模健康、内容与需求高度相关时,可以审慎
+  保留为 `primary`,并在 `decision_reason` 中说明依据的是哪类间接证据。
+- 当分享数、时长或发布时间缺失时,优先补证;补证仍失败时,结合标题、标签、点赞/
+  播放规模和同类候选的相对位置判断这条内容是否仍值得推荐。
+- 明确不达标的数值不能靠“综合表现好”绕过;补偿只适用于未知,不适用于已知失败。
+- 低相关、画像明显偏年轻、时效明显失效的候选,即使其他指标亮眼,也不应进入 `primary`。
+
+保存层对部分缺失字段留有补偿空间。下列是**参考条件**,达到时可支持补偿通过,未完全
+达到但综合证据仍很强时,也可结合经验审慎判断,并在 `decision_reason` 中说明:
+
+1. **视频侧 50+ 占比缺失**:参考 `account_50_plus_ratio >= 0.35`,或账号侧达到
+   `min_account_50_plus_ratio` 且 `R/E/S` 综合优秀;
+2. **时长缺失**:参考 `share_count >= 1.5 × min_share_count`,或 `R` 很高且 `R/E/S`
+   综合优秀;
+3. **分享数缺失**:参考 `like_count >= 5000` 或 `play_count >= 50000`,或 `S` 很高且
+   `R/E/S` 综合优秀;
+4. **发布时间缺失**:标题/标签无节日、时段、相对日期或事件表述,且其他指标支持时,
+   可按常青内容处理。
+
+`batch_update_video_discovery_candidates` 保存 `primary` 时会按 `quality_gate_rules`
+重新校验;请求保存前应自检候选是否满足或可被上述参考条件支持,避免无效提交。
 
 # 决策公理与定理
 
@@ -120,7 +147,8 @@
 第 4 层不得单独形成老年倾向结论。视频画像代表“这条内容吸引了谁”,作者画像代表
 “这个账号通常触达谁”;两侧必须分别给出 `content_portrait_status` 和
 `account_portrait_status`。两者一致时增强置信度;冲突时保留 `portrait_conflict`,
-但始终以视频侧门槛为准,不得静默平均。
+优先采信视频侧,但若视频侧缺失而账号侧、文本和互动证据共同指向偏老受众,可结合
+经验做审慎判断,不得静默平均,也不得编造画像数值。
 
 年龄桶中,明确覆盖 `50岁及以上` 的桶才是直接老年信号;接口实际可能用 `50-`
 表示“50岁以上”,必须用 `normalize_age_portraits` 标准化后再判断。只提供
@@ -159,9 +187,15 @@
 一个强反证比多个弱正向线索更重要。实际内容若围绕青少年校园、年轻圈层黑话、需要特定年轻文化背景,或画像明显偏年轻,应降低老年倾向;但剪辑快、使用网络表达等单个风格特征不能直接证明老年人不喜欢。
 
 缺失数据不是负证据,接口失败也不是零分。应标为“未知”并降低置信度,绝不能把未知写成不适合。
-但 `primary` 要求 `R / E / S` 共同成立;关键年龄证据缺失、导致 `E` 只能判为未知时,
-候选不得进入 `primary`。此时应以“证据不足以进入主推荐”归入 `rejected`,而不是声称
-画像证明其不适合老年受众。
+当关键字段缺失时,结合需求匹配度、分享动机、互动表现、账号先验和同类候选的相对质量
+做整体判断;若综合看仍是一条值得推荐给老年受众的内容,可以保留为 `primary`,并在
+`decision_reason` 中写清缺失了什么、靠什么间接证据支撑结论。
+
+`primary` 数量没有硬性配额。经验上,一个需求通常能形成少量高质量主推荐(常见约
+5 条左右),但应以“这批结果是否值得交付”为准,而不是为了凑数降低标准,也不是
+明明还有明显更好的候选却过早停止。判断时始终参照 `quality_gate_rules` 和上文
+补偿参考条件;若当前 `primary` 偏少且仍有高 `V`、仅个别字段缺失的候选,应优先补证,
+补证仍失败时再结合参考条件与整体质量审慎决定是否保留。
 
 ## 9. 多样性边际定理
 
@@ -169,7 +203,10 @@
 
 ## 10. 信息价值停止律
 
-只有当一次额外搜索、详情或画像有可能改变准入、排序或置信度时,它才有价值。证据已经足够区分候选时停止;证据不足以支持任何候选时返回“暂无可靠推荐”,不得为了凑数放宽公理。
+只有当一次额外搜索、详情或画像有可能改变准入、排序或置信度时,它才有价值。证据已经足够区分候选时停止。
+是否继续搜索,应看剩余前沿是否还可能带来更好的主推荐,而不是机械对照某个数量目标。
+若当前结果已经能代表本次需求下较优的一批内容,可以结束;若明显还有高潜力方向未探索,
+则应继续补证或扩展搜索。
 
 # 工具的证据含义
 
@@ -250,7 +287,10 @@
 
 # 结束流程
 
-结果要求只保留一项:尽量形成 5 条 `decision_bucket=primary` 的通过视频。
+结果目标:形成一小组高质量 `decision_bucket=primary` 视频。数量以经验判断为准,常见
+约 5 条左右,但可因需求宽窄、搜索结果质量和证据完整度上下浮动。优先保证相关性强、
+老年受众倾向可信、分享价值明确,并参照 `quality_gate_rules` 与补偿参考条件做准入;
+宁缺毋滥,也不应为了凑数保留勉强合格的候选。
 
 正常结束时按以下流程执行:
 
@@ -268,8 +308,9 @@
 简要说明对需求意图的理解,再报告本次形成的主推荐及其主要证据。按需概括主要淘汰原因、
 缺失数据、画像冲突或接口失败,不要求逐条列出 `rejected` 候选。
 
-语义相关性、分享动机和排序由 Agent 判断;时效、时长、分享数和视频侧 50+ 占比由
-程序按本次规则快照做不可绕过的准入校验。
+语义相关性、分享动机和排序由 Agent 结合经验判断;时效、时长、分享数和视频侧 50+ 占比
+由程序按本次 `quality_gate_rules` 快照做准入校验。程序层对部分缺失字段留有补偿空间,
+你应同时参考给定条件与整体证据做审慎综合判断,而不是机械套用固定阈值或强行凑满数量。
 
 禁止输出没有证据支撑的年龄结论,禁止把“内容讲老人”写成“观看者是老人”,禁止推荐
 低相关视频。

+ 2 - 2
agents/find_agent/support/age_portrait.py

@@ -167,13 +167,13 @@ def normalize_age_portrait_pair(
         cap = 1.0
     elif account_has:
         consistency = "account_only"
-        cap = 0.65
+        cap = 0.85 if account["strength"] == "strong" else 0.75
     elif content_has:
         consistency = "content_only"
         cap = 1.0
     else:
         consistency = "missing"
-        cap = 0.35
+        cap = 0.50
 
     return {
         "content": content,

+ 2 - 2
supply_infra/scheduler/jobs/discover_videos_from_demands.py

@@ -1,5 +1,5 @@
 """
-从全部 S/A 级需求及其拓展点位触发 find_agent 视频发现;有效视频满 200 提前结束。
+从全部 S/A 级需求及其拓展点位触发 find_agent 视频发现;有效视频满 400 提前结束。
 
 任务层负责查库与组装上下文;Agent 负责搜索、画像与分池落库。
 """
@@ -28,7 +28,7 @@ from supply_infra.services.video_discovery_service import get_video_discovery_se
 logger = logging.getLogger(__name__)
 
 _DEFAULT_WORKERS = 1
-_DAILY_PASSED_VIDEO_LIMIT = 200
+_DAILY_PASSED_VIDEO_LIMIT = 400
 
 
 def _resolve_biz_dt(biz_dt: str | None) -> str:

+ 127 - 5
supply_infra/video_discovery_gates.py

@@ -278,6 +278,14 @@ def evaluate_temporal_status(
     }
 
     if publish_at is None:
+        if _can_compensate_temporal_unknown(candidate, text=text):
+            return {
+                "temporal_type": inferred_type or "evergreen",
+                "status": "pass",
+                "reason_code": None,
+                "reason": "缺少发布时间,但无时效敏感表述且其他指标支持通过",
+                "evidence": {**evidence, "compensated": True},
+            }
         return {
             "temporal_type": inferred_type,
             "status": "unknown",
@@ -409,6 +417,96 @@ def _number(value: Any) -> float | None:
         return None
 
 
+def _semantic_score(candidate: Mapping[str, Any], field: str) -> float | None:
+    return _number(candidate.get(field))
+
+
+def _has_strong_semantic_scores(candidate: Mapping[str, Any]) -> bool:
+    """R/E/S 综合优秀,或 V 足够高,可用于缺失字段补偿。"""
+    relevance = _semantic_score(candidate, "relevance_score")
+    elder = _semantic_score(candidate, "elder_score")
+    share = _semantic_score(candidate, "share_score")
+    value = _semantic_score(candidate, "value_score")
+    if (
+        relevance is not None
+        and elder is not None
+        and share is not None
+        and relevance >= 0.70
+        and elder >= 0.70
+        and share >= 0.65
+    ):
+        return True
+    return value is not None and value >= 0.65
+
+
+def _text_has_temporal_markers(text: str) -> bool:
+    for _, terms, _, _ in _DAYPART_RULES:
+        if any(term in text for term in terms):
+            return True
+    known_names = set(_FIXED_FESTIVALS)
+    for yearly in _LUNAR_FESTIVALS.values():
+        known_names.update(yearly)
+    if any(name in text for name in known_names):
+        return True
+    if any(marker in text for marker in _EVENT_MARKERS + _RELATIVE_DATE_MARKERS):
+        return True
+    return False
+
+
+def _can_compensate_missing_portrait(
+    candidate: Mapping[str, Any],
+    account_ratio: float | None,
+    *,
+    min_account_ratio: float,
+) -> bool:
+    if account_ratio is not None and account_ratio >= max(min_account_ratio, 0.35):
+        return True
+    if account_ratio is not None and account_ratio >= min_account_ratio:
+        return _has_strong_semantic_scores(candidate)
+    elder = _semantic_score(candidate, "elder_score")
+    return elder is not None and elder >= 0.75 and _has_strong_semantic_scores(candidate)
+
+
+def _can_compensate_missing_duration(
+    candidate: Mapping[str, Any],
+    shares: float | None,
+    *,
+    min_share_count: float,
+) -> bool:
+    if shares is not None and shares >= min_share_count * 1.5:
+        return True
+    relevance = _semantic_score(candidate, "relevance_score")
+    return (
+        relevance is not None
+        and relevance >= 0.80
+        and _has_strong_semantic_scores(candidate)
+    )
+
+
+def _can_compensate_missing_shares(candidate: Mapping[str, Any]) -> bool:
+    likes = _number(candidate.get("like_count"))
+    plays = _number(candidate.get("play_count"))
+    if likes is not None and likes >= 5000:
+        return True
+    if plays is not None and plays >= 50000:
+        return True
+    share = _semantic_score(candidate, "share_score")
+    return share is not None and share >= 0.75 and _has_strong_semantic_scores(candidate)
+
+
+def _can_compensate_temporal_unknown(
+    candidate: Mapping[str, Any],
+    *,
+    text: str,
+) -> bool:
+    if _text_has_temporal_markers(text):
+        return False
+    if _has_strong_semantic_scores(candidate):
+        return True
+    shares = _number(candidate.get("share_count"))
+    return shares is not None and shares >= 2000
+
+
 def evaluate_candidate_gate(
     candidate: Mapping[str, Any],
     rule_snapshot: Mapping[str, Any] | str | None,
@@ -429,6 +527,11 @@ def evaluate_candidate_gate(
         }
     ]
 
+    min_duration = float(rules["min_duration_seconds"])
+    min_shares = float(rules["min_share_count"])
+    min_content_ratio = float(rules["min_content_50_plus_ratio"])
+    min_account_ratio = float(rules["min_account_50_plus_ratio"])
+
     def threshold_check(
         name: str,
         actual: float | None,
@@ -436,10 +539,17 @@ def evaluate_candidate_gate(
         *,
         missing_code: str,
         low_code: str,
+        compensate_missing: bool = False,
     ) -> None:
+        compensated = False
         if actual is None:
-            status = "fail"
-            reason_code = missing_code
+            if compensate_missing:
+                status = "pass"
+                reason_code = None
+                compensated = True
+            else:
+                status = "fail"
+                reason_code = missing_code
         elif actual < threshold:
             status = "fail"
             reason_code = low_code
@@ -453,29 +563,41 @@ def evaluate_candidate_gate(
                 "reason_code": reason_code,
                 "actual": actual,
                 "threshold": threshold,
+                "compensated": compensated,
             }
         )
 
     threshold_check(
         "duration_seconds",
         duration,
-        float(rules["min_duration_seconds"]),
+        min_duration,
         missing_code="DURATION_UNKNOWN",
         low_code="DURATION_TOO_SHORT",
+        compensate_missing=_can_compensate_missing_duration(
+            candidate,
+            shares,
+            min_share_count=min_shares,
+        ),
     )
     threshold_check(
         "share_count",
         shares,
-        float(rules["min_share_count"]),
+        min_shares,
         missing_code="SHARE_COUNT_UNKNOWN",
         low_code="SHARE_COUNT_TOO_LOW",
+        compensate_missing=_can_compensate_missing_shares(candidate),
     )
     threshold_check(
         "content_50_plus_ratio",
         content_ratio,
-        float(rules["min_content_50_plus_ratio"]),
+        min_content_ratio,
         missing_code="CONTENT_PORTRAIT_MISSING",
         low_code="CONTENT_50_PLUS_TOO_LOW",
+        compensate_missing=_can_compensate_missing_portrait(
+            candidate,
+            account_ratio,
+            min_account_ratio=min_account_ratio,
+        ),
     )
 
     failed_codes = [

+ 38 - 4
tests/supply_infra/scheduler/test_discover_videos_from_demands.py

@@ -374,7 +374,41 @@ def test_p0_gate_keeps_content_and_account_portraits_separate() -> None:
         ),
         _P0_RULES,
     )
-    assert "CONTENT_PORTRAIT_MISSING" in account_only["failed_reason_codes"]
+    assert account_only["primary_eligible"] is True
+    assert "CONTENT_PORTRAIT_MISSING" not in account_only["failed_reason_codes"]
+
+
+def test_p0_gate_compensates_missing_fields_with_strong_signals() -> None:
+    missing_publish = evaluate_candidate_gate(
+        _p0_candidate(publish_at=None, share_count=2000),
+        _P0_RULES,
+    )
+    assert missing_publish["primary_eligible"] is True
+
+    missing_duration = evaluate_candidate_gate(
+        _p0_candidate(duration_seconds=None, share_count=2000),
+        _P0_RULES,
+    )
+    assert missing_duration["primary_eligible"] is True
+
+    missing_shares = evaluate_candidate_gate(
+        _p0_candidate(share_count=None, like_count=8000),
+        _P0_RULES,
+    )
+    assert missing_shares["primary_eligible"] is True
+
+    strong_scores = evaluate_candidate_gate(
+        _p0_candidate(
+            content_50_plus_ratio=None,
+            account_50_plus_ratio=0.22,
+            relevance_score=0.82,
+            elder_score=0.78,
+            share_score=0.72,
+            value_score=0.70,
+        ),
+        _P0_RULES,
+    )
+    assert strong_scores["primary_eligible"] is True
 
 
 def test_search_candidate_persists_publish_time_duration_and_shares() -> None:
@@ -785,7 +819,7 @@ def test_evaluate_find_agent_run_fails_without_candidates(
 @patch(
     "supply_infra.scheduler.jobs.discover_videos_from_demands.list_find_demand_contexts"
 )
-def test_stops_discovery_after_200_passed_videos(
+def test_stops_discovery_after_400_passed_videos(
     mock_list_contexts,
     mock_filter_contexts,
     mock_count_passed,
@@ -810,14 +844,14 @@ def test_stops_discovery_after_200_passed_videos(
         contexts,
         {"total_loaded": 2, "skipped_already_done": 0},
     )
-    mock_count_passed.side_effect = [199, 200]
+    mock_count_passed.side_effect = [399, 400]
     mock_process.return_value = {"success": True, "skipped": False}
 
     result = discover_videos_from_demands("20260727", workers=1)
 
     assert mock_process.call_count == 1
     assert result["processed"] == 1
-    assert result["passed_videos"] == 200
+    assert result["passed_videos"] == 400
     assert result["stopped_by_passed_video_limit"] is True