Bläddra i källkod

feat: implement p4 pattern recall

Sam Lee 1 månad sedan
förälder
incheckning
460dc11643
37 ändrade filer med 3317 tillägg och 72 borttagningar
  1. 14 0
      .env.example
  2. 3 2
      content_agent/business_modules/content_discovery/content_discovery_builder.py
  3. 5 0
      content_agent/business_modules/content_discovery/pattern_recall/__init__.py
  4. 110 0
      content_agent/business_modules/content_discovery/pattern_recall/category_match.py
  5. 225 0
      content_agent/business_modules/content_discovery/pattern_recall/decode.py
  6. 322 0
      content_agent/business_modules/content_discovery/pattern_recall/recall_decision.py
  7. 83 1
      content_agent/business_modules/run_record/validation.py
  8. 27 1
      content_agent/graph.py
  9. 111 0
      content_agent/integrations/category_match.py
  10. 64 1
      content_agent/integrations/database_runtime.py
  11. 205 0
      content_agent/integrations/decode_api.py
  12. 25 0
      content_agent/integrations/runtime_files.py
  13. 15 0
      content_agent/interfaces.py
  14. 1 0
      content_agent/models.py
  15. 127 1
      content_agent/run_service.py
  16. 38 8
      product_documents/抖音游走策略/runtime_v1_records_schema.md
  17. 1 1
      scripts/validate_schema_registry.py
  18. 33 11
      tech_documents/Pattern回扣与分类树/01_Pattern回扣流程.md
  19. 80 21
      tech_documents/工程落地/04_V1阶段开发计划.md
  20. 187 0
      tech_documents/工程落地/implementation_briefs/P4/00_P4_Brief_Index.md
  21. 112 0
      tech_documents/工程落地/implementation_briefs/P4/P4A_MediaLink_And_MetadataOnly.md
  22. 126 0
      tech_documents/工程落地/implementation_briefs/P4/P4B_PatternRecallEvidence_RuntimeAndDB.md
  23. 118 0
      tech_documents/工程落地/implementation_briefs/P4/P4C_DecodeClient_StatusAndTimeout.md
  24. 117 0
      tech_documents/工程落地/implementation_briefs/P4/P4D_CategoryMatchPaths_Client.md
  25. 124 0
      tech_documents/工程落地/implementation_briefs/P4/P4E_RecallDecision_And_EvidenceBundle.md
  26. 114 0
      tech_documents/工程落地/implementation_briefs/P4/P4F_GraphIntegration_And_RuleBoundary.md
  27. 110 0
      tech_documents/工程落地/implementation_briefs/P4/P4G_P4_Acceptance_And_DriftGuards.md
  28. 2 2
      tech_documents/数据库字段总览/03_Runtime与RawPayload说明.md
  29. 35 22
      tech_documents/数据库字段总览/content_agent_schema_registry.json
  30. 149 0
      tests/p4_helpers.py
  31. 52 0
      tests/test_database_runtime.py
  32. 78 0
      tests/test_p4_graph_integration.py
  33. 51 0
      tests/test_pattern_recall_category_match.py
  34. 224 0
      tests/test_pattern_recall_decision.py
  35. 152 0
      tests/test_pattern_recall_decode.py
  36. 10 0
      tests/test_rule_pack_reading.py
  37. 67 1
      tests/test_runtime_files.py

+ 14 - 0
.env.example

@@ -39,6 +39,20 @@ CONTENTFIND_DOUYIN_DEFAULT_PUBLISH_TIME=不限
 CONTENTFIND_DOUYIN_DEFAULT_CURSOR=0
 CONTENTFIND_DOUYIN_MAX_RESULTS_PER_QUERY=3
 
+# Pattern recall / decode / category tree
+CONTENTFIND_API_AIGC_BASE_URL=https://aigc-api.aiddit.com
+CONTENTFIND_API_AIGC_TOKEN=<fill-if-enabled>
+CONTENTFIND_AIGC_DECODE_SUBMIT_PATH=/aigc/api/task/decode
+CONTENTFIND_AIGC_DECODE_RESULT_PATH=/aigc/api/task/decode/result
+CONTENTFIND_AIGC_DECODE_CONFIG_ID=58
+CONTENTFIND_PATTERN_RECALL_MAX_WAIT_SECONDS=1200
+CONTENTFIND_PATTERN_RECALL_POLL_INTERVAL_SECONDS=5
+CONTENTFIND_CATEGORY_MATCH_BASE_URL=https://library.aiddit.com
+CONTENTFIND_CATEGORY_MATCH_PATH=/api/search/categories/match-paths
+CONTENTFIND_CATEGORY_MATCH_SOURCE_TYPE=实质
+CONTENTFIND_CATEGORY_MATCH_TOP_K=10
+CONTENTFIND_CATEGORY_MATCH_MIN_SCORE=0.6
+
 # TikHub fallback
 TIKHUB_API_KEY=<fill-if-enabled>
 TIKHUB_BASE_URL=https://api.tikhub.io/api/v1

+ 3 - 2
content_agent/business_modules/content_discovery/content_discovery_builder.py

@@ -38,7 +38,6 @@ def run(
         discovered_content_items.append(discovered_content_item)
         content_media_records.append(content_media_record)
 
-    runtime.append_jsonl(run_id, "discovered_content_items.jsonl", discovered_content_items)
     runtime.append_jsonl(run_id, "content_media_records.jsonl", content_media_records)
     return {
         "discovered_content_items": discovered_content_items,
@@ -103,11 +102,13 @@ def _build_content_media_record(
         "platform_content_id": result["platform_content_id"],
         "content_media_status": "metadata_only",
         "content_metadata_source": result.get("content_metadata_source", "mock_platform_search"),
-        "play_url": None,
+        "play_url": result.get("play_url"),
         "local_path": None,
         "oss_url": None,
         "created_at": created_at,
     }
+    if not record["play_url"]:
+        record["failure_reason"] = result.get("media_failure_reason", "no_play_url_returned")
     return with_raw_payload(record)
 
 

+ 5 - 0
content_agent/business_modules/content_discovery/pattern_recall/__init__.py

@@ -0,0 +1,5 @@
+from __future__ import annotations
+
+from .recall_decision import run
+
+__all__ = ["run"]

+ 110 - 0
content_agent/business_modules/content_discovery/pattern_recall/category_match.py

@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+from typing import Any
+
+from content_agent.interfaces import CategoryMatchClient
+
+
+GENERIC_TERMS = {"案例", "提示", "标签", "标题", "结构", "语气", "官方来源", "视频", "内容"}
+
+
+def match_decode_terms(
+    *,
+    decode_elements: dict[str, Any],
+    category_match_client: CategoryMatchClient,
+) -> dict[str, Any]:
+    terms = [
+        term
+        for term in decode_elements.get("strong_terms", [])
+        if _is_usable_strong_term(term)
+    ]
+    items = [{"term": term, "description": "解构强证据词"} for term in terms]
+    if not items:
+        return {
+            "request": {"items": []},
+            "response": {},
+            "matched_terms": [],
+            "matched_category_paths": [],
+            "path_matches": [],
+        }
+    result = category_match_client.match_paths(items)
+    path_matches = _extract_path_matches(result.get("raw_response") or result.get("response") or {})
+    matched_terms = [
+        match["term"]
+        for match in path_matches
+        if _is_usable_strong_term(match.get("term"))
+    ]
+    matched_category_paths = [
+        match["category_path"]
+        for match in path_matches
+        if match.get("category_path")
+    ]
+    return {
+        "request": result.get("request") or {"items": items},
+        "response": result.get("response") or {},
+        "matched_terms": list(dict.fromkeys(matched_terms)),
+        "matched_category_paths": list(dict.fromkeys(matched_category_paths)),
+        "path_matches": path_matches,
+    }
+
+
+def _extract_path_matches(response: Any) -> list[dict[str, Any]]:
+    rows = _candidate_rows(response)
+    matches: list[dict[str, Any]] = []
+    for row in rows:
+        if not isinstance(row, dict):
+            continue
+        term = row.get("term") or row.get("query") or row.get("source_term") or row.get("item")
+        paths = row.get("paths") or row.get("matched_paths") or row.get("categories") or row.get("results")
+        if paths is None and (row.get("category_path") or row.get("path")):
+            paths = [row]
+        for path in _as_list(paths):
+            if not isinstance(path, dict):
+                continue
+            category_path = (
+                path.get("category_path")
+                or path.get("path")
+                or path.get("full_path")
+                or path.get("categoryPath")
+            )
+            if category_path:
+                matches.append(
+                    {
+                        "term": str(term or "").strip(),
+                        "category_path": str(category_path),
+                        "score": path.get("score"),
+                        "raw": path,
+                    }
+                )
+    return matches
+
+
+def _candidate_rows(response: Any) -> list[Any]:
+    if isinstance(response, list):
+        return response
+    if not isinstance(response, dict):
+        return []
+    for key in ["data", "results", "items", "matches"]:
+        value = response.get(key)
+        if isinstance(value, list):
+            return value
+        if isinstance(value, dict):
+            nested = _candidate_rows(value)
+            if nested:
+                return nested
+    return [response]
+
+
+def _is_usable_strong_term(term: Any) -> bool:
+    if term is None:
+        return False
+    value = str(term).strip()
+    return bool(value) and value not in GENERIC_TERMS
+
+
+def _as_list(value: Any) -> list[Any]:
+    if value is None:
+        return []
+    if isinstance(value, list):
+        return value
+    return [value]

+ 225 - 0
content_agent/business_modules/content_discovery/pattern_recall/decode.py

@@ -0,0 +1,225 @@
+from __future__ import annotations
+
+import json
+import time
+from typing import Any, Callable
+
+from content_agent.interfaces import DecodeClient
+
+
+DECODE_STATUSES = {"pending", "running", "success", "failed"}
+
+
+def decode_content(
+    *,
+    content: dict[str, Any],
+    media: dict[str, Any],
+    source_context: dict[str, Any],
+    decode_client: DecodeClient,
+    max_wait_seconds: float,
+    poll_interval_seconds: float,
+    now_fn: Callable[[], float] | None = None,
+    sleep_fn: Callable[[float], None] | None = None,
+) -> dict[str, Any]:
+    now = now_fn or time.monotonic
+    sleep = sleep_fn or time.sleep
+    try:
+        submitted = decode_client.submit_decode(content, media, source_context)
+    except Exception as exc:
+        return _client_failure("submit_decode", exc)
+    decode_task_id = submitted.get("decode_task_id") or _extract_decode_task_id(submitted)
+    current = submitted
+    status = normalize_decode_status(current)
+    deadline = now() + max_wait_seconds
+
+    while decode_task_id and status in {"pending", "running"} and now() < deadline:
+        if poll_interval_seconds > 0:
+            sleep(min(poll_interval_seconds, max(0.0, deadline - now())))
+        try:
+            current = decode_client.get_decode_result(str(decode_task_id))
+        except Exception as exc:
+            failed = _client_failure("get_decode_result", exc)
+            failed["decode_task_id"] = decode_task_id
+            return failed
+        status = normalize_decode_status(current)
+
+    if status in {"pending", "running"} and now() >= deadline:
+        return {
+            "decode_status": status,
+            "decode_task_id": decode_task_id,
+            "pending_reason": "decode_timeout_20m",
+            "decode_elements": {},
+            "raw_request": current.get("request") or submitted.get("request"),
+            "raw_response": current.get("response") or submitted.get("response"),
+        }
+
+    data_content = _extract_data_content(current)
+    decode_elements = extract_decode_elements(data_content)
+    if status == "success" and not isinstance(data_content, dict):
+        status = "failed"
+        failure_reason = "decode_result_bad_shape"
+    elif status == "success" and not decode_elements.get("strong_terms"):
+        status = "failed"
+        failure_reason = "decode_content_missing_strong_terms"
+    else:
+        failure_reason = None
+
+    return {
+        "decode_status": status,
+        "decode_task_id": decode_task_id,
+        "failure_reason": failure_reason,
+        "decode_elements": decode_elements,
+        "raw_request": current.get("request") or submitted.get("request"),
+        "raw_response": current.get("response") or submitted.get("response"),
+    }
+
+
+def _client_failure(operation: str, exc: Exception) -> dict[str, Any]:
+    return {
+        "decode_status": "failed",
+        "decode_task_id": None,
+        "failure_reason": "decode_client_error",
+        "decode_elements": {},
+        "raw_request": {"operation": operation},
+        "raw_response": {
+            "operation": operation,
+            "error_type": type(exc).__name__,
+        },
+    }
+
+
+def normalize_decode_status(payload: dict[str, Any]) -> str:
+    raw_status = _find_first(
+        payload,
+        ["decode_status", "status", "state", "taskStatus", "task_status"],
+    )
+    if raw_status is None:
+        raw_status = _find_first(payload.get("raw_response") or {}, ["status", "taskStatus", "state"])
+    value = str(raw_status or "pending").strip().lower()
+    aliases = {
+        "success": "success",
+        "succeeded": "success",
+        "complete": "success",
+        "completed": "success",
+        "done": "success",
+        "running": "running",
+        "processing": "running",
+        "pending": "pending",
+        "waiting": "pending",
+        "failed": "failed",
+        "fail": "failed",
+        "error": "failed",
+    }
+    return aliases.get(value, "pending")
+
+
+def extract_decode_elements(data_content: Any) -> dict[str, Any]:
+    if not isinstance(data_content, dict):
+        return {"strong_terms": [], "auxiliary_terms": []}
+    strong_terms: list[str] = []
+    auxiliary_terms: list[str] = []
+    for point in _as_list(data_content.get("目的点")):
+        if isinstance(point, dict):
+            _append_term(strong_terms, point.get("点"))
+            _append_substance_names(strong_terms, point.get("实质"))
+    for point in _as_list(data_content.get("关键点")):
+        if isinstance(point, dict):
+            is_substance = str(point.get("类型") or point.get("type") or point.get("source_type") or "")
+            if "实质" in is_substance:
+                _append_term(strong_terms, point.get("点"))
+            else:
+                _append_term(auxiliary_terms, point.get("点"))
+            _append_substance_names(strong_terms, point.get("实质"))
+    for token in _as_list(data_content.get("分词结果")):
+        if isinstance(token, dict):
+            _append_term(auxiliary_terms, token.get("词") or token.get("term"))
+        else:
+            _append_term(auxiliary_terms, token)
+    for token in _as_list(data_content.get("contribution_results")):
+        if isinstance(token, dict):
+            _append_term(auxiliary_terms, token.get("词") or token.get("term"))
+    return {
+        "strong_terms": _dedupe(strong_terms),
+        "auxiliary_terms": _dedupe(auxiliary_terms),
+        "raw_data_content": data_content,
+    }
+
+
+def _extract_data_content(payload: dict[str, Any]) -> Any:
+    raw = payload.get("raw_response") if isinstance(payload, dict) else None
+    candidates = [
+        payload.get("dataContent"),
+        payload.get("data_content"),
+        payload.get("data", {}).get("dataContent") if isinstance(payload.get("data"), dict) else None,
+        payload.get("data", {}).get("data_content") if isinstance(payload.get("data"), dict) else None,
+        raw.get("dataContent") if isinstance(raw, dict) else None,
+        raw.get("data", {}).get("dataContent") if isinstance(raw, dict) and isinstance(raw.get("data"), dict) else None,
+    ]
+    for candidate in candidates:
+        if candidate is None:
+            continue
+        if isinstance(candidate, str):
+            try:
+                return json.loads(candidate)
+            except json.JSONDecodeError:
+                return candidate
+        return candidate
+    return None
+
+
+def _extract_decode_task_id(payload: dict[str, Any]) -> str | None:
+    value = _find_first(payload, ["decode_task_id", "taskId", "task_id"])
+    if value:
+        return str(value)
+    raw = payload.get("raw_response") if isinstance(payload, dict) else None
+    if isinstance(raw, dict):
+        value = _find_first(raw, ["taskId", "task_id"])
+        if value:
+            return str(value)
+    return None
+
+
+def _find_first(payload: Any, keys: list[str]) -> Any:
+    if not isinstance(payload, dict):
+        return None
+    for key in keys:
+        if key in payload:
+            return payload[key]
+    data = payload.get("data")
+    if isinstance(data, dict):
+        for key in keys:
+            if key in data:
+                return data[key]
+    return None
+
+
+def _append_substance_names(target: list[str], value: Any) -> None:
+    if isinstance(value, dict):
+        for child in value.values():
+            _append_substance_names(target, child)
+        return
+    for item in _as_list(value):
+        if isinstance(item, dict):
+            _append_term(target, item.get("名称") or item.get("name") or item.get("点"))
+        else:
+            _append_term(target, item)
+
+
+def _append_term(target: list[str], value: Any) -> None:
+    if value is None:
+        return
+    term = str(value).strip()
+    if term:
+        target.append(term)
+
+
+def _as_list(value: Any) -> list[Any]:
+    if value is None:
+        return []
+    if isinstance(value, list):
+        return value
+    return [value]
+
+
+def _dedupe(values: list[str]) -> list[str]:
+    return list(dict.fromkeys(values))

+ 322 - 0
content_agent/business_modules/content_discovery/pattern_recall/recall_decision.py

@@ -0,0 +1,322 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any, Callable
+
+from content_agent.business_modules.content_discovery.pattern_recall.category_match import (
+    match_decode_terms,
+)
+from content_agent.business_modules.content_discovery.pattern_recall.decode import decode_content
+from content_agent.constants import RUNTIME_RECORD_SCHEMA_VERSION
+from content_agent.integrations.decode_api import redact_sensitive_payload
+from content_agent.interfaces import CategoryMatchClient, DecodeClient, RuntimeFileStore
+
+
+def run(
+    run_id: str,
+    policy_run_id: str,
+    discovered_content_items: list[dict[str, Any]],
+    content_media_records: list[dict[str, Any]],
+    evidence_bundles: list[dict[str, Any]],
+    source_context: dict[str, Any],
+    pattern_seed_pack: dict[str, Any],
+    runtime: RuntimeFileStore,
+    decode_client: DecodeClient,
+    category_match_client: CategoryMatchClient,
+    max_wait_seconds: float = 1200.0,
+    poll_interval_seconds: float = 5.0,
+    now_fn: Callable[[], float] | None = None,
+    sleep_fn: Callable[[float], None] | None = None,
+) -> dict[str, Any]:
+    created_at = datetime.now(timezone.utc).isoformat()
+    evidence_rows: list[dict[str, Any]] = []
+    updated_items: list[dict[str, Any]] = []
+    updated_bundles: list[dict[str, Any]] = []
+
+    media_by_content_id = {
+        row["platform_content_id"]: row
+        for row in content_media_records
+    }
+    for index, item in enumerate(discovered_content_items, start=1):
+        media = media_by_content_id.get(item["platform_content_id"], {})
+        bundle = evidence_bundles[index - 1]
+        decision = _recall_one(
+            index=index,
+            content=item,
+            media=media,
+            source_context=source_context,
+            pattern_seed_pack=pattern_seed_pack,
+            decode_client=decode_client,
+            category_match_client=category_match_client,
+            max_wait_seconds=max_wait_seconds,
+            poll_interval_seconds=poll_interval_seconds,
+            now_fn=now_fn,
+            sleep_fn=sleep_fn,
+        )
+        pattern_match_result = decision["pattern_match_result"]
+        evidence_row = _build_evidence_row(
+            run_id=run_id,
+            policy_run_id=policy_run_id,
+            content=item,
+            decision=decision,
+            created_at=created_at,
+        )
+        updated_item = _update_discovered_item(item, pattern_match_result)
+        updated_bundle = _update_evidence_bundle(bundle, pattern_match_result)
+        evidence_rows.append(evidence_row)
+        updated_items.append(updated_item)
+        updated_bundles.append(updated_bundle)
+
+    runtime.append_jsonl(run_id, "pattern_recall_evidence.jsonl", evidence_rows)
+    runtime.append_jsonl(run_id, "discovered_content_items.jsonl", updated_items)
+    return {
+        "pattern_recall_evidence": evidence_rows,
+        "discovered_content_items": updated_items,
+        "evidence_bundles": updated_bundles,
+    }
+
+
+def _recall_one(
+    *,
+    index: int,
+    content: dict[str, Any],
+    media: dict[str, Any],
+    source_context: dict[str, Any],
+    pattern_seed_pack: dict[str, Any],
+    decode_client: DecodeClient,
+    category_match_client: CategoryMatchClient,
+    max_wait_seconds: float,
+    poll_interval_seconds: float,
+    now_fn: Callable[[], float] | None,
+    sleep_fn: Callable[[float], None] | None,
+) -> dict[str, Any]:
+    recall_evidence_id = f"recall_{index:03d}"
+    decode_result = decode_content(
+        content=content,
+        media=media,
+        source_context=source_context,
+        decode_client=decode_client,
+        max_wait_seconds=max_wait_seconds,
+        poll_interval_seconds=poll_interval_seconds,
+        now_fn=now_fn,
+        sleep_fn=sleep_fn,
+    )
+    decode_status = decode_result["decode_status"]
+    if decode_status in {"pending", "running"}:
+        return _decision(
+            recall_evidence_id=recall_evidence_id,
+            decode_result=decode_result,
+            recall_status="pending",
+            pattern_recall="pattern_recall_pending",
+            category_or_element_binding="pattern_recall_pending",
+            match_result={},
+        )
+    if decode_status == "failed":
+        is_client_failure = decode_result.get("failure_reason") == "decode_client_error"
+        return _decision(
+            recall_evidence_id=recall_evidence_id,
+            decode_result=decode_result,
+            recall_status="failed" if is_client_failure else "rejected",
+            pattern_recall="pattern_recall_failed" if is_client_failure else "pattern_recall_rejected",
+            category_or_element_binding=(
+                "pattern_recall_failed" if is_client_failure else "pattern_recall_rejected"
+            ),
+            match_result={},
+        )
+
+    try:
+        match_result = match_decode_terms(
+            decode_elements=decode_result.get("decode_elements") or {},
+            category_match_client=category_match_client,
+        )
+    except Exception as exc:
+        match_result = _match_client_failure(exc)
+        return _decision(
+            recall_evidence_id=recall_evidence_id,
+            decode_result=decode_result,
+            recall_status="failed",
+            pattern_recall="pattern_recall_failed",
+            category_or_element_binding="pattern_recall_failed",
+            match_result=match_result,
+        )
+    matched_terms = match_result.get("matched_terms") or []
+    matched_paths = match_result.get("matched_category_paths") or []
+    if not matched_terms or not matched_paths:
+        return _decision(
+            recall_evidence_id=recall_evidence_id,
+            decode_result=decode_result,
+            recall_status="no_match",
+            pattern_recall="pattern_recall_no_match",
+            category_or_element_binding="pattern_recall_no_match",
+            match_result=match_result,
+        )
+    if not _can_explain_pattern(matched_terms, matched_paths, source_context, pattern_seed_pack):
+        return _decision(
+            recall_evidence_id=recall_evidence_id,
+            decode_result=decode_result,
+            recall_status="no_match",
+            pattern_recall="pattern_recall_no_match",
+            category_or_element_binding="pattern_recall_no_match",
+            match_result=match_result,
+        )
+    return _decision(
+        recall_evidence_id=recall_evidence_id,
+        decode_result=decode_result,
+        recall_status="matched",
+        pattern_recall="matched",
+        category_or_element_binding="tree_walk_match",
+        match_result=match_result,
+    )
+
+
+def _decision(
+    *,
+    recall_evidence_id: str,
+    decode_result: dict[str, Any],
+    recall_status: str,
+    pattern_recall: str,
+    category_or_element_binding: str,
+    match_result: dict[str, Any],
+) -> dict[str, Any]:
+    matched_paths = match_result.get("matched_category_paths") or []
+    matched_terms = match_result.get("matched_terms") or []
+    primary_path = matched_paths[0] if matched_paths else None
+    pattern_match_result = {
+        "pattern_recall": pattern_recall,
+        "category_or_element_binding": category_or_element_binding,
+        "decode_status": decode_result.get("decode_status"),
+        "match_status": "matched" if recall_status == "matched" else recall_status,
+        "recall_status": recall_status,
+        "matched_terms": matched_terms,
+        "matched_category_paths": matched_paths,
+        "primary_matched_category_path": primary_path,
+        "decode_elements": decode_result.get("decode_elements") or {},
+        "pattern_recall_evidence_id": recall_evidence_id,
+    }
+    return {
+        "recall_evidence_id": recall_evidence_id,
+        "decode_result": decode_result,
+        "match_result": match_result,
+        "pattern_match_result": pattern_match_result,
+    }
+
+
+def _build_evidence_row(
+    *,
+    run_id: str,
+    policy_run_id: str,
+    content: dict[str, Any],
+    decision: dict[str, Any],
+    created_at: str,
+) -> dict[str, Any]:
+    decode_result = decision["decode_result"]
+    match_result = decision["match_result"]
+    pattern_match = decision["pattern_match_result"]
+    raw_payload = redact_sensitive_payload(
+        {
+            "run_id": run_id,
+            "policy_run_id": policy_run_id,
+            "recall_evidence_id": decision["recall_evidence_id"],
+            "platform": content.get("platform"),
+            "primary_matched_category_path": pattern_match.get("primary_matched_category_path"),
+            "pending_reason": decode_result.get("pending_reason"),
+            "failure_reason": decode_result.get("failure_reason") or match_result.get("failure_reason"),
+            "decode_request": decode_result.get("raw_request"),
+            "decode_response": decode_result.get("raw_response"),
+            "match_paths_request": match_result.get("request"),
+            "match_paths_response": match_result.get("response"),
+        }
+    )
+    return {
+        "record_schema_version": RUNTIME_RECORD_SCHEMA_VERSION,
+        "run_id": run_id,
+        "policy_run_id": policy_run_id,
+        "recall_evidence_id": decision["recall_evidence_id"],
+        "content_discovery_id": content.get("content_discovery_id"),
+        "platform": content.get("platform"),
+        "platform_content_id": content.get("platform_content_id"),
+        "decode_status": pattern_match.get("decode_status"),
+        "decode_task_id": decode_result.get("decode_task_id"),
+        "recall_status": pattern_match.get("recall_status"),
+        "matched_terms": pattern_match.get("matched_terms"),
+        "matched_category_paths": pattern_match.get("matched_category_paths"),
+        "decode_elements": pattern_match.get("decode_elements"),
+        "match_paths_request": match_result.get("request"),
+        "match_paths_response": match_result.get("response"),
+        "evidence_summary": {
+            "pattern_recall": pattern_match.get("pattern_recall"),
+            "category_or_element_binding": pattern_match.get("category_or_element_binding"),
+            "primary_matched_category_path": pattern_match.get("primary_matched_category_path"),
+            "pending_reason": decode_result.get("pending_reason"),
+            "failure_reason": decode_result.get("failure_reason") or match_result.get("failure_reason"),
+        },
+        "raw_payload": raw_payload,
+        "created_at": created_at,
+    }
+
+
+def _update_discovered_item(
+    item: dict[str, Any],
+    pattern_match_result: dict[str, Any],
+) -> dict[str, Any]:
+    updated = {**item, "pattern_match_result": pattern_match_result}
+    raw_payload = dict(updated.get("raw_payload") or {})
+    raw_payload["pattern_match_result"] = pattern_match_result
+    updated["raw_payload"] = raw_payload
+    return updated
+
+
+def _update_evidence_bundle(
+    bundle: dict[str, Any],
+    pattern_match_result: dict[str, Any],
+) -> dict[str, Any]:
+    existing = bundle.get("pattern_match_result") or {}
+    return {**bundle, "pattern_match_result": {**existing, **pattern_match_result}}
+
+
+def _can_explain_pattern(
+    matched_terms: list[str],
+    matched_paths: list[str],
+    source_context: dict[str, Any],
+    pattern_seed_pack: dict[str, Any],
+) -> bool:
+    evidence_pack = source_context.get("ext_data", {}).get("evidence_pack", {})
+    seed_terms = set(pattern_seed_pack.get("seed_terms") or evidence_pack.get("seed_terms") or [])
+    if seed_terms.intersection(matched_terms):
+        return True
+    binding_paths = [
+        binding.get("category_path")
+        for binding in [
+            *(pattern_seed_pack.get("category_bindings") or []),
+            *(evidence_pack.get("category_bindings") or []),
+            *(pattern_seed_pack.get("itemsets") or []),
+            *(pattern_seed_pack.get("itemset_items") or []),
+            *(evidence_pack.get("itemset_items") or []),
+        ]
+        if isinstance(binding, dict) and binding.get("category_path")
+    ]
+    for matched_path in matched_paths:
+        for binding_path in binding_paths:
+            if matched_path in binding_path or binding_path in matched_path:
+                return True
+            if _path_leaf(matched_path) == _path_leaf(binding_path):
+                return True
+    return False
+
+
+def _match_client_failure(exc: Exception) -> dict[str, Any]:
+    return {
+        "request": {},
+        "response": {
+            "operation": "match_paths",
+            "error_type": type(exc).__name__,
+        },
+        "matched_terms": [],
+        "matched_category_paths": [],
+        "path_matches": [],
+        "failure_reason": "category_match_client_error",
+    }
+
+
+def _path_leaf(path: str) -> str:
+    return str(path).rstrip("/").split("/")[-1]

+ 83 - 1
content_agent/business_modules/run_record/validation.py

@@ -21,6 +21,7 @@ POLICY_RUN_FILES = {
     "search_queries.jsonl",
     "discovered_content_items.jsonl",
     "content_media_records.jsonl",
+    "pattern_recall_evidence.jsonl",
     "rule_decisions.jsonl",
     "run_events.jsonl",
     "source_path_records.jsonl",
@@ -32,13 +33,27 @@ RAW_PAYLOAD_FILES = {
     "search_queries.jsonl",
     "discovered_content_items.jsonl",
     "content_media_records.jsonl",
+    "pattern_recall_evidence.jsonl",
     "rule_decisions.jsonl",
     "run_events.jsonl",
     "source_path_records.jsonl",
     "search_clues.jsonl",
     "strategy_review.json",
 }
-FORBIDDEN_PAYLOAD_KEYS = {"password", "token", "api_key", "secret", "dsn"}
+FORBIDDEN_PAYLOAD_KEYS = {
+    "password",
+    "token",
+    "access_token",
+    "refresh_token",
+    "api_key",
+    "apikey",
+    "secret",
+    "dsn",
+    "authorization",
+    "cookie",
+    "session",
+    "credential",
+}
 DECISION_ACTIONS = {
     "ADD_TO_CONTENT_POOL",
     "KEEP_CONTENT_FOR_REVIEW",
@@ -85,6 +100,7 @@ def validate_run(run_id: str, runtime: RuntimeFileStore) -> dict[str, Any]:
     _check_raw_payloads(data, findings)
     _check_unique_ids(data, findings)
     _check_references(data, findings)
+    _check_pattern_recall_evidence(data, findings)
     _check_source_evidence(data, findings)
     _check_source_paths(data, findings)
     _check_summary(data, findings)
@@ -202,6 +218,7 @@ def _check_unique_ids(data: dict[str, Any], findings: list[dict[str, Any]]) -> N
         ("search_queries.jsonl", "search_query_id"),
         ("discovered_content_items.jsonl", "content_discovery_id"),
         ("discovered_content_items.jsonl", "platform_content_id"),
+        ("pattern_recall_evidence.jsonl", "recall_evidence_id"),
         ("rule_decisions.jsonl", "decision_id"),
         ("rule_decisions.jsonl", "decision_target_id"),
         ("source_path_records.jsonl", "source_path_record_id"),
@@ -239,6 +256,14 @@ def _check_references(data: dict[str, Any], findings: list[dict[str, Any]]) -> N
                 f"media has unknown platform_content_id: {media.get('platform_content_id')}",
             )
 
+    for evidence in data.get("pattern_recall_evidence.jsonl", []):
+        if evidence.get("platform_content_id") not in content_ids:
+            _fail(
+                findings,
+                "missing_content_ref",
+                f"recall evidence has unknown platform_content_id: {evidence.get('platform_content_id')}",
+            )
+
     for decision in decisions:
         if decision.get("decision_target_id") not in content_ids:
             _fail(
@@ -276,7 +301,64 @@ def _check_references(data: dict[str, Any], findings: list[dict[str, Any]]) -> N
                     findings,
                     "missing_decision_ref",
                     f"{section} has unknown decision_id: {row.get('decision_id')}",
+            )
+
+
+def _check_pattern_recall_evidence(
+    data: dict[str, Any],
+    findings: list[dict[str, Any]],
+) -> None:
+    evidence_rows = data.get("pattern_recall_evidence.jsonl", [])
+    evidence_by_id = {row.get("recall_evidence_id"): row for row in evidence_rows}
+    for row in evidence_rows:
+        recall_status = row.get("recall_status")
+        if recall_status == "matched":
+            missing = [
+                field
+                for field in [
+                    "recall_evidence_id",
+                    "matched_terms",
+                    "matched_category_paths",
+                ]
+                if not row.get(field)
+            ]
+            if missing:
+                _fail(
+                    findings,
+                    "pattern_recall_matched_missing_evidence",
+                    f"matched recall evidence missing {missing}",
                 )
+            if len(row.get("matched_category_paths") or []) > 1:
+                primary_path = (row.get("raw_payload") or {}).get("primary_matched_category_path")
+                if not primary_path:
+                    summary = row.get("evidence_summary") or {}
+                    primary_path = summary.get("primary_matched_category_path")
+                if not primary_path:
+                    _fail(
+                        findings,
+                        "pattern_recall_primary_path_missing",
+                        "matched multi-path recall evidence missing primary path",
+                    )
+
+    for item in data.get("discovered_content_items.jsonl", []):
+        pattern_match = item.get("pattern_match_result") or {}
+        if pattern_match.get("pattern_recall") != "matched":
+            continue
+        evidence_id = pattern_match.get("pattern_recall_evidence_id")
+        evidence = evidence_by_id.get(evidence_id)
+        if not evidence:
+            _fail(
+                findings,
+                "pattern_recall_evidence_missing",
+                f"matched item cannot find recall evidence: {evidence_id}",
+            )
+            continue
+        if not pattern_match.get("matched_terms") or not pattern_match.get("matched_category_paths"):
+            _fail(
+                findings,
+                "pattern_recall_matched_missing_evidence",
+                f"matched item missing terms or paths: {item.get('content_discovery_id')}",
+            )
 
 
 def _check_source_evidence(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:

+ 27 - 1
content_agent/graph.py

@@ -17,7 +17,10 @@ from content_agent.business_modules import (
     source_seed,
     walk_strategy,
 )
+from content_agent.business_modules.content_discovery import pattern_recall
 from content_agent.interfaces import (
+    CategoryMatchClient,
+    DecodeClient,
     PlatformSearchClient,
     PolicyBundleStore,
     QueryVariantClient,
@@ -32,6 +35,10 @@ class RunDependencies:
     platform_client: PlatformSearchClient
     policy_store: PolicyBundleStore
     query_variant_client: QueryVariantClient
+    decode_client: DecodeClient
+    category_match_client: CategoryMatchClient
+    pattern_recall_max_wait_seconds: float = 1200.0
+    pattern_recall_poll_interval_seconds: float = 5.0
 
 
 def build_run_graph(deps: RunDependencies):
@@ -67,6 +74,23 @@ def build_run_graph(deps: RunDependencies):
         )
         return {**result, "current_step": "build_discovered_content"}
 
+    def recall_pattern(state: RunState) -> dict[str, Any]:
+        result = pattern_recall.run(
+            state["run_id"],
+            state["policy_run_id"],
+            state["discovered_content_items"],
+            state["content_media_records"],
+            state["evidence_bundles"],
+            state["source_context"],
+            state["pattern_seed_pack"],
+            deps.runtime,
+            deps.decode_client,
+            deps.category_match_client,
+            max_wait_seconds=deps.pattern_recall_max_wait_seconds,
+            poll_interval_seconds=deps.pattern_recall_poll_interval_seconds,
+        )
+        return {**result, "current_step": "recall_pattern"}
+
     def load_policy(state: RunState) -> dict[str, Any]:
         bundle = policy_version.run(state["strategy_version"], deps.policy_store)
         return {
@@ -131,6 +155,7 @@ def build_run_graph(deps: RunDependencies):
     graph.add_node("plan_queries", plan_queries)
     graph.add_node("search_platform", search_platform)
     graph.add_node("build_discovered_content", build_discovered_content)
+    graph.add_node("recall_pattern", recall_pattern)
     graph.add_node("load_policy", load_policy)
     graph.add_node("evaluate_rules", evaluate_rules)
     graph.add_node("plan_walk", plan_walk)
@@ -142,7 +167,8 @@ def build_run_graph(deps: RunDependencies):
     graph.add_edge("load_source", "plan_queries")
     graph.add_edge("plan_queries", "search_platform")
     graph.add_edge("search_platform", "build_discovered_content")
-    graph.add_edge("build_discovered_content", "load_policy")
+    graph.add_edge("build_discovered_content", "recall_pattern")
+    graph.add_edge("recall_pattern", "load_policy")
     graph.add_edge("load_policy", "evaluate_rules")
     graph.add_edge("evaluate_rules", "plan_walk")
     graph.add_edge("plan_walk", "record_run")

+ 111 - 0
content_agent/integrations/category_match.py

@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import Any
+from urllib.parse import urljoin
+
+import httpx
+
+from content_agent.integrations.decode_api import redact_sensitive_payload
+
+
+class CategoryMatchClient:
+    def __init__(
+        self,
+        *,
+        base_url: str,
+        match_path: str = "/api/search/categories/match-paths",
+        source_type: str = "实质",
+        top_k: int = 10,
+        min_score: float = 0.6,
+        timeout_seconds: float = 60.0,
+        http_client: Any | None = None,
+    ) -> None:
+        self.base_url = base_url.rstrip("/") + "/"
+        self.match_path = match_path.lstrip("/")
+        self.source_type = source_type
+        self.top_k = top_k
+        self.min_score = min_score
+        self.timeout_seconds = timeout_seconds
+        self.http_client = http_client or httpx.Client(timeout=timeout_seconds)
+
+    @classmethod
+    def from_env(cls, env_path: str | Path = ".env") -> "CategoryMatchClient":
+        env = _merged_env(env_path)
+        return cls(
+            base_url=_env(
+                "CONTENTFIND_CATEGORY_MATCH_BASE_URL",
+                env,
+                default="https://library.aiddit.com",
+            ),
+            match_path=_env(
+                "CONTENTFIND_CATEGORY_MATCH_PATH",
+                env,
+                default="/api/search/categories/match-paths",
+            ),
+            source_type=_env("CONTENTFIND_CATEGORY_MATCH_SOURCE_TYPE", env, default="实质"),
+            top_k=int(_env("CONTENTFIND_CATEGORY_MATCH_TOP_K", env, default="10")),
+            min_score=float(_env("CONTENTFIND_CATEGORY_MATCH_MIN_SCORE", env, default="0.6")),
+            timeout_seconds=float(_env("CONTENTFIND_CATEGORY_MATCH_TIMEOUT_SECONDS", env, default="60")),
+        )
+
+    def match_paths(self, items: list[dict[str, Any]]) -> dict[str, Any]:
+        payload = {
+            "source_type": self.source_type,
+            "top_k": self.top_k,
+            "min_score": self.min_score,
+            "items": items,
+        }
+        response = self._post_json(payload)
+        return {
+            "request": redact_sensitive_payload(payload),
+            "response": redact_sensitive_payload(response),
+            "raw_response": response,
+        }
+
+    def _post_json(self, payload: dict[str, Any]) -> dict[str, Any]:
+        url = urljoin(self.base_url, self.match_path)
+        try:
+            response = self.http_client.post(
+                url,
+                json=payload,
+                headers={"Content-Type": "application/json"},
+                timeout=self.timeout_seconds,
+            )
+            response.raise_for_status()
+            data = response.json()
+        except httpx.HTTPStatusError as exc:
+            status_code = exc.response.status_code if exc.response is not None else "unknown"
+            raise RuntimeError(f"category match failed: HTTP {status_code}") from exc
+        except httpx.HTTPError as exc:
+            raise RuntimeError("category match failed: network_error") from exc
+        except ValueError as exc:
+            raise RuntimeError("category match failed: bad_json") from exc
+        if not isinstance(data, dict):
+            raise RuntimeError("category match failed: bad_response")
+        return data
+
+
+def _merged_env(env_path: str | Path) -> dict[str, str]:
+    env = _load_env_file(env_path)
+    env.update({key: value for key, value in os.environ.items() if value})
+    return env
+
+
+def _load_env_file(env_path: str | Path) -> dict[str, str]:
+    path = Path(env_path)
+    if not path.exists():
+        return {}
+    env: dict[str, str] = {}
+    for line in path.read_text(encoding="utf-8").splitlines():
+        stripped = line.strip()
+        if not stripped or stripped.startswith("#") or "=" not in stripped:
+            continue
+        key, value = stripped.split("=", 1)
+        env[key.strip()] = value.strip().strip('"').strip("'")
+    return env
+
+
+def _env(key: str, file_env: dict[str, str], default: str | None = None) -> str:
+    return file_env.get(key) or default or ""

+ 64 - 1
content_agent/integrations/database_runtime.py

@@ -35,6 +35,7 @@ RUNTIME_FILE_TABLES = {
     "search_queries.jsonl": "content_agent_queries",
     "discovered_content_items.jsonl": "content_agent_discovered_content_items",
     "content_media_records.jsonl": "content_agent_content_media_records",
+    "pattern_recall_evidence.jsonl": "content_agent_pattern_recall_evidence",
     "rule_decisions.jsonl": "content_agent_rule_decisions",
     "run_events.jsonl": "content_agent_run_events",
     "source_path_records.jsonl": "content_agent_source_path_records",
@@ -58,6 +59,15 @@ JSON_COLUMNS_BY_TABLE = {
         "raw_payload",
     },
     "content_agent_content_media_records": {"raw_payload"},
+    "content_agent_pattern_recall_evidence": {
+        "matched_terms",
+        "matched_category_paths",
+        "decode_elements",
+        "match_paths_request",
+        "match_paths_response",
+        "evidence_summary",
+        "raw_payload",
+    },
     "content_agent_rule_decisions": {
         "triggered_blocking_rules",
         "scorecard",
@@ -186,7 +196,15 @@ class DatabaseRuntimeStore:
         for row in rows:
             if row.get("run_id") != run_id:
                 raise ValueError(f"{filename} row run_id does not match runtime run_id")
-            self._insert(table, _record_for_jsonl(filename, row))
+            record = _record_for_jsonl(filename, row)
+            if filename == "pattern_recall_evidence.jsonl":
+                self._upsert(
+                    table,
+                    record,
+                    key_columns=("run_id", "policy_run_id", "recall_evidence_id"),
+                )
+            else:
+                self._insert(table, record)
         return self.run_dir(run_id) / filename
 
     def read_json(self, run_id: str, filename: str) -> dict[str, Any]:
@@ -274,6 +292,30 @@ class DatabaseRuntimeStore:
                 )
             conn.commit()
 
+    def _upsert(
+        self,
+        table: str,
+        record: dict[str, Any],
+        key_columns: tuple[str, ...],
+    ) -> None:
+        sanitized = _sanitize_record(table, record)
+        columns = list(sanitized)
+        placeholders = ", ".join(["%s"] * len(columns))
+        column_sql = ", ".join(f"`{column}`" for column in columns)
+        update_columns = [column for column in columns if column not in key_columns]
+        assignments = ", ".join(f"`{column}` = VALUES(`{column}`)" for column in update_columns)
+        values = [
+            _db_value(table, column, sanitized[column])
+            for column in columns
+        ]
+        sql = f"INSERT INTO `{table}` ({column_sql}) VALUES ({placeholders})"
+        if assignments:
+            sql += f" ON DUPLICATE KEY UPDATE {assignments}"
+        with self._connection_factory() as conn:
+            with conn.cursor() as cur:
+                cur.execute(sql, values)
+            conn.commit()
+
     def _fetch_one(self, sql: str, params: tuple[Any, ...]) -> dict[str, Any] | None:
         with self._connection_factory() as conn:
             with conn.cursor() as cur:
@@ -373,6 +415,8 @@ def _record_for_jsonl(filename: str, row: dict[str, Any]) -> dict[str, Any]:
         return _with_db_schema(row)
     if filename == "content_media_records.jsonl":
         return _with_db_schema(row)
+    if filename == "pattern_recall_evidence.jsonl":
+        return _with_db_schema(row)
     if filename == "rule_decisions.jsonl":
         return _with_db_schema(row)
     if filename == "run_events.jsonl":
@@ -491,6 +535,25 @@ TABLE_COLUMNS = {
         "raw_payload",
         "created_at",
     },
+    "content_agent_pattern_recall_evidence": {
+        "schema_version",
+        "run_id",
+        "policy_run_id",
+        "recall_evidence_id",
+        "content_discovery_id",
+        "platform_content_id",
+        "decode_status",
+        "decode_task_id",
+        "recall_status",
+        "matched_terms",
+        "matched_category_paths",
+        "decode_elements",
+        "match_paths_request",
+        "match_paths_response",
+        "evidence_summary",
+        "raw_payload",
+        "created_at",
+    },
     "content_agent_rule_decisions": {
         "schema_version",
         "run_id",

+ 205 - 0
content_agent/integrations/decode_api.py

@@ -0,0 +1,205 @@
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import Any
+from urllib.parse import urljoin
+
+import httpx
+
+
+SENSITIVE_KEYS = {
+    "password",
+    "token",
+    "access_token",
+    "refresh_token",
+    "api_key",
+    "apikey",
+    "secret",
+    "dsn",
+    "authorization",
+    "cookie",
+    "session",
+    "credential",
+}
+
+
+class AigcDecodeClient:
+    def __init__(
+        self,
+        *,
+        base_url: str,
+        token: str,
+        submit_path: str = "/aigc/api/task/decode",
+        result_path: str = "/aigc/api/task/decode/result",
+        config_id: int = 58,
+        timeout_seconds: float = 60.0,
+        http_client: Any | None = None,
+    ) -> None:
+        self.base_url = base_url.rstrip("/") + "/"
+        self.token = token
+        self.submit_path = submit_path.lstrip("/")
+        self.result_path = result_path.lstrip("/")
+        self.config_id = config_id
+        self.timeout_seconds = timeout_seconds
+        self.http_client = http_client or httpx.Client(timeout=timeout_seconds)
+
+    @classmethod
+    def from_env(cls, env_path: str | Path = ".env") -> "AigcDecodeClient":
+        env = _merged_env(env_path)
+        return cls(
+            base_url=_env("CONTENTFIND_API_AIGC_BASE_URL", env, required=True),
+            token=_env("CONTENTFIND_API_AIGC_TOKEN", env, default=env.get("AIGC_TOKEN"), required=True),
+            submit_path=_env(
+                "CONTENTFIND_AIGC_DECODE_SUBMIT_PATH",
+                env,
+                default="/aigc/api/task/decode",
+            ),
+            result_path=_env(
+                "CONTENTFIND_AIGC_DECODE_RESULT_PATH",
+                env,
+                default="/aigc/api/task/decode/result",
+            ),
+            config_id=int(_env("CONTENTFIND_AIGC_DECODE_CONFIG_ID", env, default="58")),
+            timeout_seconds=float(_env("CONTENTFIND_API_AIGC_TIMEOUT_SECONDS", env, default="60")),
+        )
+
+    def submit_decode(
+        self,
+        content: dict[str, Any],
+        media: dict[str, Any],
+        source_context: dict[str, Any],
+    ) -> dict[str, Any]:
+        payload = {
+            "params": {
+                "configId": self.config_id,
+                "skipCompleted": False,
+                "posts": [_post_payload(content, media, source_context)],
+            }
+        }
+        response = self._post_json(self.submit_path, payload)
+        return {
+            "request": redact_sensitive_payload(payload),
+            "response": redact_sensitive_payload(response),
+            "decode_task_id": _extract_decode_task_id(response),
+            "raw_response": response,
+        }
+
+    def get_decode_result(self, decode_task_id: str) -> dict[str, Any]:
+        payload = {"taskId": decode_task_id}
+        response = self._post_json(self.result_path, payload)
+        return {
+            "request": redact_sensitive_payload(payload),
+            "response": redact_sensitive_payload(response),
+            "raw_response": response,
+        }
+
+    def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
+        url = urljoin(self.base_url, path)
+        try:
+            response = self.http_client.post(
+                url,
+                json=payload,
+                headers={
+                    "Content-Type": "application/json",
+                    "Authorization": f"Bearer {self.token}",
+                },
+                timeout=self.timeout_seconds,
+            )
+            response.raise_for_status()
+            data = response.json()
+        except httpx.HTTPStatusError as exc:
+            status_code = exc.response.status_code if exc.response is not None else "unknown"
+            raise RuntimeError(f"aigc decode failed: HTTP {status_code}") from exc
+        except httpx.HTTPError as exc:
+            raise RuntimeError("aigc decode failed: network_error") from exc
+        except ValueError as exc:
+            raise RuntimeError("aigc decode failed: bad_json") from exc
+        if not isinstance(data, dict):
+            raise RuntimeError("aigc decode failed: bad_response")
+        return data
+
+
+def redact_sensitive_payload(value: Any) -> Any:
+    if isinstance(value, dict):
+        result: dict[str, Any] = {}
+        for key, child in value.items():
+            if str(key).lower() in SENSITIVE_KEYS:
+                result[f"{key}_redacted"] = "<redacted>"
+            else:
+                result[key] = redact_sensitive_payload(child)
+        return result
+    if isinstance(value, list):
+        return [redact_sensitive_payload(item) for item in value]
+    return value
+
+
+def _post_payload(
+    content: dict[str, Any],
+    media: dict[str, Any],
+    source_context: dict[str, Any],
+) -> dict[str, Any]:
+    evidence_pack = source_context.get("ext_data", {}).get("evidence_pack", {})
+    description = content.get("description") or ""
+    return {
+        "channelContentId": content.get("platform_content_id"),
+        "title": description,
+        "bodyText": description,
+        "images": [],
+        "video": media.get("play_url"),
+        "contentModal": 4,
+        "channel": 2,
+        "mergeLeve1": "",
+        "mergeLeve2": source_context.get("merge_leve2") or source_context.get("name") or "",
+        "metadata": {
+            "tags": content.get("tags", []),
+            "platform": content.get("platform", "douyin"),
+            "source_post_id": evidence_pack.get("source_post_id"),
+            "pattern_execution_id": evidence_pack.get("pattern_execution_id"),
+        },
+    }
+
+
+def _extract_decode_task_id(response: dict[str, Any]) -> str | None:
+    candidates = [
+        response.get("taskId"),
+        response.get("task_id"),
+        response.get("data", {}).get("taskId") if isinstance(response.get("data"), dict) else None,
+        response.get("data", {}).get("task_id") if isinstance(response.get("data"), dict) else None,
+    ]
+    for candidate in candidates:
+        if candidate:
+            return str(candidate)
+    return None
+
+
+def _merged_env(env_path: str | Path) -> dict[str, str]:
+    env = _load_env_file(env_path)
+    env.update({key: value for key, value in os.environ.items() if value})
+    return env
+
+
+def _load_env_file(env_path: str | Path) -> dict[str, str]:
+    path = Path(env_path)
+    if not path.exists():
+        return {}
+    env: dict[str, str] = {}
+    for line in path.read_text(encoding="utf-8").splitlines():
+        stripped = line.strip()
+        if not stripped or stripped.startswith("#") or "=" not in stripped:
+            continue
+        key, value = stripped.split("=", 1)
+        env[key.strip()] = value.strip().strip('"').strip("'")
+    return env
+
+
+def _env(
+    key: str,
+    file_env: dict[str, str],
+    default: str | None = None,
+    required: bool = False,
+) -> str:
+    value = file_env.get(key) or default
+    if required and not value:
+        raise RuntimeError(f"missing required env: {key}")
+    return value or ""

+ 25 - 0
content_agent/integrations/runtime_files.py

@@ -12,6 +12,7 @@ RUNTIME_FILENAMES = [
     "search_queries.jsonl",
     "discovered_content_items.jsonl",
     "content_media_records.jsonl",
+    "pattern_recall_evidence.jsonl",
     "rule_decisions.jsonl",
     "run_events.jsonl",
     "source_path_records.jsonl",
@@ -44,6 +45,16 @@ class LocalRuntimeFileStore:
     def append_jsonl(self, run_id: str, filename: str, rows: list[dict[str, Any]]) -> Path:
         path = self.run_dir(run_id) / filename
         path.parent.mkdir(parents=True, exist_ok=True)
+        if filename == "pattern_recall_evidence.jsonl":
+            rows = _replace_pattern_recall_rows(self.read_jsonl(run_id, filename), rows)
+            path.write_text(
+                "".join(
+                    json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n"
+                    for row in rows
+                ),
+                encoding="utf-8",
+            )
+            return path
         with path.open("a", encoding="utf-8") as file:
             for row in rows:
                 file.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n")
@@ -88,3 +99,17 @@ class LocalRuntimeFileStore:
         rows: list[dict[str, Any]],
     ) -> None:
         return None
+
+
+def _replace_pattern_recall_rows(
+    existing_rows: list[dict[str, Any]],
+    new_rows: list[dict[str, Any]],
+) -> list[dict[str, Any]]:
+    keyed_rows: dict[tuple[Any, Any, Any], dict[str, Any]] = {}
+    order: list[tuple[Any, Any, Any]] = []
+    for row in [*existing_rows, *new_rows]:
+        key = (row.get("run_id"), row.get("policy_run_id"), row.get("recall_evidence_id"))
+        if key not in keyed_rows:
+            order.append(key)
+        keyed_rows[key] = row
+    return [keyed_rows[key] for key in order]

+ 15 - 0
content_agent/interfaces.py

@@ -44,6 +44,21 @@ class QueryVariantClient(Protocol):
     ) -> QueryVariantResult: ...
 
 
+class DecodeClient(Protocol):
+    def submit_decode(
+        self,
+        content: dict[str, Any],
+        media: dict[str, Any],
+        source_context: dict[str, Any],
+    ) -> dict[str, Any]: ...
+
+    def get_decode_result(self, decode_task_id: str) -> dict[str, Any]: ...
+
+
+class CategoryMatchClient(Protocol):
+    def match_paths(self, items: list[dict[str, Any]]) -> dict[str, Any]: ...
+
+
 class PlatformSearchClient(Protocol):
     def search(self, search_query: dict[str, Any]) -> list[dict[str, Any]]: ...
 

+ 1 - 0
content_agent/models.py

@@ -33,6 +33,7 @@ class RunState(TypedDict, total=False):
     query_failures: list[dict[str, Any]]
     discovered_content_items: list[dict[str, Any]]
     content_media_records: list[dict[str, Any]]
+    pattern_recall_evidence: list[dict[str, Any]]
     evidence_bundles: list[dict[str, Any]]
     policy_bundle: dict[str, Any]
     rule_decisions: list[dict[str, Any]]

+ 127 - 1
content_agent/run_service.py

@@ -11,7 +11,9 @@ from content_agent.constants import RUNTIME_SCHEMA_VERSION
 from content_agent.errors import ContentAgentError, ErrorCode, error_from_exception
 from content_agent.graph import RunDependencies, build_run_graph
 from content_agent.integrations.composite_runtime import CompositeRuntimeStore
+from content_agent.integrations.category_match import CategoryMatchClient as HttpCategoryMatchClient
 from content_agent.integrations.database_runtime import ContentSupplyDbConfig, DatabaseRuntimeStore
+from content_agent.integrations.decode_api import AigcDecodeClient
 from content_agent.integrations.demand_source import DemandSourceService
 from content_agent.integrations.douyin import CrawapiDouyinClient
 from content_agent.integrations.mock_platform import MockPlatformClient
@@ -22,6 +24,8 @@ from content_agent.integrations.query_variant import (
 )
 from content_agent.integrations.runtime_files import LocalRuntimeFileStore
 from content_agent.interfaces import (
+    CategoryMatchClient,
+    DecodeClient,
     PlatformSearchClient,
     PolicyBundleStore,
     QueryVariantClient,
@@ -39,6 +43,10 @@ class RunService:
         policy_store: PolicyBundleStore | None = None,
         demand_source: DemandSourceService | None = None,
         query_variant_client: QueryVariantClient | None = None,
+        decode_client: DecodeClient | None = None,
+        category_match_client: CategoryMatchClient | None = None,
+        pattern_recall_max_wait_seconds: float = 1200.0,
+        pattern_recall_poll_interval_seconds: float = 5.0,
     ) -> None:
         self.runtime = runtime or LocalRuntimeFileStore(runtime_root)
         self.policy_store = policy_store or JsonPolicyBundleStore(Path("."))
@@ -46,11 +54,20 @@ class RunService:
         self.query_variant_client = query_variant_client or MissingQueryVariantClient(
             "query variant client is not configured"
         )
+        self.decode_client = decode_client or _DeterministicDecodeClient()
+        self.category_match_client = category_match_client or _DeterministicCategoryMatchClient()
+        self.pattern_recall_max_wait_seconds = pattern_recall_max_wait_seconds
+        self.pattern_recall_poll_interval_seconds = pattern_recall_poll_interval_seconds
 
     @classmethod
     def from_env(cls, runtime_root: Path | str = Path("runtime/v1")) -> "RunService":
         local_runtime = LocalRuntimeFileStore(runtime_root)
-        query_variant_client = query_variant_client_from_env(_merged_project_env())
+        env = _merged_project_env()
+        query_variant_client = query_variant_client_from_env(env)
+        decode_client = _decode_client_from_env(env)
+        category_match_client = _category_match_client_from_env(env)
+        recall_max_wait = float(env.get("CONTENTFIND_PATTERN_RECALL_MAX_WAIT_SECONDS") or 1200)
+        recall_poll_interval = float(env.get("CONTENTFIND_PATTERN_RECALL_POLL_INTERVAL_SECONDS") or 5)
         db_runtime_enabled = _env_enabled("CONTENT_AGENT_DB_RUNTIME_ENABLED")
         try:
             config = ContentSupplyDbConfig.from_env()
@@ -60,6 +77,10 @@ class RunService:
                     runtime_root=runtime_root,
                     runtime=local_runtime,
                     query_variant_client=query_variant_client,
+                    decode_client=decode_client,
+                    category_match_client=category_match_client,
+                    pattern_recall_max_wait_seconds=recall_max_wait,
+                    pattern_recall_poll_interval_seconds=recall_poll_interval,
                 )
             raise ContentAgentError(
                 ErrorCode.DB_CONFIG_MISSING,
@@ -74,6 +95,10 @@ class RunService:
                 runtime=local_runtime,
                 demand_source=demand_source,
                 query_variant_client=query_variant_client,
+                decode_client=decode_client,
+                category_match_client=category_match_client,
+                pattern_recall_max_wait_seconds=recall_max_wait,
+                pattern_recall_poll_interval_seconds=recall_poll_interval,
             )
 
         db_runtime = DatabaseRuntimeStore(config)
@@ -82,6 +107,10 @@ class RunService:
             runtime=CompositeRuntimeStore(db_runtime, local_runtime),
             demand_source=demand_source,
             query_variant_client=query_variant_client,
+            decode_client=decode_client,
+            category_match_client=category_match_client,
+            pattern_recall_max_wait_seconds=recall_max_wait,
+            pattern_recall_poll_interval_seconds=recall_poll_interval,
         )
 
     def start_run(self, request: RunStartRequest) -> RunState:
@@ -130,6 +159,10 @@ class RunService:
                 platform_client=self._platform_client(request.platform, request.platform_mode),
                 policy_store=self.policy_store,
                 query_variant_client=self.query_variant_client,
+                decode_client=self.decode_client,
+                category_match_client=self.category_match_client,
+                pattern_recall_max_wait_seconds=self.pattern_recall_max_wait_seconds,
+                pattern_recall_poll_interval_seconds=self.pattern_recall_poll_interval_seconds,
             )
             graph = build_run_graph(deps)
             state = graph.invoke(initial_state)
@@ -491,6 +524,99 @@ def _decision_summary(decisions: list[dict[str, Any]]) -> dict[str, Any]:
     }
 
 
+def _decode_client_from_env(env: dict[str, str]) -> DecodeClient:
+    if env.get("CONTENTFIND_API_AIGC_BASE_URL") and (
+        env.get("CONTENTFIND_API_AIGC_TOKEN") or env.get("AIGC_TOKEN")
+    ):
+        return AigcDecodeClient.from_env()
+    return _MissingDecodeClient("AIGC decode client is not configured")
+
+
+def _category_match_client_from_env(env: dict[str, str]) -> CategoryMatchClient:
+    return HttpCategoryMatchClient.from_env()
+
+
+class _MissingDecodeClient:
+    def __init__(self, reason: str) -> None:
+        self.reason = reason
+
+    def submit_decode(
+        self,
+        content: dict[str, Any],
+        media: dict[str, Any],
+        source_context: dict[str, Any],
+    ) -> dict[str, Any]:
+        raise RuntimeError(self.reason)
+
+    def get_decode_result(self, decode_task_id: str) -> dict[str, Any]:
+        raise RuntimeError(self.reason)
+
+
+class _DeterministicDecodeClient:
+    def submit_decode(
+        self,
+        content: dict[str, Any],
+        media: dict[str, Any],
+        source_context: dict[str, Any],
+    ) -> dict[str, Any]:
+        evidence_pack = source_context.get("ext_data", {}).get("evidence_pack", {})
+        seed_terms = evidence_pack.get("seed_terms") or ["爱国情感"]
+        return {
+            "decode_status": "success",
+            "decode_task_id": f"fake_decode_{content.get('platform_content_id')}",
+            "request": {"params": {"configId": 58}},
+            "response": {"status": "SUCCESS"},
+            "dataContent": {
+                "目的点": [
+                    {
+                        "点": seed_terms[0],
+                        "实质": [{"名称": term} for term in seed_terms],
+                    }
+                ],
+                "关键点": [{"点": term, "类型": "实质"} for term in seed_terms],
+                "分词结果": [{"词": content.get("description", "")}],
+            },
+        }
+
+    def get_decode_result(self, decode_task_id: str) -> dict[str, Any]:
+        return {
+            "decode_status": "success",
+            "decode_task_id": decode_task_id,
+            "request": {"taskId": decode_task_id},
+            "response": {"status": "SUCCESS"},
+            "dataContent": {
+                "目的点": [{"点": "爱国情感", "实质": [{"名称": "人物故事"}]}],
+                "关键点": [{"点": "爱国情感", "类型": "实质"}],
+            },
+        }
+
+
+class _DeterministicCategoryMatchClient:
+    def match_paths(self, items: list[dict[str, Any]]) -> dict[str, Any]:
+        rows = [
+            {
+                "term": item.get("term"),
+                "paths": [
+                    {
+                        "category_path": "/理念/观念/个人观念/情感认同/国家民族认同/爱国情感",
+                        "score": 0.91,
+                    }
+                ],
+            }
+            for item in items
+        ]
+        return {
+            "request": {
+                "source_type": "实质",
+                "top_k": 10,
+                "min_score": 0.6,
+                "items": items,
+            },
+            "response": {"data": rows},
+            "raw_response": {"data": rows},
+        }
+
+
 def _env_enabled(key: str) -> bool:
     value = _load_project_env().get(key)
     if os.environ.get(key) is not None:

+ 38 - 8
product_documents/抖音游走策略/runtime_v1_records_schema.md

@@ -2,7 +2,7 @@
 
 ## 阅读约定
 
-本文第一次出现核心字段或枚举时,会用括号补一句中文解释。常见字段包括:`platform_content_id`(平台内容 ID,抖音下等于抖音视频 ID)、`platform_author_id`(抖音作者 ID)、`run_id`(本次运行 ID)、`policy_run_id`(本次策略执行 ID)、`schema_version`(JSON 文件结构版本)、`record_schema_version`(JSONL 单行结构版本)、`source_evidence`(来源证据)、`search_query_effect_status`(搜索词效果状态)、`ADD_TO_CONTENT_POOL`(入池)、`KEEP_CONTENT_FOR_REVIEW`(待复看)、`HOLD_CONTENT_PENDING`(待观察)、`REJECT_CONTENT`(淘汰)、`source_path_records.jsonl`(来源记录文件)、`rule_decisions.jsonl`(规则判断结果文件)。
+本文第一次出现核心字段或枚举时,会用括号补一句中文解释。常见字段包括:`platform_content_id`(平台内容 ID,抖音下等于抖音视频 ID)、`platform_author_id`(抖音作者 ID)、`run_id`(本次运行 ID)、`policy_run_id`(本次策略执行 ID)、`schema_version`(JSON 文件结构版本)、`record_schema_version`(JSONL 单行结构版本)、`source_evidence`(来源证据)、`search_query_effect_status`(搜索词效果状态)、`pattern_recall_evidence.jsonl`(Pattern 回扣证据文件)、`ADD_TO_CONTENT_POOL`(入池)、`KEEP_CONTENT_FOR_REVIEW`(待复看)、`HOLD_CONTENT_PENDING`(待观察)、`REJECT_CONTENT`(淘汰)、`source_path_records.jsonl`(来源记录文件)、`rule_decisions.jsonl`(规则判断结果文件)。
 
 本文定义 V1 本地 JSON / JSONL 兼容导出格式,不定义生产事实层。生产事实层是云上 MySQL 的 `content_agent_*` 表;本地文件用于开发调试、兼容验收和回放,不是 show 后端联调数据。默认目录:
 
@@ -23,6 +23,7 @@ V1 不假设视频下载或 OSS 已接入。抖音接口默认只沉淀视频 ID
 | `search_queries.jsonl` | 由 Pattern item 或 tag 生成的 query | Query 模块 |
 | `discovered_content_items.jsonl` | 抖音搜索和作者作品返回的原始发现内容 | 发现内容与证据模块,平台接入提供结果 |
 | `content_media_records.jsonl` | 视频媒体可用性与链接状态 | 发现内容与证据模块,平台接入提供结果 |
+| `pattern_recall_evidence.jsonl` | 新发现视频的解构、分类树匹配和 Pattern 回扣证据 | Pattern 回扣模块 |
 | `rule_decisions.jsonl` | 规则判断结果文件 | 判断模块 |
 | `source_path_records.jsonl` | 来源记录文件,记录节点之间从哪里来到哪里去 | 运行记录模块 |
 | `search_clues.jsonl` | query / tag 的线索效果 | 运行记录模块 |
@@ -55,6 +56,8 @@ V1 不假设视频下载或 OSS 已接入。抖音接口默认只沉淀视频 ID
 | `author_expand_decision` | `EXPAND_AUTHOR_WORKS` / `EXPAND_AUTHOR_WORKS_LOW_BUDGET` / `HOLD_AUTHOR_PENDING` / `DO_NOT_EXPAND_AUTHOR` |
 | `author_asset_decision` | `STORE_AUTHOR_ASSET` / `HOLD_AUTHOR_PENDING` / `REJECT_AUTHOR` |
 | `content_media_status` | `metadata_only` / `downloaded_local` / `oss_uploaded` / `unavailable` |
+| `decode_status` | `pending` / `running` / `success` / `failed` |
+| `recall_status` | `matched` / `pending` / `failed` / `rejected` / `no_match` |
 | `final_asset_status` | `pooled` / `review_asset` / `stored_author` / `clue_only` |
 
 `search_query_effect_status` 含义:`success` 表示产生入池或待复看结果;`weak_effective` 表示只产生待观察结果或质量偏弱;`pending` 表示暂时还不能评价;`blocked` 表示被接口、画像、媒体或来源记录问题阻断;`failed` 表示空召回或全部淘汰。
@@ -185,7 +188,32 @@ V1 不假设视频下载或 OSS 已接入。抖音接口默认只沉淀视频 ID
 {"record_schema_version":"runtime_record.v1","run_id":"v1_run_001","policy_run_id":"policy_run_001","platform":"douyin","platform_content_id":"7390000000000000099","content_media_status":"unavailable","content_metadata_source":"douyin_keyword_search","failure_reason":"no_play_url_returned","raw_payload":{"platform":"douyin","platform_content_id":"7390000000000000099"},"created_at":"2026-06-05T10:04:10+08:00"}
 ```
 
-## 8. `rule_decisions.jsonl`
+## 8. `pattern_recall_evidence.jsonl`
+
+用途:保存新发现视频自己的 Pattern 回扣证据。它记录解构状态、分类树匹配路径、回扣判断和脱敏后的请求 / 响应快照;不能写入或改写上游 `source_post_id`、`matched_post_ids`、`decode_case_ids`。
+
+写入边界:由 P4 Pattern 回扣模块追加或更新。首次 pending 写入后,后续补跑成功必须更新同一个 `recall_evidence_id`,不能新建另一个 evidence id。
+
+必填:`record_schema_version`、`run_id`、`policy_run_id`、`recall_evidence_id`、`content_discovery_id`、`platform`、`platform_content_id`、`decode_status`、`recall_status`、`raw_payload`。  
+可空:`decode_task_id`、`matched_terms`、`matched_category_paths`、`primary_matched_category_path`、`decode_elements`、`match_paths_request`、`match_paths_response`、`evidence_summary`、`pending_reason`、`failure_reason`。
+
+关键约束:
+
+- `decode_status` 只允许 `pending / running / success / failed`。
+- `recall_status` 只允许 `matched / pending / failed / rejected / no_match`。
+- `matched` 必须有 `pattern_recall_evidence_id`、`matched_terms`、`matched_category_paths`;多路径命中时必须写 `primary_matched_category_path`。
+- 标题、tag、desc 只能作为解构输入或辅助解释,不能单独构成 `matched`。
+- raw request / response 可以保存,但必须脱敏 token、cookie、签名、账号、敏感 header 和敏感错误文本。
+- 视频链接不可用不等于 Pattern 回扣失败;链接原因写 `failure_reason` 或 `raw_payload.failure_reason`。
+
+样例:
+
+```jsonl
+{"record_schema_version":"runtime_record.v1","run_id":"v1_run_001","policy_run_id":"policy_run_001","recall_evidence_id":"recall_001","content_discovery_id":"c_001","platform":"douyin","platform_content_id":"7390000000000000000","decode_status":"success","decode_task_id":"decode_task_001","recall_status":"matched","matched_terms":["爱国情感","人物故事"],"matched_category_paths":["/理念/观念/个人观念/情感认同/国家民族认同/爱国情感"],"primary_matched_category_path":"/理念/观念/个人观念/情感认同/国家民族认同/爱国情感","decode_elements":{"strong_terms":["爱国情感","人物故事"]},"match_paths_request":{"source_type":"实质","top_k":10,"min_score":0.6},"match_paths_response":{"matched_path_count":1},"evidence_summary":{"basis":"decode_strong_terms_match_category_path"},"raw_payload":{"recall_evidence_id":"recall_001","redacted":true},"created_at":"2026-06-05T10:04:20+08:00"}
+{"record_schema_version":"runtime_record.v1","run_id":"v1_run_001","policy_run_id":"policy_run_001","recall_evidence_id":"recall_002","content_discovery_id":"c_002","platform":"douyin","platform_content_id":"7390000000000000001","decode_status":"running","recall_status":"pending","pending_reason":"decode_timeout_20m","raw_payload":{"recall_evidence_id":"recall_002","redacted":true},"created_at":"2026-06-05T10:24:20+08:00"}
+```
+
+## 9. `rule_decisions.jsonl`
 
 用途:保存规则包判断。每条视频、作者、tag、路径停止判断都写一行。
 
@@ -202,7 +230,7 @@ V1 不假设视频下载或 OSS 已接入。抖音接口默认只沉淀视频 ID
 {"record_schema_version":"runtime_record.v1","run_id":"v1_run_001","policy_run_id":"policy_run_001","decision_id":"d_003","policy_bundle_id":"douyin_policy_bundle_v1","rule_pack_id":"douyin_content_discovery_rule_pack_v1","rule_pack_version":"1.0.0","strategy_version":"V1","decision_target_type":"content","decision_target_id":"7390000000000000099","triggered_blocking_rules":["missing_content_portrait"],"scorecard":{},"score":null,"decision_action":"REJECT_CONTENT","decision_reason_code":"missing_content_portrait","source_evidence":{"run_id":"v1_run_001","policy_run_id":"policy_run_001","source_post_id":"51978710","discovered_platform_content_id":"7390000000000000099"},"decision_input_snapshot_id":"evidence_bundle:v1_run_001:c_099:douyin_content_discovery_rule_pack_v1:1.0.0","decision_evidence_refs":["source_evidence","content_audience_profile"],"decision_replay_data":{"policy_run_id":"policy_run_001","policy_bundle_id":"douyin_policy_bundle_v1","strategy_version":"V1","rule_pack_version":"1.0.0","runtime_record_schema_version":"runtime_record.v1","decision_evidence_refs":["source_evidence"],"matched_gate":"missing_content_portrait"},"raw_payload":{"decision_id":"d_003"}}
 ```
 
-## 9. `source_path_records.jsonl`
+## 10. `source_path_records.jsonl`
 
 用途:保存每一次运行事实边,保证任何结果都能反查来源路径。
 
@@ -218,7 +246,7 @@ V1 不假设视频下载或 OSS 已接入。抖音接口默认只沉淀视频 ID
 {"record_schema_version":"runtime_record.v1","run_id":"v1_run_001","policy_run_id":"policy_run_001","source_path_record_id":"path_002","from_node_type":"SearchQuery","from_node_id":"q_001","to_node_type":"Content","to_node_id":"7390000000000000000","source_path_type":"search_query_to_content","rule_pack_id":"douyin_content_discovery_rule_pack_v1","decision_id":"d_001","discovery_start_source":"pattern_itemset","previous_discovery_step":"query_direct","raw_payload":{"source_path_record_id":"path_002"},"created_at":"2026-06-05T10:03:00+08:00"}
 ```
 
-## 10. `search_clues.jsonl`
+## 11. `search_clues.jsonl`
 
 用途:记录 query、tag query 的阶段效果。tag query 暂不算正式资产,只是运行期线索观测。
 
@@ -234,7 +262,7 @@ V1 不假设视频下载或 OSS 已接入。抖音接口默认只沉淀视频 ID
 {"record_schema_version":"runtime_record.v1","run_id":"v1_run_001","policy_run_id":"policy_run_001","clue_id":"clue_002","search_query_id":"q_002","search_query":"爱国人物故事","discovery_start_source":"pattern_itemset","previous_discovery_step":"pattern_query","result_count":5,"pooled_content_count":0,"review_content_count":1,"pending_content_count":1,"rejected_content_count":3,"search_query_effect_status":"weak_effective","walk_next_step":"low_budget_pending","raw_payload":{"clue_id":"clue_002"}}
 ```
 
-## 11. `run_events.jsonl`
+## 12. `run_events.jsonl`
 
 用途:流水日志,记录每个关键动作是否成功。
 
@@ -248,7 +276,7 @@ V1 不假设视频下载或 OSS 已接入。抖音接口默认只沉淀视频 ID
 {"record_schema_version":"runtime_record.v1","run_id":"v1_run_001","policy_run_id":"policy_run_001","event_id":"evt_002","event_type":"content_portrait_missing","status":"blocked","input_ref":"discovered_content_items.jsonl:c_099","output_ref":"rule_decisions.jsonl:d_003","raw_payload":{"event_id":"evt_002"},"created_at":"2026-06-05T10:04:00+08:00"}
 ```
 
-## 12. `final_output.json`
+## 13. `final_output.json`
 
 用途:一次 V1 运行 的最终复盘和资产输出。
 
@@ -366,7 +394,7 @@ V1 不假设视频下载或 OSS 已接入。抖音接口默认只沉淀视频 ID
 }
 ```
 
-## 13. `strategy_review.json`
+## 14. `strategy_review.json`
 
 用途:保存本次策略复盘结果。V1 只保存建议,不自动修改规则包。
 
@@ -393,7 +421,7 @@ V1 不假设视频下载或 OSS 已接入。抖音接口默认只沉淀视频 ID
 }
 ```
 
-## 14. 校验口径
+## 15. 校验口径
 
 - DB 事实写入是主验收;本节只校验本地兼容导出是否仍可解析和回放。
 - JSON 文件必须能 `JSON.parse`。
@@ -410,3 +438,5 @@ V1 不假设视频下载或 OSS 已接入。抖音接口默认只沉淀视频 ID
 - `source_evidence` 必须完整继承上游 `evidence_pack`,不能把新召回的 `platform_content_id` 写进 `source_post_id` 或 `matched_post_ids`。
 - `final_output.json.decision_records` 必须覆盖全部规则判断,确保入池、待复看、待观察和淘汰都能复盘。
 - `final_output.json.summary` 和 `search_clues.jsonl` 的计数必须与 `rule_decisions.jsonl.decision_action` 对齐。
+- `pattern_recall_evidence.jsonl` 中 `recall_status=matched` 的记录必须有 `recall_evidence_id`、`matched_terms`、`matched_category_paths`,多路径命中时必须有 `primary_matched_category_path`。
+- `pattern_recall_evidence.jsonl` 的 raw request / response 必须脱敏,不能包含 token、cookie、签名、账号、敏感 header 或敏感错误文本。

+ 1 - 1
scripts/validate_schema_registry.py

@@ -20,7 +20,7 @@ EXPECTED_COUNTS = {
     "unique_index_count": 18,
     "secondary_index_count": 29,
     "business_module_count": 10,
-    "runtime_file_count": 11,
+    "runtime_file_count": 12,
 }
 RAW_PAYLOAD_TERMS = {
     "raw_payload",

+ 33 - 11
tech_documents/Pattern回扣与分类树/01_Pattern回扣流程.md

@@ -24,8 +24,10 @@
     "/理念/知识/公共管理/政策法规/治理监督/反腐案例"
   ],
   "matched_terms": ["贪污公款", "贪污案例"],
-  "decode_status": "SUCCESS",
-  "match_status": "matched"
+  "decode_status": "success",
+  "recall_status": "matched",
+  "match_status": "matched",
+  "pattern_recall_evidence_id": "recall_001"
 }
 ```
 
@@ -37,8 +39,10 @@
 {
   "pattern_recall": "pattern_recall_pending",
   "category_or_element_binding": "pattern_recall_pending",
-  "decode_status": "RUNNING",
-  "match_status": "pending"
+  "decode_status": "running",
+  "recall_status": "pending",
+  "match_status": "pending",
+  "pending_reason": "decode_running"
 }
 ```
 
@@ -107,11 +111,13 @@ POST https://aigc-api.aiddit.com/aigc/api/task/decode/result
 
 | 状态 | 处理 |
 |---|---|
-| `SUCCESS` | 解析 `dataContent`,继续分类树匹配 |
-| `decode_pending` / `decode_running` | 本轮标记 `match_status=pending`,发现内容进入 `HOLD_CONTENT_PENDING` 或继续等待 |
-| `FAILED` | 标记 `decode_status=FAILED`,保留错误原因摘要,不进入 Pattern 回扣通过 |
+| `success` | 解析 `dataContent`,继续分类树匹配 |
+| `pending` / `running` | 本轮标记 `recall_status=pending` 和 `match_status=pending`,记录原因,不影响 run 状态 |
+| `failed` | 标记 `decode_status=failed`,保留脱敏错误原因摘要,不进入 Pattern 回扣通过 |
 
-V1 不能同步阻塞太久。建议第一版把解构做成可轮询状态:本次运行先记 `HOLD_CONTENT_PENDING`,后续补跑回扣和规则判断。
+外部接口可能返回 `SUCCESS / RUNNING / PENDING / FAILED` 等大写状态;CFA integration 层必须统一归一为小写 `success / running / pending / failed` 后再写 runtime 和 DB。
+
+V1 同步等待最长 20 分钟。超过 20 分钟仍未完成时,本次内容写 `recall_status=pending` 和 `pending_reason=decode_timeout_20m`,不影响 run 状态;后续补跑成功时更新同一个 `recall_evidence_id`。
 
 ## 4. 解构元素抽取
 
@@ -196,11 +202,24 @@ pattern_recall = matched
 category_or_element_binding = direct_match 或 tree_walk_match
 ```
 
-否则保持:
+如果解构或分类树仍未完成,保持:
 
 ```text
 pattern_recall = pattern_recall_pending
 category_or_element_binding = pattern_recall_pending
+recall_status = pending
+```
+
+如果解构和分类树已完成但不能解释回当前 Pattern,写:
+
+```text
+recall_status = no_match
+```
+
+如果视频不可解构、内容缺失、返回结构坏或证据无效,写:
+
+```text
+recall_status = rejected
 ```
 
 回扣通过后仍要保留来源边界:
@@ -257,11 +276,14 @@ V1 接入后需要补充记录:
 ```json
 {
   "pattern_recall_evidence": {
-    "decode_status": "SUCCESS",
+    "recall_evidence_id": "recall_001",
+    "decode_status": "success",
+    "recall_status": "matched",
     "matched_terms": ["贪污公款", "贪污案例"],
     "matched_category_paths": [
       "/理念/知识/公共管理/政策法规/治理监督/反腐案例"
     ],
+    "primary_matched_category_path": "/理念/知识/公共管理/政策法规/治理监督/反腐案例",
     "match_api": "library.aiddit.com/api/search/categories/match-paths"
   }
 }
@@ -273,7 +295,7 @@ V1 接入后需要补充记录:
 
 - 解构提交接口可达。
 - 解构查询接口可达。
-- 真实发现视频 `7386896932536438066` 返回 `SUCCESS`。
+- 真实发现视频 `7386896932536438066` 返回成功状态;CFA runtime 写入时统一归一为 `success`。
 - 解构结果能抽出实质词和关键点。
 - 分类树 `match-paths` 接口可达。
 - 解构词可以命中反腐相关路径,但接口返回会漂移,不能把某次分数写成稳定业务合同。

+ 80 - 21
tech_documents/工程落地/04_V1阶段开发计划.md

@@ -453,6 +453,7 @@ DB 备注:
 - 未实现 / 部分实现:没有 `pattern_recall_evidence.jsonl`。
 - 未实现 / 部分实现:没有持久化 `evidence_bundles.jsonl`。
 - 当前代码默认值:`content_media_status=metadata_only`。
+- 当前代码默认值:P3 已拿到平台元数据和内容画像;P4 仍不以下载视频文件作为 Pattern 回扣前置条件。
 - 当前代码默认值:`pattern_recall` 和 `category_or_element_binding` 默认来自平台结果。
 - 当前代码默认值:真实待复看仍是 `pattern_recall_pending`。
 - 当前代码默认值:缺画像时 `content_audience_profile` 为空。
@@ -460,24 +461,37 @@ DB 备注:
 已拍板:
 
 - 真实待复看不能靠标题、tag、desc 直接升级为 `matched`。
+- V1 当前只做视频内容回扣;Pattern 回扣必须以视频结构解构和分类树匹配为准,标题、tag、desc 只能作为辅助输入或辅助解释,不能单独让内容进入 `matched`。
 - Pattern 回扣必须经过解构接口 + 分类树 `match-paths`。
 - `pattern_recall_pending` 不能表示回扣通过。
 - `matched` 是 Pattern 回扣通过态。
+- 新增 `pattern_recall_evidence.jsonl`,并写入现有 `content_agent_pattern_recall_evidence` 表,用于记录发现内容自己的 Pattern 回扣证据。
+- `pattern_recall_evidence.jsonl` 至少记录 `recall_evidence_id`、`content_discovery_id`、`platform_content_id`、`decode_status`、`decode_task_id`、`recall_status`、`matched_terms`、`matched_category_paths`、`decode_elements`、`match_paths_request`、`match_paths_response`、`evidence_summary`、`raw_payload`。
+- `decode_status` 枚举统一使用小写:`pending`、`running`、`success`、`failed`;不在代码和运行记录里混用 `SUCCESS` / `FAILED` / `RUNNING`。
+- `recall_status` 枚举统一使用:`matched`、`pending`、`failed`、`rejected`、`no_match`。
+- 解构 `success` 后才允许进入分类树 `match-paths` 并判断是否回扣。
+- 解构 `pending` / `running` 不阻断整次 run,但只能写 `pattern_recall_pending`,进入 `HOLD_CONTENT_PENDING` 或后续补跑,不能通过 Pattern hard gate。
+- 解构 `failed` 不允许 Pattern 通过;视频不可解构、内容缺失、返回结构异常或结构坏时,由规则包处理为 reject。
+- `EvidenceBundle.pattern_match_result` 扩展为承载 `pattern_recall`、`category_or_element_binding`、`decode_status`、`match_status`、`matched_terms`、`matched_category_paths`、`primary_matched_category_path`、`decode_elements`、`pattern_recall_evidence_id`。
+- `content_audience_profile` 缺失继续维持 P3 已拍板逻辑:portrait 失败重试一次;重试后仍失败则画像缺失,进入规则包判断并触发 `missing_content_portrait`,不在 P4 另设 pending。
+- `content_media_status=metadata_only` 作为 V1 长期允许状态。P4 不把下载视频文件作为 Pattern 回扣前置条件;Crawapi 若返回视频链接,应保存 `play_url`,拿不到时才为 `None`。
+- `risk_level=unknown` 允许进入规则判断,但不能等同于低风险;规则包必须显式处理 unknown。
+- P4 可以同步等待解构结果,但最长等待 20 分钟;超过 20 分钟仍未完成时写 `decode_status=running` 或 `pending`、`recall_status=pending`,记录 pending 原因,不影响 run 状态,本轮不无限阻塞。
+- `no_match` 和 `rejected` 必须分开:`no_match` 表示解构和分类树匹配完成但不能解释回当前 Pattern;`rejected` 表示视频不可解构、内容缺失、返回结构异常、结构坏或证据无效。
+- 同一视频命中多个分类树路径时,保留全部 `matched_category_paths`,并写入 `primary_matched_category_path` 作为规则包和复盘使用的主路径。
+- 视频链接不可用不等于 Pattern 回扣失败;应在 `content_media_records.jsonl.failure_reason` 或 `pattern_recall_evidence.raw_payload` 记录链接不可用原因,再按是否仍能完成解构决定回扣状态。
+- pending 内容后续补跑成功时,更新同一个 `recall_evidence_id` 对应的证据记录,不新增新的 `recall_evidence_id`。
+- `pattern_recall_evidence.jsonl.raw_payload` 可保存解构接口和分类树接口的 raw request / response,但错误消息、账号、token、cookie、签名、请求头敏感字段必须脱敏。
+- P4 validator 必须升级:当 `pattern_match_result.pattern_recall=matched` 时,必须同时存在 `pattern_recall_evidence_id`、`matched_terms`、`matched_category_paths`,且能反查到对应 `pattern_recall_evidence.jsonl` 行。
+- 规则包不直接调用解构接口或分类树接口,只读取 `EvidenceBundle.pattern_match_result`、`content_audience_profile`、`content_risk_check` 等证据字段。
 
 开工前必须拍板:
 
-- 是否新增 `pattern_recall_evidence.jsonl`。
-- 解构 pending / running / failed 如何进入补跑或规则判断。
-- 解构结果、分类树路径、回扣结论写入 `EvidenceBundle` 哪些字段。
-- `content_audience_profile` 缺失时是 retry、pending、blocked,还是 reject。
-- `content_media_status=metadata_only` 是否作为 V1 长期状态。
-- `risk_level=unknown` 是否允许进入规则判断。
+- 无。
 
 可先按当前默认值推进:
 
 - 未接入回扣前,真实待复看继续保守为 `pattern_recall_pending`。
-- 未拍板新增 `pattern_recall_evidence.jsonl` 前,只能把它写成建议新增文件,不能作为硬验收文件。
-- `content_media_status=metadata_only` 可保留到媒体下载 / OSS 方案拍板后再改。
 
 本阶段不处理:
 
@@ -492,8 +506,12 @@ DB 备注:
 - `content_agent/business_modules/content_discovery/__init__.py`
 - `content_agent/business_modules/content_discovery/content_discovery_builder.py`
 - `content_agent/business_modules/content_discovery/source_evidence.py`
+- `content_agent/business_modules/content_discovery/pattern_recall/`
+- `content_agent/integrations/decode_api.py`
+- `content_agent/integrations/category_match.py`
 - `content_agent/graph.py`
-- 后续可新增解构接口和分类树接入文件。
+- `content_agent/integrations/database_runtime.py`
+- `content_agent/business_modules/run_record/validation.py`
 
 主要函数:
 
@@ -502,44 +520,84 @@ DB 备注:
 - `_build_evidence_bundle()`
 - `build_source_evidence()`
 - 后续 `recall_pattern.run()`
+- 后续 `decode.submit_decode_task()`
+- 后续 `decode.get_decode_result()`
+- 后续 `category_match.match_paths()`
+- 后续 `recall_decision.decide_recall()`
 
 开发顺序:
 
 1. 平台结果转 `DiscoveredContentItem`。
 2. 写入 `discovered_content_items.jsonl`。
 3. 写入 `content_media_records.jsonl`。
-4. 生成 `EvidenceBundle`。
-5. 深拷贝上游 `evidence_pack` 到 `source_evidence`。
-6. 接入解构接口,拿到发现视频元素。
-7. 把解构元素提交分类树 `match-paths`。
-8. 根据分类路径和强证据词生成 Pattern 回扣结论。
-9. 在 LangGraph 中把 `recall_pattern` 放到 `build_discovered_content -> load_policy` 之间。
+4. 若平台返回视频链接,写入 `content_media_records.jsonl.play_url`;未返回时保留 `None`。不下载视频,不上传 OSS。
+5. 生成 `EvidenceBundle`。
+6. 深拷贝上游 `evidence_pack` 到 `source_evidence`,不得改写 `source_post_id` 或 `matched_post_ids`。
+7. 在 LangGraph 中把 `recall_pattern` 放到 `build_discovered_content -> load_policy` 之间。
+8. `recall_pattern` 为每条发现内容创建 `recall_evidence_id`,提交解构任务或查询已有解构结果。
+9. 同步等待解构结果,最长 20 分钟;超时仍未完成则写 `recall_status=pending` 和 pending 原因,不继续无限等待,也不影响 run 状态。
+10. 解构 `success` 时抽取强证据词和实质元素。
+11. 把解构元素提交分类树 `match-paths`。
+12. 根据分类路径、强证据词和本次 `evidence_pack` / `pattern_seed_pack` 的可解释关系生成 Pattern 回扣结论。
+13. 写入 `pattern_recall_evidence.jsonl`,并通过 `DatabaseRuntimeStore` 写入 `content_agent_pattern_recall_evidence`。
+14. 把 `pattern_recall_evidence_id`、解构状态、全部分类树路径、主分类树路径和回扣结论装入 `EvidenceBundle.pattern_match_result`。
+15. 解构 `pending` / `running` 写 `pattern_recall_pending` 和 `match_status=pending`,进入规则包判断,不伪装成通过。
+16. 解构 `failed`、视频不可解构、内容缺失、返回结构异常或结构坏,写失败证据,进入规则包 reject。
+17. 后续补跑 pending 内容时,复用并更新同一个 `recall_evidence_id` 的证据记录。
 
 达到效果:
 
 - 真实待复看不会因为标题或 tag 看起来像,就直接变成 `matched`。
 - 只有解构和分类树证据成立,才允许 `pattern_recall=matched`。
+- 每个新发现视频的回扣证据可以通过 `recall_evidence_id` 复盘。
+- `no_match` 和 `rejected` 在运行记录中可区分,方便判断是“内容不匹配”还是“证据无效 / 不可解构”。
+- 新发现 `platform_content_id` 不污染上游 `source_post_id`、`matched_post_ids` 或 `decode_case_ids`。
+- V1 可以长期保持 `metadata_only`,Pattern 回扣不依赖视频文件下载。
 
 输出:
 
 - `discovered_content_items.jsonl`
 - `content_media_records.jsonl`
-- 若拍板新增,则输出 `pattern_recall_evidence.jsonl`
+- `pattern_recall_evidence.jsonl`
+- 扩展后的 `EvidenceBundle.pattern_match_result`
 
 验证:
 
 - `discovered_platform_content_id` 不覆盖 `source_post_id`。
 - `matched_post_ids` 不加入新发现内容 ID。
 - `pattern_recall_pending` 不能当作 Pattern 通过。
-- 解构 pending / failed 有明确状态。
+- 解构 `pending` / `running` / `failed` 有明确状态和证据记录。
+- `pattern_recall_evidence.jsonl` 每行能通过 `recall_evidence_id`、`content_discovery_id`、`platform_content_id` 反查发现内容。
+- `EvidenceBundle.pattern_match_result.pattern_recall=matched` 时必须有 `pattern_recall_evidence_id`、`matched_terms` 和 `matched_category_paths`。
+- 多路径命中时必须保留全部 `matched_category_paths`,并写入 `primary_matched_category_path`。
+- `decode_status` 和 `recall_status` 只能使用已拍板枚举。
+- 解构等待超过 20 分钟时不能继续阻塞 run,必须进入 pending 状态并记录原因,不影响 run 状态。
+- pending 内容补跑成功时必须更新同一个 `recall_evidence_id`。
+- 视频链接不可用时必须记录原因,但不能单独作为 Pattern 回扣失败依据。
+- raw request / response 可以保存,但敏感字段必须脱敏。
+- 规则包调用边界检查:规则判断模块不得直接调用解构接口或分类树接口。
+- `content_media_status=metadata_only` 不阻塞 Pattern 回扣;有平台视频链接时应保存 `play_url`,无链接时才为 `None`。
+- `risk_level=unknown` 可以进入规则判断,但不能被当作 `low`。
 
 测试用例:
 
 - 回扣成功视频。
-- 解构 `decode_pending` / `decode_running`。
-- 解构 `FAILED`。
+- 解构 `pending` / `running`。
+- 解构 `failed`。
+- 解构超过 20 分钟仍未完成。
+- pending 补跑成功后更新同一个 `recall_evidence_id`。
+- 视频不可解构、内容缺失、返回结构异常或结构坏。
+- 解构成功但不回扣当前 Pattern,写 `recall_status=no_match`。
+- 证据无效或不可解构,写 `recall_status=rejected`。
 - 分类树泛词命中但不通过。
+- 只有标题 / tag / desc 命中但视频结构解构不通过时,不能写 `matched`。
+- 多分类路径命中时保留全部路径并写 `primary_matched_category_path`。
+- 视频链接不可用但文本 / metadata 仍可完成解构时,不因链接不可用直接判回扣失败。
+- `matched` 缺 `pattern_recall_evidence_id`、`matched_terms` 或 `matched_category_paths` 时 validator 失败。
+- raw request / response 含敏感字段时必须脱敏。
 - 画像缺失。
+- `risk_level=unknown` 进入规则判断但不按低风险处理。
+- `content_media_status=metadata_only` 且 `play_url` 存在 / 缺失两种情况。
 - 新发现内容 ID 污染上游字段时校验失败。
 
 ## P5 判断规则模块和策略版本管理模块
@@ -564,13 +622,14 @@ DB 备注:
 已拍板:
 
 - Video 规则包里 Pattern 回扣是 hard gate。
+- V1.0 只执行 Video rule pack;Author / Hashtag / Path / Budget dispatch 不进入 V1.0 主链路。
 - `pattern_recall_pending` 不是通过态。
 - `matched` 才是 Pattern 回扣通过态。
 - 规则阈值、动作、原因码继续从 JSON 读取,不在代码里改业务含义。
+- `risk_level=unknown` 由 P4 允许进入规则判断,但 P5 不能当作低风险;规则包必须显式处理 unknown,例如扣分、待复看或按风险策略 reject。
 
 开工前必须拍板:
 
-- V1.0 是否只执行 Video rule pack。
 - 画像缺失是 `REJECT_CONTENT`、`HOLD_CONTENT_PENDING`、`blocked`,还是先 retry。
 - `search_query_effect_status` 由规则包输出,还是继续由代码映射。
 - score 缺失时是 blocked、pending、reject,还是 fallback。
@@ -579,7 +638,7 @@ DB 备注:
 
 可先按当前默认值推进:
 
-- 未拍板多规则包 dispatch 前,继续只执行 Video rule pack,但必须标为代码临时默认
+- V1.0 已拍板只执行 Video rule pack;Author / Hashtag / Path / Budget dispatch 保持后置
 - 未拍板 `search_query_effect_status` 来源前,继续由代码映射,但测试要覆盖映射关系。
 - 未拍板 scorecard 细项前,先保留现有 fallback,不把空维度写成真实评分。
 

+ 187 - 0
tech_documents/工程落地/implementation_briefs/P4/00_P4_Brief_Index.md

@@ -0,0 +1,187 @@
+# P4 Implementation Brief Index
+
+状态:本目录是 P4 发现内容与证据模块 + Pattern 回扣实施前的短实施简报集合。它不是新的架构权威源,也不是产品计划;长期事实仍以 `tech_documents/工程落地/04_V1阶段开发计划.md`、`tech_documents/Pattern回扣与分类树/01_Pattern回扣流程.md`、`product_documents/抖音游走策略/runtime_v1_records_schema.md`、`tech_documents/数据库字段总览/content_agent_schema_registry.json` 和当前代码为准。
+
+## 目标
+
+为 P4 保留实施清单,颗粒度固定到文件级、函数 / 类级、数据合同级、验证级和失败归因级。
+
+P4 只处理视频内容 Pattern 回扣:
+
+1. P4A:媒体链接与 `metadata_only`。
+2. P4B:`pattern_recall_evidence.jsonl` 与 DB runtime 映射。
+3. P4C:解构提交 / 查询、状态归一和 20 分钟等待。
+4. P4D:分类树 `match-paths` client。
+5. P4E:回扣判断与 EvidenceBundle 扩展。
+6. P4F:LangGraph 接入与规则包边界。
+7. P4G:P4 验收与 drift guard。
+
+本目录不处理:
+
+- 媒体下载。
+- OSS 上传。
+- 作者本身 Pattern 回扣。
+- tag 扩散。
+- Path / Budget dispatch。
+- P5 规则策略改造。
+- P9 作者一跳。
+- P10 tag 扩散。
+
+## 现有证据
+
+- `tech_documents/工程落地/04_V1阶段开发计划.md`:P4 已拍板只做视频内容回扣;新增 `pattern_recall_evidence.jsonl`;状态统一小写;解构最长同步等待 20 分钟;`no_match` 和 `rejected` 分开;`metadata_only` 是 V1 长期允许状态;规则包只读 EvidenceBundle。
+- `tech_documents/Pattern回扣与分类树/01_Pattern回扣流程.md`:P4 技术流程为 `DiscoveredContentItem -> 提交解构任务 -> 轮询解构结果 -> 抽取解构元素 -> 分类树 match-paths -> 回扣判断 -> EvidenceBundle.pattern_match_result -> 规则包 hard gate`。
+- `tech_documents/Pattern回扣与分类树/00_全链路说明.md`:发现内容回扣不能改写上游 `source_post_id / matched_post_ids`,发现内容自己的回扣依据必须另存。
+- `content_agent/graph.py`:当前主链路是 `build_discovered_content -> load_policy`,尚无 `recall_pattern` 节点。
+- `content_agent/business_modules/content_discovery/content_discovery_builder.py`:当前已生成 `discovered_content_items.jsonl`、`content_media_records.jsonl` 和内存态 EvidenceBundle;`play_url` 当前固定为 `None`;`pattern_match_result` 仍来自平台默认值。
+- `content_agent/integrations/douyin.py`:真实抖音结果当前保守写 `pattern_recall="pattern_recall_pending"`、`category_or_element_binding="pattern_recall_pending"`、`risk_level="unknown"`。
+- `content_agent/integrations/database_runtime.py`:当前 runtime 文件映射尚无 `pattern_recall_evidence.jsonl`。
+- `content_agent/business_modules/run_record/validation.py`:当前 validator 保护上游 `source_evidence`,尚未检查 P4 matched evidence。
+- `sql/content_agent_schema.sql`:`content_agent_pattern_recall_evidence` 已存在;`content_agent_content_media_records.play_url` 已存在;`content_agent_discovered_content_items.pattern_match_result` 已存在。
+- `product_documents/规则包/douyin_rule_packs.v1.json`:Video rule pack 的 `pattern_recall_required` 和 `category_or_element_binding_required` 是 hard gate。
+- `product_documents/抖音游走策略/runtime_v1_records_schema.md`:`metadata_only` 是 V1 默认 / 允许媒体状态。
+
+真实 DB 证据:
+
+- DB validator 显示 `host=192.168.82.27`、DB `content-deconstruction-supply`、15/15 `content_agent_*` 表存在、18 个唯一索引通过、`schema_ready=true`。
+- `content_agent_pattern_recall_evidence` 表已存在,唯一键为 `run_id + policy_run_id + recall_evidence_id`。
+- `content_agent_content_media_records.play_url` 已存在。
+- `content_agent_content_media_records.failure_reason` 不存在;P4 不新增 DDL,失败原因进入 `raw_payload`。
+
+sub-agent 交叉验证结论:
+
+- 代码侧 sub-agent 确认:P4 DB 表和基础 EvidenceBundle 已具备,但代码层没有 `recall_pattern`、decode integration、category match integration 和 runtime 映射。
+- DB 侧 sub-agent 确认:P4 可不新增 DDL;`failure_reason`、`primary_matched_category_path` 等先写 JSON / raw payload。
+- 文档 / JSON sub-agent 确认:P4 口径整体一致,但旧 Pattern 文档中的大写状态示例必须在实现层归一为小写。
+
+## 修改范围
+
+本目录只新增 P4 brief 文档。后续真正执行 P4 时,预计涉及:
+
+- `content_agent/business_modules/content_discovery/content_discovery_builder.py`
+- `content_agent/business_modules/content_discovery/source_evidence.py`
+- `content_agent/business_modules/content_discovery/pattern_recall/`
+- `content_agent/integrations/decode_api.py`
+- `content_agent/integrations/category_match.py`
+- `content_agent/integrations/database_runtime.py`
+- `content_agent/graph.py`
+- `content_agent/models.py`
+- `content_agent/business_modules/run_record/validation.py`
+- `tests/` 下 P4 fake decode、fake category match、runtime、DB、graph 和 validator 测试。
+
+## 不修改范围
+
+- 本轮不改代码。
+- 本轮不写 DB。
+- 本轮不执行 DDL。
+- 本轮不更新 `.env` 真实值。
+- 本轮不把任何真实密码、token、API key、cookie 或签名写进文档。
+- 本轮不修改旧版归档目录。
+
+## 涉及文件 / 函数 / 类
+
+全局证据文件:
+
+- `tech_documents/Pattern回扣与分类树/01_Pattern回扣流程.md`
+- `tech_documents/Pattern回扣与分类树/00_全链路说明.md`
+- `tech_documents/工程落地/04_V1阶段开发计划.md`
+- `content_agent/graph.py`
+- `content_agent/business_modules/content_discovery/content_discovery_builder.py`
+- `content_agent/business_modules/content_discovery/source_evidence.py`
+- `content_agent/integrations/douyin.py`
+- `content_agent/integrations/database_runtime.py`
+- `content_agent/business_modules/run_record/validation.py`
+- `sql/content_agent_schema.sql`
+- `tech_documents/数据库字段总览/content_agent_schema_registry.json`
+- `product_documents/抖音游走策略/runtime_v1_records_schema.md`
+- `product_documents/规则包/douyin_rule_packs.v1.json`
+
+关键对象:
+
+- `build_run_graph()`
+- `content_discovery.run()`
+- `_build_content_media_record()`
+- `_build_evidence_bundle()`
+- `build_source_evidence()`
+- 后续 `recall_pattern.run()`
+- 后续 `decode.submit_decode_task()`
+- 后续 `decode.get_decode_result()`
+- 后续 `category_match.match_paths()`
+- 后续 `recall_decision.decide_recall()`
+- 后续 `DatabaseRuntimeStore` 的 `pattern_recall_evidence.jsonl` 映射。
+
+## 数据合同
+
+P4 输入:
+
+- `discovered_content_items`
+- `content_media_records`
+- `evidence_bundles`
+- `source_context.ext_data.evidence_pack`
+- `pattern_seed_pack`
+- 平台结果中的标题、desc、tag、metadata 和可用 `play_url`
+
+P4 输出:
+
+- `pattern_recall_evidence.jsonl`
+- 扩展后的 `EvidenceBundle.pattern_match_result`
+- 更新后的 `discovered_content_items.pattern_match_result`
+- `content_media_records.raw_payload.failure_reason` 或 `pattern_recall_evidence.raw_payload` 中的链接不可用原因
+
+状态合同:
+
+- `decode_status` 只允许 `pending / running / success / failed`。
+- `recall_status` 只允许 `matched / pending / failed / rejected / no_match`。
+- `pattern_recall_pending` 不能伪装为 `matched`。
+- `matched` 必须有 `pattern_recall_evidence_id / matched_terms / matched_category_paths`。
+- 多路径命中必须写 `primary_matched_category_path`。
+- pending 补跑成功时更新同一个 `recall_evidence_id`。
+- raw request / response 可保存但必须脱敏。
+
+DB 承载:
+
+- `pattern_recall_evidence.jsonl -> content_agent_pattern_recall_evidence`
+- `pattern_match_result -> content_agent_discovered_content_items.pattern_match_result`
+- `play_url -> content_agent_content_media_records.play_url`
+- `failure_reason / primary_matched_category_path -> JSON / raw_payload`
+
+P4 不新增 DB 字段,不修改 DDL。
+
+## 实施步骤
+
+1. 每进入一个 P4 小阶段,先读对应 brief。
+2. 只执行该 brief 的修改范围,不顺手做 P5 规则策略、作者回扣、tag 扩散或媒体下载。
+3. 修改前用 brief 中的证据文件确认当前代码、文档和 DB 状态仍未漂移。
+4. 修改后先跑该 brief 指定的最小验证,再跑 P4 汇总测试。
+5. 如果验证失败,先按 brief 的失败归因判断,不扩大修改面。
+
+## 验证命令
+
+```bash
+find tech_documents/工程落地/implementation_briefs/P4 -maxdepth 1 -type f | sort
+rg -n "Implementation Brief|sub-agent|验证命令|失败归因" tech_documents/工程落地/implementation_briefs/P4
+rg -n "PASS[W]ORD=|TO[K]EN=|API[_]KEY=|SEC[R]ET=|Authorizatio[n]:|Cooki[e]:" tech_documents/工程落地/implementation_briefs/P4
+rg -n "pattern_recall_evidence.jsonl|recall_pattern|decode_status|recall_status|primary_matched_category_path|20 分钟|metadata_only|match-paths|configId=58" tech_documents/工程落地/implementation_briefs/P4
+uv run python scripts/validate_schema_registry.py
+uv run --with pymysql python scripts/validate_content_agent_db.py --env-file .env
+uv run pytest tests/test_douyin_client.py tests/test_platform_access.py tests/test_api.py tests/test_v1_graph.py tests/test_runtime_files.py tests/test_source_evidence.py tests/test_p0d_p0g.py tests/test_search_intent.py -q
+uv run pytest -q
+```
+
+## 失败归因
+
+- brief 文件缺失:本目录落地问题。
+- 敏感键值命中:brief 内容问题,应改成只写 env key 名和脱敏规则,不写赋值或真实 token。
+- `validate_schema_registry.py` 失败:字段 registry 与 SQL / 文档漂移。
+- DB validator 失败:DB 连接、权限、schema 或 `.env` 目标库问题。
+- P0-P3 测试失败:本次只新增 Markdown,通常不应引发业务测试失败;先检查工作区已有代码改动。
+- P4 后续实现测试失败:按具体 brief 判断是媒体链接、runtime 映射、解构状态、分类树匹配、回扣判断、Graph 接入还是 validator 问题。
+
+## sub-agent 交叉验证要点
+
+- 代码侧确认:当前没有 `recall_pattern` 节点和 P4 integration。
+- 代码侧确认:`play_url` 当前固定 `None`。
+- 代码侧确认:`DatabaseRuntimeStore` 尚未映射 `pattern_recall_evidence.jsonl`。
+- DB 侧确认:`content_agent_pattern_recall_evidence` 和 `play_url` 已存在,P4 不新增 DDL。
+- DB 侧确认:`failure_reason` 不是物理列,只能进 JSON / raw_payload。
+- 文档 / JSON 侧确认:技术流程以 `Pattern回扣与分类树` 为依据,但运行枚举以 V1 最新计划的小写口径为准。

+ 112 - 0
tech_documents/工程落地/implementation_briefs/P4/P4A_MediaLink_And_MetadataOnly.md

@@ -0,0 +1,112 @@
+# P4A Media Link And MetadataOnly Implementation Brief
+
+状态:本 brief 覆盖 P4 的媒体链接保存和 `metadata_only` 边界。它不下载视频,不上传 OSS,也不把视频链接可用性当成 Pattern 回扣是否通过的唯一依据。
+
+## 目标
+
+- 保存平台返回的 `play_url`。
+- 保持 `content_media_status=metadata_only` 作为 V1 长期允许状态。
+- 记录视频链接不可用原因。
+- 不把链接不可用直接等同于 Pattern 回扣失败。
+
+## 现有证据
+
+- `content_agent/business_modules/content_discovery/content_discovery_builder.py`:`_build_content_media_record()` 当前固定写 `play_url=None`。
+- `sql/content_agent_schema.sql`:`content_agent_content_media_records.play_url` 已存在,类型为 `TEXT NULL`。
+- `tech_documents/数据库字段总览/content_agent_schema_registry.json`:`play_url` 映射到 `content_media_records.jsonl.play_url`。
+- `product_documents/抖音游走策略/runtime_v1_records_schema.md`:`metadata_only / downloaded_local / oss_uploaded / unavailable` 是媒体状态枚举;V1 不假设视频下载或 OSS 已接入。
+- `tech_documents/工程落地/04_V1阶段开发计划.md`:P4 不把下载视频文件作为 Pattern 回扣前置条件;Crawapi 若返回视频链接,应保存 `play_url`,拿不到时才为 `None`。
+- DB sub-agent 确认:`failure_reason` 不是物理列,当前只能写入 `raw_payload.failure_reason` 或 P4 evidence 的 `raw_payload`。
+
+## 修改范围
+
+后续执行 P4A 时允许:
+
+- 修改 `content_agent/integrations/douyin.py`,从 Crawapi item 中提取可用视频链接字段。
+- 修改 `content_agent/business_modules/content_discovery/content_discovery_builder.py`,把 `result.play_url` 写入 `content_media_records.jsonl.play_url`。
+- 在 `content_media_records.jsonl.raw_payload.failure_reason` 中保存链接不可用原因。
+- 增加 `play_url` 存在和缺失测试。
+
+## 不修改范围
+
+- 不下载视频。
+- 不上传 OSS。
+- 不新增 `failure_reason` 物理列。
+- 不把 `content_media_status` 从 `metadata_only` 升级为 `downloaded_local` 或 `oss_uploaded`。
+- 不因链接不可用直接把 `pattern_recall` 写成失败。
+
+## 涉及文件 / 函数 / 类
+
+文件:
+
+- `content_agent/integrations/douyin.py`
+- `content_agent/business_modules/content_discovery/content_discovery_builder.py`
+- `content_agent/integrations/database_runtime.py`
+- `tests/test_douyin_client.py`
+- `tests/test_runtime_files.py`
+- `tests/test_database_runtime.py`
+- `product_documents/抖音游走策略/runtime_v1_records_schema.md`
+
+函数 / 类:
+
+- `CrawapiDouyinClient._normalize_content_item()`
+- `_build_content_media_record()`
+- `DatabaseRuntimeStore` 对 `content_media_records.jsonl` 的映射
+
+## 数据合同
+
+`content_media_records.jsonl` 行:
+
+- `record_schema_version`
+- `run_id`
+- `policy_run_id`
+- `platform`
+- `platform_content_id`
+- `content_media_status="metadata_only"`
+- `content_metadata_source`
+- `play_url`
+- `local_path=null`
+- `oss_url=null`
+- `raw_payload`
+- `created_at`
+
+链接不可用原因:
+
+- 物理列没有 `failure_reason`。
+- 写入 `raw_payload.failure_reason`。
+- 可选值示例:`play_url_missing`、`play_url_empty`、`play_url_untrusted_shape`。
+
+业务边界:
+
+- `metadata_only` 不阻塞 P4。
+- `play_url` 缺失不等于 Pattern 回扣失败。
+- 如果标题、desc、metadata 仍可提交解构,P4 可以继续尝试回扣。
+
+## 实施步骤
+
+1. 读取 Crawapi keyword item 的视频链接候选字段,不扩大保存完整原始响应。
+2. 将可用链接写入平台归一结果 `play_url`。
+3. `_build_content_media_record()` 写入 `play_url=result.get("play_url")`。
+4. 若无链接,保留 `play_url=None`,并在 raw payload 写 `failure_reason`。
+5. 保持 `content_media_status="metadata_only"`。
+6. 增加测试:有链接、无链接、无链接但仍允许后续 P4 回扣流程继续。
+
+## 验证命令
+
+```bash
+uv run pytest tests/test_douyin_client.py tests/test_runtime_files.py tests/test_database_runtime.py -q
+rg -n "play_url|metadata_only|failure_reason" content_agent tests
+```
+
+## 失败归因
+
+- `play_url` 仍为 `None`:Crawapi 字段提取或 builder 写入遗漏。
+- DB 写入失败:`DatabaseRuntimeStore` 字段映射或 JSON 序列化问题。
+- 出现 `failure_reason` 列相关错误:误把失败原因当成物理列。
+- `metadata_only` 被改成下载状态:越界到媒体资产能力。
+
+## sub-agent 交叉验证要点
+
+- 代码侧确认:`play_url` 从平台归一结果进入 `content_media_records.jsonl`。
+- DB 侧确认:不新增 DDL,不引用不存在的 `failure_reason` 列。
+- 文档侧确认:`metadata_only` 是 V1 长期允许状态,媒体下载 / OSS 不阻塞 P4。

+ 126 - 0
tech_documents/工程落地/implementation_briefs/P4/P4B_PatternRecallEvidence_RuntimeAndDB.md

@@ -0,0 +1,126 @@
+# P4B Pattern Recall Evidence Runtime And DB Implementation Brief
+
+状态:本 brief 覆盖 `pattern_recall_evidence.jsonl`、DB runtime 映射和回扣证据脱敏。它不实现解构接口、分类树接口或回扣判断策略。
+
+## 目标
+
+- 新增 runtime 文件 `pattern_recall_evidence.jsonl`。
+- 映射到现有 `content_agent_pattern_recall_evidence` 表。
+- 保存发现内容自己的 Pattern 回扣证据。
+- raw request / response 可复盘,但必须脱敏。
+- pending 后续补跑成功时更新同一个 `recall_evidence_id`。
+
+## 现有证据
+
+- `sql/content_agent_schema.sql`:`content_agent_pattern_recall_evidence` 已存在,包含 `recall_evidence_id`、`decode_status`、`recall_status`、`matched_terms`、`matched_category_paths`、`decode_elements`、`match_paths_request`、`match_paths_response`、`evidence_summary`、`raw_payload`。
+- `tech_documents/数据库字段总览/content_agent_schema_registry.json`:该表 runtime mapping 仍标记为 `future:pattern_recall_evidence.jsonl`。
+- `content_agent/integrations/database_runtime.py`:`RUNTIME_FILE_TABLES` 当前没有 `pattern_recall_evidence.jsonl`。
+- DB validator:真实 DB 中 `content_agent_pattern_recall_evidence` 已 ready,唯一键为 `run_id + policy_run_id + recall_evidence_id`。
+- `tech_documents/Pattern回扣与分类树/00_全链路说明.md`:发现内容自己的回扣依据必须另存为 `pattern_recall_evidence`。
+- `tech_documents/工程落地/04_V1阶段开发计划.md`:pending 内容后续补跑成功时更新同一个 `recall_evidence_id`。
+
+## 修改范围
+
+后续执行 P4B 时允许:
+
+- 修改 `content_agent/integrations/database_runtime.py`,增加 `pattern_recall_evidence.jsonl` 到 `content_agent_pattern_recall_evidence` 的映射。
+- 增加 JSON column map 和 allowed columns。
+- 新增或扩展 runtime store 测试,验证写入和读回。
+- 增加脱敏 helper 或复用现有 raw payload 禁止键检查。
+
+## 不修改范围
+
+- 不新增 DB 表。
+- 不新增 DDL。
+- 不写 `failure_reason` 或 `primary_matched_category_path` 物理列。
+- 不把新发现内容写入上游 `source_post_id / matched_post_ids / decode_case_ids`。
+- 不在本阶段实现真实 decode 或 match-paths 调用。
+
+## 涉及文件 / 函数 / 类
+
+文件:
+
+- `content_agent/integrations/database_runtime.py`
+- `content_agent/interfaces.py`
+- `content_agent/integrations/runtime_files.py`
+- `tests/test_database_runtime.py`
+- `tests/test_runtime_files.py`
+- `sql/content_agent_schema.sql`
+- `tech_documents/数据库字段总览/content_agent_schema_registry.json`
+
+函数 / 类:
+
+- `DatabaseRuntimeStore.append_jsonl()`
+- `DatabaseRuntimeStore.read_jsonl()`
+- `LocalRuntimeFileStore.append_jsonl()`
+- `FORBIDDEN_RAW_PAYLOAD_KEYS`
+- 后续 `sanitize_raw_payload()`
+
+## 数据合同
+
+`pattern_recall_evidence.jsonl` 每行至少包含:
+
+- `record_schema_version`
+- `run_id`
+- `policy_run_id`
+- `recall_evidence_id`
+- `content_discovery_id`
+- `platform_content_id`
+- `decode_status`
+- `decode_task_id`
+- `recall_status`
+- `matched_terms`
+- `matched_category_paths`
+- `decode_elements`
+- `match_paths_request`
+- `match_paths_response`
+- `evidence_summary`
+- `raw_payload`
+- `created_at`
+
+枚举:
+
+- `decode_status`: `pending / running / success / failed`
+- `recall_status`: `matched / pending / failed / rejected / no_match`
+
+脱敏规则:
+
+- 不保存真实 token、cookie、签名、账号敏感信息、敏感 header、敏感错误文本。
+- 对请求头、账号态、授权字段统一写脱敏占位。
+- 文档只写 env key 名,不写真实值。
+
+补跑规则:
+
+- 同一 `run_id + policy_run_id + recall_evidence_id` 表示同一个发现内容的回扣证据。
+- pending 后续补跑成功时更新同一个 `recall_evidence_id` 对应记录。
+- 本地 JSONL append-only 和 DB 唯一键更新之间的差异必须在 brief 中说明:DB runtime 可按该 evidence id 做可控更新,或者后续补跑通过同一 id 的最新行规则读取;具体实现时必须保持读路径确定。
+
+## 实施步骤
+
+1. 在 runtime 文件表映射中加入 `pattern_recall_evidence.jsonl`。
+2. 为 `content_agent_pattern_recall_evidence` 增加 JSON columns:`matched_terms`、`matched_category_paths`、`decode_elements`、`match_paths_request`、`match_paths_response`、`evidence_summary`、`raw_payload`。
+3. 增加 allowed columns。
+4. 增加 fake DB 测试:写入一条 matched evidence。
+5. 增加 fake DB 测试:pending 后续补跑同一 `recall_evidence_id` 的处理规则。
+6. 增加敏感字段扫描和脱敏测试。
+
+## 验证命令
+
+```bash
+uv run pytest tests/test_database_runtime.py tests/test_runtime_files.py -q
+rg -n "pattern_recall_evidence.jsonl|content_agent_pattern_recall_evidence|FORBIDDEN_RAW_PAYLOAD_KEYS" content_agent tests
+```
+
+## 失败归因
+
+- DB 写入失败:runtime table mapping 或 allowed columns 缺失。
+- JSON 序列化失败:JSON column map 缺字段。
+- 补跑更新失败:唯一键与 append-only 策略未处理清楚。
+- 敏感字段进入 raw payload:脱敏规则不足。
+- `failure_reason` 物理列错误:误用不存在列。
+
+## sub-agent 交叉验证要点
+
+- DB 侧确认:表和唯一键已存在,P4 不新增 DDL。
+- 代码侧确认:runtime file 与 DB table 映射完整。
+- 文档侧确认:回扣证据只属于新发现内容,不污染上游证据。

+ 118 - 0
tech_documents/工程落地/implementation_briefs/P4/P4C_DecodeClient_StatusAndTimeout.md

@@ -0,0 +1,118 @@
+# P4C Decode Client Status And Timeout Implementation Brief
+
+状态:本 brief 覆盖解构提交 / 查询 client、状态归一和 20 分钟等待。它不做分类树匹配,也不做最终回扣判断。
+
+## 目标
+
+- 接入解构提交和查询接口。
+- 将外部状态归一成小写 `decode_status`。
+- 最长同步等待 20 分钟。
+- 超时写 pending 原因,不影响 run 状态。
+- 视频不可解构、内容缺失、返回结构异常或结构坏时保留证据,交规则包 reject。
+
+## 现有证据
+
+- `tech_documents/Pattern回扣与分类树/01_Pattern回扣流程.md`:提交接口为 `POST https://aigc-api.aiddit.com/aigc/api/task/decode`,查询接口为 `POST https://aigc-api.aiddit.com/aigc/api/task/decode/result`,固定 `configId=58`。
+- 同一文档:解构输出后抽取 `目的点`、`关键点`、`实质元素`,再进入分类树匹配。
+- `tech_documents/工程落地/04_V1阶段开发计划.md`:`decode_status` 统一小写;最长等待 20 分钟;超过 20 分钟写 pending 原因,不影响 run 状态。
+- 代码侧 sub-agent 确认:当前没有 `content_agent/integrations/decode_api.py`,也没有 `content_discovery/pattern_recall/` 模块。
+- 文档侧 sub-agent 确认:旧文档大写 `SUCCESS / RUNNING / FAILED` 是历史示例,实现层必须归一小写。
+
+## 修改范围
+
+后续执行 P4C 时允许:
+
+- 新增 `content_agent/integrations/decode_api.py`。
+- 新增 `content_agent/business_modules/content_discovery/pattern_recall/decode.py`。
+- 增加 fake decode client 和单元测试。
+- 在 P4 后续 Graph 集成时通过依赖注入使用 decode client。
+
+## 不修改范围
+
+- 不调用分类树 `match-paths`。
+- 不判断 `matched / no_match`。
+- 不修改规则包。
+- 不下载视频。
+- 不把标题 / tag / desc 单独升级为 `matched`。
+
+## 涉及文件 / 函数 / 类
+
+文件:
+
+- `content_agent/integrations/decode_api.py`
+- `content_agent/business_modules/content_discovery/pattern_recall/decode.py`
+- `content_agent/interfaces.py`
+- `content_agent/graph.py`
+- `tests/test_pattern_recall_decode.py`
+
+函数 / 类:
+
+- 后续 `DecodeClient.submit_decode_task()`
+- 后续 `DecodeClient.get_decode_result()`
+- 后续 `normalize_decode_status()`
+- 后续 `extract_decode_elements()`
+- 后续 `wait_for_decode_result()`
+
+## 数据合同
+
+提交解构输入:
+
+- `platform_content_id`
+- `title`
+- `bodyText`
+- `tags`
+- `description`
+- `play_url`
+- `contentModal=4`
+- `channel=2`
+- `configId=58`
+
+状态归一:
+
+- 外部 `SUCCESS` -> `success`
+- 外部 `RUNNING` 或 `decode_running` -> `running`
+- 外部 `PENDING` 或 `decode_pending` -> `pending`
+- 外部 `FAILED` -> `failed`
+
+等待合同:
+
+- 最长 20 分钟。
+- 超时写 `decode_status=running` 或 `pending`。
+- 同时写 `recall_status=pending` 和 `pending_reason=decode_timeout_20m`。
+- 不影响 run 状态。
+
+失败合同:
+
+- 视频不可解构、内容缺失、返回结构异常、结构坏:写 `decode_status=failed`,后续 recall status 可为 `rejected`。
+- 接口临时失败:写 `decode_status=failed` 或 `pending`,由失败类型决定,错误细节脱敏。
+
+## 实施步骤
+
+1. 新增 decode client protocol / adapter。
+2. 用 env key 配置 base URL、timeout、config id;文档只写 key 名,不写真实值。
+3. 实现 submit request 构造,输入来自 `DiscoveredContentItem`、`content_media_records` 和平台 metadata。
+4. 实现 result 查询和状态归一。
+5. 实现 20 分钟等待策略,测试中用 fake clock 或短超时替代。
+6. 实现解构元素抽取,只抽取 P4D/P4E 需要的结构化候选词。
+7. 增加测试:success、pending、running、failed、结构坏、内容缺失、20 分钟超时、脱敏错误。
+
+## 验证命令
+
+```bash
+uv run pytest tests/test_pattern_recall_decode.py -q
+rg -n "decode_status|decode_timeout_20m|configId=58|submit_decode" content_agent tests
+```
+
+## 失败归因
+
+- 状态大小写漂移:adapter 未归一化。
+- run 被超时内容拖失败:等待策略错误。
+- 结构坏仍继续分类树:decode validation 不足。
+- 标题 / tag / desc 被当成回扣通过:越界到 recall decision。
+- 错误消息泄漏敏感信息:脱敏不足。
+
+## sub-agent 交叉验证要点
+
+- 代码侧确认:decode client 不直接修改 EvidenceBundle。
+- 文档侧确认:实现流程参考 Pattern 回扣文档,但运行状态使用小写枚举。
+- 测试侧确认:20 分钟等待使用 fake clock / fake timeout,不让默认测试真实等待。

+ 117 - 0
tech_documents/工程落地/implementation_briefs/P4/P4D_CategoryMatchPaths_Client.md

@@ -0,0 +1,117 @@
+# P4D Category MatchPaths Client Implementation Brief
+
+状态:本 brief 覆盖分类树 `match-paths` client 和解构强证据词匹配。它不做最终 EvidenceBundle 写入,也不修改规则包。
+
+## 目标
+
+- 接入分类树 `match-paths`。
+- 使用解构强证据词发起匹配。
+- 保存脱敏后的 request / response 快照。
+- 明确标题 / tag / desc 只能辅助,不能单独 `matched`。
+- 防止泛词、形式词、噪声词直接放行。
+
+## 现有证据
+
+- `tech_documents/Pattern回扣与分类树/01_Pattern回扣流程.md`:分类树接口为 `POST https://library.aiddit.com/api/search/categories/match-paths`,示例使用 `source_type=实质`、`top_k=10`、`min_score=0.6`。
+- 同一文档:强证据词优先来自 `目的点[].点`、`目的点[].实质.*[].名称`、实质型 `关键点[].点`。
+- 同一文档:`match-paths` 是在线向量匹配接口,返回会漂移,不能把某次 score 写成稳定业务合同。
+- `tech_documents/工程落地/04_V1阶段开发计划.md`:标题、tag、desc 只能作为辅助输入或辅助解释,不能单独让内容进入 `matched`。
+- 代码侧 sub-agent 确认:当前没有 `content_agent/integrations/category_match.py`。
+
+## 修改范围
+
+后续执行 P4D 时允许:
+
+- 新增 `content_agent/integrations/category_match.py`。
+- 新增 `content_agent/business_modules/content_discovery/pattern_recall/category_match.py`。
+- 增加 fake category match client 和单元测试。
+- 保存脱敏后的 match request / response 到 P4B evidence 结构。
+
+## 不修改范围
+
+- 不提交解构任务。
+- 不轮询解构。
+- 不做最终 `matched / no_match` 业务结论。
+- 不修改规则包。
+- 不把某次 match score 写成稳定业务合同。
+
+## 涉及文件 / 函数 / 类
+
+文件:
+
+- `content_agent/integrations/category_match.py`
+- `content_agent/business_modules/content_discovery/pattern_recall/category_match.py`
+- `tests/test_pattern_recall_category_match.py`
+- `tech_documents/Pattern回扣与分类树/01_Pattern回扣流程.md`
+
+函数 / 类:
+
+- 后续 `CategoryMatchClient.match_paths()`
+- 后续 `build_match_paths_request()`
+- 后续 `extract_strong_evidence_terms()`
+- 后续 `normalize_match_paths_response()`
+
+## 数据合同
+
+请求:
+
+- `source_type="实质"`
+- `top_k=10`
+- `min_score=0.6`
+- `items[].term`
+- `items[].description`
+
+强证据词来源:
+
+- `目的点[].点`
+- `目的点[].实质.*[].名称`
+- 实质型 `关键点[].点`
+
+辅助证据:
+
+- `关键点[].实质.*[].名称`
+- `分词结果[].词`
+- `contribution_results[].词`
+- 标题
+- tag
+- desc
+
+输出:
+
+- `matched_terms`
+- `matched_category_paths`
+- `primary_matched_category_path` 候选
+- `match_paths_request`
+- `match_paths_response`
+- `raw_payload`
+
+## 实施步骤
+
+1. 从 decode result 中抽取强证据词和辅助词。
+2. 过滤明显空值和重复词。
+3. 构造 match-paths request。
+4. 调用 category match client。
+5. 归一化 response,保留路径、score、term、原始候选。
+6. 保存 request / response 快照,敏感字段脱敏。
+7. 增加测试:强证据词命中、泛词命中、无命中、接口漂移仅保存证据不写死分数。
+
+## 验证命令
+
+```bash
+uv run pytest tests/test_pattern_recall_category_match.py -q
+rg -n "match-paths|source_type|top_k|min_score|matched_category_paths" content_agent tests
+```
+
+## 失败归因
+
+- 强证据词为空:decode element extraction 问题。
+- 泛词直接放行:recall decision 越界或过滤不足。
+- score 被当成稳定合同:测试和文档口径错误。
+- request / response 泄漏敏感字段:脱敏不足。
+- 标题 / tag / desc 直接生成 matched:违反 P4 边界。
+
+## sub-agent 交叉验证要点
+
+- 文档侧确认:match-paths 参数与 Pattern 回扣流程一致。
+- 代码侧确认:category client 只返回证据,不直接改规则结果。
+- 测试侧确认:标题 / tag / desc 命中不能单独通过。

+ 124 - 0
tech_documents/工程落地/implementation_briefs/P4/P4E_RecallDecision_And_EvidenceBundle.md

@@ -0,0 +1,124 @@
+# P4E Recall Decision And EvidenceBundle Implementation Brief
+
+状态:本 brief 覆盖回扣判断和 EvidenceBundle 扩展。它不调用外部接口,不修改规则包策略。
+
+## 目标
+
+- 根据解构结果和分类树路径判断是否回扣当前 Pattern。
+- 区分 `matched / pending / failed / rejected / no_match`。
+- 扩展 `EvidenceBundle.pattern_match_result`。
+- 保留全部分类路径和主路径。
+- 不污染上游 Pattern 证据。
+
+## 现有证据
+
+- `tech_documents/Pattern回扣与分类树/01_Pattern回扣流程.md`:只有强证据词命中分类树路径,并能和本次 `evidence_pack.category_bindings`、`itemset_items` 或 `seed_terms` 可解释相连,才允许 `pattern_recall=matched`。
+- `tech_documents/Pattern回扣与分类树/00_全链路说明.md`:回扣成功不能说明新发现内容是上游 itemset 的原始支撑帖子,不能改写 `source_post_id / matched_post_ids`。
+- `content_agent/business_modules/content_discovery/content_discovery_builder.py`:当前 `_build_evidence_bundle()` 只写平台默认 `pattern_recall` 和 `category_or_element_binding`。
+- `product_documents/规则包/douyin_rule_packs.v1.json`:Video hard gate 读取 `pattern_match_result.pattern_recall` 和 `pattern_match_result.category_or_element_binding`。
+- `tech_documents/工程落地/04_V1阶段开发计划.md`:P4 已拍板 `no_match` 和 `rejected` 分开,多路径写 `primary_matched_category_path`。
+
+## 修改范围
+
+后续执行 P4E 时允许:
+
+- 新增 `content_agent/business_modules/content_discovery/pattern_recall/recall_decision.py`。
+- 修改 `_build_evidence_bundle()` 或新增 EvidenceBundle update helper。
+- 修改 discovered content item 的 `pattern_match_result` 写入。
+- 增加 validator 对 matched evidence 的检查。
+- 增加回扣判断单元测试。
+
+## 不修改范围
+
+- 不调用 decode 接口。
+- 不调用 category match 接口。
+- 不修改规则包 JSON 的业务阈值。
+- 不把新发现 `platform_content_id` 写入上游 `source_post_id / matched_post_ids / decode_case_ids`。
+- 不把标题 / tag / desc 单独作为 matched 依据。
+
+## 涉及文件 / 函数 / 类
+
+文件:
+
+- `content_agent/business_modules/content_discovery/pattern_recall/recall_decision.py`
+- `content_agent/business_modules/content_discovery/content_discovery_builder.py`
+- `content_agent/business_modules/content_discovery/source_evidence.py`
+- `content_agent/business_modules/run_record/validation.py`
+- `tests/test_pattern_recall_decision.py`
+- `tests/test_source_evidence.py`
+
+函数 / 类:
+
+- 后续 `decide_recall()`
+- 后续 `build_pattern_match_result()`
+- `_build_evidence_bundle()`
+- `build_source_evidence()`
+- 后续 validator helper:`validate_pattern_recall_evidence()`
+
+## 数据合同
+
+`EvidenceBundle.pattern_match_result` 扩展字段:
+
+- `pattern_recall`
+- `category_or_element_binding`
+- `decode_status`
+- `match_status`
+- `recall_status`
+- `matched_terms`
+- `matched_category_paths`
+- `primary_matched_category_path`
+- `decode_elements`
+- `pattern_recall_evidence_id`
+
+回扣状态:
+
+- `matched`:强证据词 + 分类树路径 + 本次 Pattern 证据可解释相连。
+- `pending`:解构或匹配未完成。
+- `failed`:接口或处理失败,但不一定是内容无效。
+- `rejected`:视频不可解构、内容缺失、结构坏或证据无效。
+- `no_match`:解构和分类树完成,但不能解释回当前 Pattern。
+
+`category_or_element_binding`:
+
+- `direct_match`
+- `tree_walk_match`
+- `pattern_recall_pending`
+
+matched 最小条件:
+
+- `pattern_recall_evidence_id` 非空。
+- `matched_terms` 非空。
+- `matched_category_paths` 非空。
+- 多路径时 `primary_matched_category_path` 非空。
+
+## 实施步骤
+
+1. 从 P4B/P4C/P4D 产物读取 decode status、matched terms、matched paths 和 evidence id。
+2. 判断 `pending / failed / rejected / no_match / matched`。
+3. 对 `matched` 执行强约束校验。
+4. 构造 `pattern_match_result`。
+5. 写入 EvidenceBundle。
+6. 保持 `source_evidence` 的上游字段不可改写。
+7. 扩展 validator:`matched` 缺核心 evidence 时失败。
+8. 增加测试:matched、pending、failed、rejected、no_match、多路径、标题/tag/desc 不能单独通过、source evidence 污染保护。
+
+## 验证命令
+
+```bash
+uv run pytest tests/test_pattern_recall_decision.py tests/test_source_evidence.py -q
+rg -n "primary_matched_category_path|pattern_recall_evidence_id|no_match|rejected" content_agent tests
+```
+
+## 失败归因
+
+- `matched` 缺 evidence 仍通过:decision 或 validator 漏检。
+- `no_match` 与 `rejected` 混淆:状态判断错误。
+- 多路径丢失:match response 归一化或 EvidenceBundle 写入遗漏。
+- 上游字段被污染:source evidence 更新逻辑错误。
+- 标题 / tag / desc 单独通过:回扣判断越界。
+
+## sub-agent 交叉验证要点
+
+- 代码侧确认:规则包仍只读 EvidenceBundle。
+- 文档侧确认:matched 语义是“新发现内容解释回当前 Pattern”,不是上游原始支撑帖。
+- 测试侧确认:所有状态都可复盘到 evidence。

+ 114 - 0
tech_documents/工程落地/implementation_briefs/P4/P4F_GraphIntegration_And_RuleBoundary.md

@@ -0,0 +1,114 @@
+# P4F Graph Integration And Rule Boundary Implementation Brief
+
+状态:本 brief 覆盖 `recall_pattern` 接入 LangGraph 和规则包边界。它不改 P5 规则策略。
+
+## 目标
+
+- 在主链路中加入 `recall_pattern`。
+- 位置固定为 `build_discovered_content -> recall_pattern -> load_policy`。
+- 确保规则包只读 EvidenceBundle。
+- pending 内容不影响 run 状态。
+- `pattern_recall_pending` 不能伪装为 `matched`。
+
+## 现有证据
+
+- `content_agent/graph.py`:当前节点顺序是 `build_discovered_content -> load_policy`,中间没有 `recall_pattern`。
+- `tech_documents/Pattern回扣与分类树/01_Pattern回扣流程.md`:主链路建议为 `search_platform -> build_discovered_content -> recall_pattern -> load_policy -> evaluate_rules`。
+- `tech_documents/Pattern回扣与分类树/00_全链路说明.md`:规则包不直接调用解构接口,也不直接调用分类树接口。
+- `content_agent/business_modules/rule_judgment/evaluator.py`:当前规则判断只读 bundle 字段和 JSON rule pack。
+- `tech_documents/工程落地/04_V1阶段开发计划.md`:P4 允许单条内容 pending,不影响 run 状态。
+
+## 修改范围
+
+后续执行 P4F 时允许:
+
+- 修改 `content_agent/graph.py`,增加 `recall_pattern` 节点和依赖。
+- 修改 `RunDependencies`,注入 fake / real decode client 和 category match client。
+- 修改 `RunService.from_env()`,装配 P4 clients。
+- 增加 graph / API / RunService 集成测试。
+- 增加 run event 记录:decode submitted、decode completed、category matched、recall decided。
+
+## 不修改范围
+
+- 不修改规则包业务含义。
+- 不实现作者 / tag / Path / Budget dispatch。
+- 不把 pending 内容变成 run failure。
+- 不让 rule_judgment 调外部接口。
+- 不新增 API 成功响应字段。
+
+## 涉及文件 / 函数 / 类
+
+文件:
+
+- `content_agent/graph.py`
+- `content_agent/run_service.py`
+- `content_agent/models.py`
+- `content_agent/interfaces.py`
+- `content_agent/business_modules/content_discovery/pattern_recall/__init__.py`
+- `content_agent/business_modules/rule_judgment/evaluator.py`
+- `tests/test_v1_graph.py`
+- `tests/test_p4_graph_integration.py`
+- `tests/test_api.py`
+
+函数 / 类:
+
+- `build_run_graph()`
+- `RunDependencies`
+- `RunService.from_env()`
+- 后续 `recall_pattern.run()`
+- `rule_judgment.run()`
+
+## 数据合同
+
+Graph 输入:
+
+- `discovered_content_items`
+- `content_media_records`
+- `evidence_bundles`
+- `source_context`
+- `pattern_seed_pack`
+
+Graph 输出:
+
+- `pattern_recall_evidence`
+- 更新后的 `evidence_bundles`
+- 更新后的 `discovered_content_items`
+- `current_step="recall_pattern"`
+
+状态:
+
+- `matched` 内容进入规则包 hard gate。
+- `pending` 内容仍进入规则包,但只能作为 pending / blocked / reject 输入,不影响 run 状态。
+- `failed / rejected / no_match` 内容进入规则包,由 Video rule pack 决定动作。
+
+## 实施步骤
+
+1. 在 `RunDependencies` 增加 decode client 和 category match client。
+2. 增加 `recall_pattern` graph node。
+3. 将 edge 从 `build_discovered_content -> load_policy` 改为 `build_discovered_content -> recall_pattern -> load_policy`。
+4. `recall_pattern` 调用 P4B-P4E 产物,写 runtime evidence 并更新 bundle。
+5. 确认 `rule_judgment.run()` 不新增外部 client 依赖。
+6. 增加 fake clients 集成测试。
+7. 增加 pending 不影响 run 状态测试。
+
+## 验证命令
+
+```bash
+uv run pytest tests/test_v1_graph.py tests/test_api.py tests/test_p4_graph_integration.py -q
+rg -n "recall_pattern|build_discovered_content|load_policy|rule_judgment" content_agent/graph.py content_agent tests
+```
+
+## 失败归因
+
+- 规则包直接调接口:依赖边界错误。
+- pending 导致 run failed:状态处理错误。
+- `recall_pattern` 未更新 EvidenceBundle:Graph 返回字段遗漏。
+- API 成功响应变更:越界修改。
+- mock / fake clients 未注入:默认测试访问真实外部接口。
+
+## sub-agent 交叉验证要点
+
+- 代码侧确认:Graph 节点顺序正确。
+- 代码侧确认:rule_judgment 无 decode/category client 依赖。
+- 测试侧确认:fake decode + fake category match 可跑完整 mock run。
+- DB 侧确认:P4 runtime 文件可 DB 写入。

+ 110 - 0
tech_documents/工程落地/implementation_briefs/P4/P4G_P4_Acceptance_And_DriftGuards.md

@@ -0,0 +1,110 @@
+# P4G P4 Acceptance And Drift Guards Implementation Brief
+
+状态:本 brief 覆盖 P4 完整验收、集成测试和漂移防护。P4 完成后应能确认进入 P5 Video rule pack 判断实现。
+
+## 目标
+
+- 证明 P4 已完成视频 Pattern 回扣。
+- 证明 `pattern_recall_evidence.jsonl` 和 DB runtime 对齐。
+- 证明 `matched` 有完整证据。
+- 证明 pending 不影响 run 状态。
+- 证明规则包不直接调用解构或分类树接口。
+
+## 现有证据
+
+- 当前 P0-P3 回归测试通过:`tests/test_douyin_client.py tests/test_platform_access.py tests/test_api.py tests/test_v1_graph.py tests/test_runtime_files.py tests/test_source_evidence.py tests/test_p0d_p0g.py tests/test_search_intent.py` 为 54 passed。
+- DB validator 显示 `schema_ready=true`,`content_agent_pattern_recall_evidence` 已 ready。
+- 当前代码没有 P4 主链路,因此 P4 测试必须新增 fake decode 和 fake category match。
+- `product_documents/规则包/douyin_rule_packs.v1.json` 已有 Pattern hard gate,但 P4 不修改规则策略。
+
+## 修改范围
+
+后续执行 P4G 时允许:
+
+- 新增 P4 专项测试文件。
+- 扩展 runtime validator。
+- 扩展 DB runtime tests。
+- 增加 drift guard `rg` 命令。
+- 增加 opt-in DB runtime smoke。
+
+## 不修改范围
+
+- 不新增 DDL。
+- 不访问真实 decode / category match 作为默认测试。
+- 不访问真实 Crawapi 作为默认测试。
+- 不修改 P5 规则策略。
+- 不做媒体下载 / OSS。
+
+## 涉及文件 / 函数 / 类
+
+文件:
+
+- `tests/test_pattern_recall_decode.py`
+- `tests/test_pattern_recall_category_match.py`
+- `tests/test_pattern_recall_decision.py`
+- `tests/test_p4_graph_integration.py`
+- `tests/test_database_runtime.py`
+- `tests/test_runtime_files.py`
+- `tests/test_source_evidence.py`
+- `content_agent/business_modules/run_record/validation.py`
+
+函数 / 类:
+
+- `validate_run()`
+- `DatabaseRuntimeStore.append_jsonl()`
+- `recall_pattern.run()`
+- fake decode client
+- fake category match client
+
+## 数据合同
+
+P4 acceptance 必须验证:
+
+- `pattern_recall_evidence.jsonl` 存在。
+- `content_agent_pattern_recall_evidence` 可写入。
+- `matched` 必须有 `pattern_recall_evidence_id / matched_terms / matched_category_paths`。
+- 多路径必须有 `primary_matched_category_path`。
+- `pending` 有原因且不影响 run 状态。
+- `no_match` 与 `rejected` 分开。
+- `play_url` 有则保存,无则记录原因。
+- raw request / response 脱敏。
+
+## 实施步骤
+
+1. 建立 fake decode client,覆盖 success / pending / running / failed / bad shape / timeout。
+2. 建立 fake category match client,覆盖 stable match、no match、generic match、多路径。
+3. 建立 P4 full mock run:mock platform + fake decode + fake category match。
+4. 验证 runtime 文件:`pattern_recall_evidence.jsonl`、`discovered_content_items.jsonl`、`content_media_records.jsonl`、`rule_decisions.jsonl`、`source_path_records.jsonl`。
+5. 验证 DB runtime:同一 `run_id / policy_run_id` 写入 `content_agent_pattern_recall_evidence`。
+6. 验证 validator:matched 缺 evidence 失败;source evidence 污染失败。
+7. 验证 drift guard:业务模块无 DB 访问;rule_judgment 无外部接口调用;P4 不出现媒体下载 / OSS。
+
+## 验证命令
+
+```bash
+uv run pytest tests/test_pattern_recall_decode.py tests/test_pattern_recall_category_match.py tests/test_pattern_recall_decision.py -q
+uv run pytest tests/test_p4_graph_integration.py tests/test_database_runtime.py tests/test_runtime_files.py tests/test_source_evidence.py -q
+uv run pytest tests/test_douyin_client.py tests/test_platform_access.py tests/test_api.py tests/test_v1_graph.py tests/test_p0d_p0g.py tests/test_search_intent.py -q
+uv run pytest -q
+uv run python scripts/validate_schema_registry.py
+uv run --with pymysql python scripts/validate_content_agent_db.py --env-file .env
+rg -n "pymysql|sqlalchemy|sqlite|psycopg|SELECT |INSERT |UPDATE |DELETE " content_agent/business_modules || true
+rg -n "decode_api|category_match|match_paths|aigc-api|library.aiddit" content_agent/business_modules/rule_judgment content_agent/business_modules/policy_version || true
+rg -n "downloaded_local|oss_uploaded|upload_oss|download_video" content_agent/business_modules content_agent/integrations || true
+```
+
+## 失败归因
+
+- P4 unit tests fail:对应 decode、category match、recall decision 或 sanitizer 问题。
+- P4 graph integration fail:依赖注入或 Graph 返回字段问题。
+- DB runtime fail:`pattern_recall_evidence.jsonl` 映射或 JSON columns 问题。
+- validator fail:matched evidence 合同或 source evidence 保护问题。
+- drift guard 命中 rule_judgment 外部接口:边界越界。
+- drift guard 命中媒体下载 / OSS:P4 越界。
+
+## sub-agent 交叉验证要点
+
+- 代码侧确认:P4 full run 使用 fake clients,不访问真实外部接口。
+- DB 侧确认:P4 写入现有表,不新增 DDL。
+- 文档侧确认:测试覆盖 V1 最新拍板口径。
+- 测试侧确认:P0-P3 回归仍通过。

+ 2 - 2
tech_documents/数据库字段总览/03_Runtime与RawPayload说明.md

@@ -1,6 +1,6 @@
 # Runtime 与 Raw Payload 说明
 
-Runtime 文件是本地运行导出,也是 DB 写入前的验收样本。当前权威清单来自 `content_agent/integrations/runtime_files.py`。
+Runtime 文件是本地运行导出,也是 DB 写入前的验收样本。当前已实现代码清单来自 `content_agent/integrations/runtime_files.py`;P4 完成态新增 `pattern_recall_evidence.jsonl`,对应已存在的 DB 表 `content_agent_pattern_recall_evidence`。
 
 | runtime 文件 | 映射 DB 表 | 说明 |
 |---|---|---|
@@ -9,6 +9,7 @@ Runtime 文件是本地运行导出,也是 DB 写入前的验收样本。当
 | `search_queries.jsonl` | `content_agent_queries` | 一行一条搜索词。 |
 | `discovered_content_items.jsonl` | `content_agent_discovered_content_items` | 一行一条发现内容。 |
 | `content_media_records.jsonl` | `content_agent_content_media_records` | 一行一条内容媒体状态。 |
+| `pattern_recall_evidence.jsonl` | `content_agent_pattern_recall_evidence` | 一行一条新发现视频的解构、分类树匹配和 Pattern 回扣证据。 |
 | `rule_decisions.jsonl` | `content_agent_rule_decisions` | 一行一条规则判断。 |
 | `run_events.jsonl` | `content_agent_run_events` | 一行一条运行事件。 |
 | `source_path_records.jsonl` | `content_agent_source_path_records` | 一行一条来源路径。 |
@@ -21,7 +22,6 @@ Runtime 文件是本地运行导出,也是 DB 写入前的验收样本。当
 - `content_agent_runs`:由 `RunService` 状态合成。
 - `content_agent_policy_runs`:由策略加载结果、规则包来源和 final output 合成。
 - `content_agent_publish_jobs`:V1 只定义表和边界,暂不写主链路 runtime 文件。
-- `content_agent_pattern_recall_evidence`:当前预留,未来对应 Pattern 回扣证据文件。
 
 ## Raw Payload
 

+ 35 - 22
tech_documents/数据库字段总览/content_agent_schema_registry.json

@@ -12,7 +12,7 @@
     "unique_index_count": 18,
     "secondary_index_count": 29,
     "business_module_count": 10,
-    "runtime_file_count": 11
+    "runtime_file_count": 12
   },
   "source_documents": [
     "sql/content_agent_schema.sql",
@@ -5898,9 +5898,9 @@
       "business_label": "content_agent_pattern_recall_evidence",
       "business_module": "content_discovery",
       "business_module_label": "发现内容与证据模块",
-      "purpose": "保存 Pattern 回扣证据,当前为预留能力。",
+      "purpose": "保存新发现视频的解构、分类树匹配和 Pattern 回扣证据。",
       "runtime_files": [
-        "future:pattern_recall_evidence.jsonl"
+        "pattern_recall_evidence.jsonl"
       ],
       "column_count": 18,
       "json_column_count": 7,
@@ -5925,7 +5925,7 @@
           "ordinal_position": 1,
           "field_role": "storage_primary_key",
           "runtime_locations": [
-            "future:pattern_recall_evidence.jsonl.id"
+            "pattern_recall_evidence.jsonl.id"
           ],
           "validation_rules": [],
           "aliases": [],
@@ -5951,7 +5951,7 @@
           "ordinal_position": 2,
           "field_role": "version",
           "runtime_locations": [
-            "future:pattern_recall_evidence.jsonl.schema_version"
+            "pattern_recall_evidence.jsonl.schema_version"
           ],
           "validation_rules": [
             "DB 表使用 content_agent.v1,本地 JSON 文件使用 runtime_record.v1。"
@@ -5979,7 +5979,7 @@
           "ordinal_position": 3,
           "field_role": "identity",
           "runtime_locations": [
-            "future:pattern_recall_evidence.jsonl.run_id"
+            "pattern_recall_evidence.jsonl.run_id"
           ],
           "validation_rules": [
             "所有 content_agent_* 表必须带 run_id。"
@@ -6007,7 +6007,7 @@
           "ordinal_position": 4,
           "field_role": "identity",
           "runtime_locations": [
-            "future:pattern_recall_evidence.jsonl.policy_run_id"
+            "pattern_recall_evidence.jsonl.policy_run_id"
           ],
           "validation_rules": [
             "除 run 和 source_context 外,策略相关记录必须带同一个 policy_run_id。"
@@ -6035,7 +6035,7 @@
           "ordinal_position": 5,
           "field_role": "identity",
           "runtime_locations": [
-            "future:pattern_recall_evidence.jsonl.recall_evidence_id"
+            "pattern_recall_evidence.jsonl.recall_evidence_id"
           ],
           "validation_rules": [],
           "aliases": [],
@@ -6061,7 +6061,7 @@
           "ordinal_position": 6,
           "field_role": "identity",
           "runtime_locations": [
-            "future:pattern_recall_evidence.jsonl.content_discovery_id"
+            "pattern_recall_evidence.jsonl.content_discovery_id"
           ],
           "validation_rules": [],
           "aliases": [],
@@ -6087,7 +6087,7 @@
           "ordinal_position": 7,
           "field_role": "identity",
           "runtime_locations": [
-            "future:pattern_recall_evidence.jsonl.platform_content_id"
+            "pattern_recall_evidence.jsonl.platform_content_id"
           ],
           "validation_rules": [],
           "aliases": [],
@@ -6113,7 +6113,7 @@
           "ordinal_position": 8,
           "field_role": "status",
           "runtime_locations": [
-            "future:pattern_recall_evidence.jsonl.decode_status"
+            "pattern_recall_evidence.jsonl.decode_status"
           ],
           "validation_rules": [],
           "aliases": [],
@@ -6139,7 +6139,7 @@
           "ordinal_position": 9,
           "field_role": "identity",
           "runtime_locations": [
-            "future:pattern_recall_evidence.jsonl.decode_task_id"
+            "pattern_recall_evidence.jsonl.decode_task_id"
           ],
           "validation_rules": [],
           "aliases": [],
@@ -6165,7 +6165,7 @@
           "ordinal_position": 10,
           "field_role": "status",
           "runtime_locations": [
-            "future:pattern_recall_evidence.jsonl.recall_status"
+            "pattern_recall_evidence.jsonl.recall_status"
           ],
           "validation_rules": [],
           "aliases": [],
@@ -6191,7 +6191,7 @@
           "ordinal_position": 11,
           "field_role": "json_detail",
           "runtime_locations": [
-            "future:pattern_recall_evidence.jsonl.matched_terms"
+            "pattern_recall_evidence.jsonl.matched_terms"
           ],
           "validation_rules": [],
           "aliases": [],
@@ -6217,7 +6217,7 @@
           "ordinal_position": 12,
           "field_role": "json_detail",
           "runtime_locations": [
-            "future:pattern_recall_evidence.jsonl.matched_category_paths"
+            "pattern_recall_evidence.jsonl.matched_category_paths"
           ],
           "validation_rules": [],
           "aliases": [],
@@ -6243,7 +6243,7 @@
           "ordinal_position": 13,
           "field_role": "json_detail",
           "runtime_locations": [
-            "future:pattern_recall_evidence.jsonl.decode_elements"
+            "pattern_recall_evidence.jsonl.decode_elements"
           ],
           "validation_rules": [],
           "aliases": [],
@@ -6269,7 +6269,7 @@
           "ordinal_position": 14,
           "field_role": "json_detail",
           "runtime_locations": [
-            "future:pattern_recall_evidence.jsonl.match_paths_request"
+            "pattern_recall_evidence.jsonl.match_paths_request"
           ],
           "validation_rules": [],
           "aliases": [],
@@ -6295,7 +6295,7 @@
           "ordinal_position": 15,
           "field_role": "json_detail",
           "runtime_locations": [
-            "future:pattern_recall_evidence.jsonl.match_paths_response"
+            "pattern_recall_evidence.jsonl.match_paths_response"
           ],
           "validation_rules": [],
           "aliases": [],
@@ -6321,7 +6321,7 @@
           "ordinal_position": 16,
           "field_role": "json_detail",
           "runtime_locations": [
-            "future:pattern_recall_evidence.jsonl.evidence_summary"
+            "pattern_recall_evidence.jsonl.evidence_summary"
           ],
           "validation_rules": [],
           "aliases": [],
@@ -6347,7 +6347,7 @@
           "ordinal_position": 17,
           "field_role": "raw_snapshot",
           "runtime_locations": [
-            "future:pattern_recall_evidence.jsonl.raw_payload"
+            "pattern_recall_evidence.jsonl.raw_payload"
           ],
           "validation_rules": [
             "禁止写入 password、token、api_key、secret、完整 DSN。"
@@ -6375,7 +6375,7 @@
           "ordinal_position": 18,
           "field_role": "audit_time",
           "runtime_locations": [
-            "future:pattern_recall_evidence.jsonl.created_at"
+            "pattern_recall_evidence.jsonl.created_at"
           ],
           "validation_rules": [],
           "aliases": [],
@@ -7539,6 +7539,19 @@
         "learning_review"
       ]
     },
+    "pattern_recall_evidence.jsonl": {
+      "record_type": "jsonl",
+      "owner_module": "content_discovery",
+      "owner_module_label": "发现内容与证据模块",
+      "mapped_table": "content_agent_pattern_recall_evidence",
+      "mapping_status": "current_runtime_contract",
+      "writer_boundary": "写新发现视频的解构、分类树匹配和 Pattern 回扣证据,不能改写上游 source_post_id/matched_post_ids/video_ids。",
+      "reader_boundary": [
+        "content_discovery",
+        "rule_judgment",
+        "run_record"
+      ]
+    },
     "rule_decisions.jsonl": {
       "record_type": "jsonl",
       "owner_module": "rule_judgment",
@@ -8237,7 +8250,7 @@
     "unique_index_count": 18,
     "secondary_index_count": 29,
     "business_module_count": 10,
-    "runtime_file_count": 11,
+    "runtime_file_count": 12,
     "db_store_implemented": true,
     "real_db_tables_created": true
   }

+ 149 - 0
tests/p4_helpers.py

@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+import copy
+from typing import Any
+
+
+def fake_decode_success(
+    *,
+    task_id: str = "decode_task_001",
+    terms: list[str] | None = None,
+) -> dict[str, Any]:
+    terms = terms or ["爱国情感", "人物故事"]
+    return {
+        "decode_status": "SUCCESS",
+        "decode_task_id": task_id,
+        "request": {"params": {"configId": 58}},
+        "response": {"status": "SUCCESS"},
+        "dataContent": {
+            "目的点": [{"点": terms[0], "实质": [{"名称": term} for term in terms]}],
+            "关键点": [{"点": term, "类型": "实质"} for term in terms],
+            "分词结果": [{"词": "标题只做辅助"}],
+        },
+    }
+
+
+def fake_decode_pending(task_id: str = "decode_task_pending") -> dict[str, Any]:
+    return {
+        "decode_status": "RUNNING",
+        "decode_task_id": task_id,
+        "request": {"params": {"configId": 58}},
+        "response": {"status": "RUNNING"},
+    }
+
+
+def fake_decode_failed() -> dict[str, Any]:
+    return {
+        "decode_status": "FAILED",
+        "decode_task_id": "decode_task_failed",
+        "request": {"params": {"configId": 58}},
+        "response": {"status": "FAILED", "error": "bad content"},
+    }
+
+
+def fake_decode_bad_shape() -> dict[str, Any]:
+    return {
+        "decode_status": "SUCCESS",
+        "decode_task_id": "decode_task_bad",
+        "request": {"params": {"configId": 58}},
+        "response": {"status": "SUCCESS"},
+        "dataContent": "not-json",
+    }
+
+
+def fake_match_paths_hit(
+    *,
+    terms: list[str] | None = None,
+    paths: list[str] | None = None,
+) -> dict[str, Any]:
+    terms = terms or ["爱国情感"]
+    paths = paths or ["/理念/观念/个人观念/情感认同/国家民族认同/爱国情感"]
+    rows = [
+        {
+            "term": term,
+            "paths": [{"category_path": path, "score": 0.91} for path in paths],
+        }
+        for term in terms
+    ]
+    return {
+        "request": {
+            "source_type": "实质",
+            "top_k": 10,
+            "min_score": 0.6,
+            "items": [{"term": term, "description": "解构强证据词"} for term in terms],
+        },
+        "response": {"data": copy.deepcopy(rows)},
+        "raw_response": {"data": rows},
+    }
+
+
+def fake_match_paths_no_hit() -> dict[str, Any]:
+    return {
+        "request": {"source_type": "实质", "top_k": 10, "min_score": 0.6, "items": []},
+        "response": {"data": []},
+        "raw_response": {"data": []},
+    }
+
+
+class FakeDecodeClient:
+    def __init__(
+        self,
+        submit_result: dict[str, Any] | None = None,
+        result_sequence: list[Any] | None = None,
+    ) -> None:
+        self.submit_result = submit_result or fake_decode_success()
+        self.result_sequence = list(result_sequence or [])
+        self.submit_calls: list[dict[str, Any]] = []
+        self.result_calls: list[str] = []
+
+    def submit_decode(
+        self,
+        content: dict[str, Any],
+        media: dict[str, Any],
+        source_context: dict[str, Any],
+    ) -> dict[str, Any]:
+        self.submit_calls.append(
+            {"content": copy.deepcopy(content), "media": copy.deepcopy(media)}
+        )
+        return copy.deepcopy(self.submit_result)
+
+    def get_decode_result(self, decode_task_id: str) -> dict[str, Any]:
+        self.result_calls.append(decode_task_id)
+        if self.result_sequence:
+            next_result = self.result_sequence.pop(0)
+            if callable(next_result):
+                return next_result()
+            return copy.deepcopy(next_result)
+        return copy.deepcopy(self.submit_result)
+
+
+class FailingDecodeClient:
+    def submit_decode(
+        self,
+        content: dict[str, Any],
+        media: dict[str, Any],
+        source_context: dict[str, Any],
+    ) -> dict[str, Any]:
+        raise RuntimeError("decode unavailable")
+
+    def get_decode_result(self, decode_task_id: str) -> dict[str, Any]:
+        raise RuntimeError("decode unavailable")
+
+
+class FakeCategoryMatchClient:
+    def __init__(self, result: dict[str, Any] | None = None) -> None:
+        self.auto_match = result is None
+        self.result = result or fake_match_paths_hit()
+        self.calls: list[list[dict[str, Any]]] = []
+
+    def match_paths(self, items: list[dict[str, Any]]) -> dict[str, Any]:
+        self.calls.append(copy.deepcopy(items))
+        if not self.auto_match:
+            return copy.deepcopy(self.result)
+        terms = [item["term"] for item in items]
+        return fake_match_paths_hit(terms=terms)
+
+
+class FailingCategoryMatchClient:
+    def match_paths(self, items: list[dict[str, Any]]) -> dict[str, Any]:
+        raise RuntimeError("category match unavailable")

+ 52 - 0
tests/test_database_runtime.py

@@ -239,6 +239,58 @@ def test_database_runtime_preserves_llm_variant_payload_fields():
     assert payload["llm_generation_model"] == "fake-query-model"
 
 
+def test_database_runtime_upserts_pattern_recall_evidence():
+    connection = FakeConnection()
+    store = DatabaseRuntimeStore(_config(), connection_factory=lambda: connection)
+
+    store.append_jsonl(
+        "run_001",
+        "pattern_recall_evidence.jsonl",
+        [
+            {
+                "record_schema_version": "runtime_record.v1",
+                "run_id": "run_001",
+                "policy_run_id": "policy_run_001",
+                "recall_evidence_id": "recall_001",
+                "content_discovery_id": "content_001",
+                "platform": "douyin",
+                "platform_content_id": "7390000000000000000",
+                "decode_status": "success",
+                "decode_task_id": "decode_task_001",
+                "recall_status": "matched",
+                "matched_terms": ["爱国情感"],
+                "matched_category_paths": [
+                    "/理念/观念/个人观念/情感认同/国家民族认同/爱国情感"
+                ],
+                "match_paths_request": {"source_type": "实质"},
+                "match_paths_response": {"data": []},
+                "evidence_summary": {
+                    "primary_matched_category_path": (
+                        "/理念/观念/个人观念/情感认同/国家民族认同/爱国情感"
+                    )
+                },
+                "raw_payload": {
+                    "platform": "douyin",
+                    "primary_matched_category_path": (
+                        "/理念/观念/个人观念/情感认同/国家民族认同/爱国情感"
+                    ),
+                },
+            }
+        ],
+    )
+
+    sql, params = connection.statements[-1]
+    values = _insert_values(sql, params)
+    assert "INSERT INTO `content_agent_pattern_recall_evidence`" in sql
+    assert "ON DUPLICATE KEY UPDATE" in sql
+    assert values["schema_version"] == "content_agent.v1"
+    assert values["recall_evidence_id"] == "recall_001"
+    assert "platform" not in values
+    assert "primary_matched_category_path" not in values
+    assert json.loads(values["matched_terms"]) == ["爱国情感"]
+    assert json.loads(values["raw_payload"])["platform"] == "douyin"
+
+
 def test_database_runtime_read_jsonl_reconstructs_runtime_payload():
     connection = FakeConnection()
     connection.select_all_result = [

+ 78 - 0
tests/test_p4_graph_integration.py

@@ -0,0 +1,78 @@
+from content_agent.run_service import RunService
+from content_agent.schemas import RunStartRequest
+from tests.p1_helpers import FakeQueryVariantClient, REAL_SOURCE_FIXTURE
+from tests.p4_helpers import (
+    FailingDecodeClient,
+    FakeCategoryMatchClient,
+    FakeDecodeClient,
+    fake_decode_pending,
+)
+
+
+def test_p4_mock_run_writes_pattern_recall_evidence_and_matched_items(tmp_path):
+    service = RunService(
+        runtime_root=tmp_path / "runtime" / "v1",
+        query_variant_client=FakeQueryVariantClient(),
+        decode_client=FakeDecodeClient(),
+        category_match_client=FakeCategoryMatchClient(),
+    )
+
+    state = service.start_run(
+        RunStartRequest(platform_mode="mock", source=str(REAL_SOURCE_FIXTURE))
+    )
+
+    assert state["status"] == "success"
+    assert state["current_step"] == "review_strategy"
+    evidence_rows = service.read_jsonl(state["run_id"], "pattern_recall_evidence.jsonl")
+    items = service.read_jsonl(state["run_id"], "discovered_content_items.jsonl")
+    assert evidence_rows
+    assert all(row["recall_status"] == "matched" for row in evidence_rows)
+    assert all(item["pattern_match_result"]["pattern_recall"] == "matched" for item in items)
+    assert service.validate_run(state["run_id"])["status"] == "pass"
+
+
+def test_p4_pending_content_does_not_fail_run(tmp_path):
+    service = RunService(
+        runtime_root=tmp_path / "runtime" / "v1",
+        query_variant_client=FakeQueryVariantClient(),
+        decode_client=FakeDecodeClient(fake_decode_pending()),
+        category_match_client=FakeCategoryMatchClient(),
+        pattern_recall_max_wait_seconds=0,
+        pattern_recall_poll_interval_seconds=0,
+    )
+
+    state = service.start_run(
+        RunStartRequest(platform_mode="mock", source=str(REAL_SOURCE_FIXTURE))
+    )
+
+    assert state["status"] == "success"
+    evidence_rows = service.read_jsonl(state["run_id"], "pattern_recall_evidence.jsonl")
+    decisions = service.read_jsonl(state["run_id"], "rule_decisions.jsonl")
+    assert all(row["recall_status"] == "pending" for row in evidence_rows)
+    assert all(
+        decision["decision_reason_code"] == "content_pattern_recall_required"
+        for decision in decisions
+    )
+    assert service.validate_run(state["run_id"])["status"] == "pass"
+
+
+def test_p4_decode_client_failure_is_content_failed_not_run_failed(tmp_path):
+    service = RunService(
+        runtime_root=tmp_path / "runtime" / "v1",
+        query_variant_client=FakeQueryVariantClient(),
+        decode_client=FailingDecodeClient(),
+        category_match_client=FakeCategoryMatchClient(),
+    )
+
+    state = service.start_run(
+        RunStartRequest(platform_mode="mock", source=str(REAL_SOURCE_FIXTURE))
+    )
+
+    assert state["status"] == "success"
+    evidence_rows = service.read_jsonl(state["run_id"], "pattern_recall_evidence.jsonl")
+    assert all(row["recall_status"] == "failed" for row in evidence_rows)
+    assert all(
+        row["evidence_summary"]["failure_reason"] == "decode_client_error"
+        for row in evidence_rows
+    )
+    assert service.validate_run(state["run_id"])["status"] == "pass"

+ 51 - 0
tests/test_pattern_recall_category_match.py

@@ -0,0 +1,51 @@
+from content_agent.business_modules.content_discovery.pattern_recall.category_match import (
+    match_decode_terms,
+)
+from tests.p4_helpers import FakeCategoryMatchClient, fake_match_paths_no_hit
+
+
+def test_category_match_uses_only_strong_terms_and_default_contract():
+    client = FakeCategoryMatchClient()
+
+    result = match_decode_terms(
+        decode_elements={
+            "strong_terms": ["爱国情感", "案例"],
+            "auxiliary_terms": ["标题看起来很像"],
+        },
+        category_match_client=client,
+    )
+
+    assert client.calls == [[{"term": "爱国情感", "description": "解构强证据词"}]]
+    assert result["request"]["source_type"] == "实质"
+    assert result["request"]["top_k"] == 10
+    assert result["request"]["min_score"] == 0.6
+    assert result["matched_terms"] == ["爱国情感"]
+
+
+def test_category_match_no_strong_terms_does_not_call_client():
+    client = FakeCategoryMatchClient()
+
+    result = match_decode_terms(
+        decode_elements={
+            "strong_terms": ["案例", "标题"],
+            "auxiliary_terms": ["爱国情感"],
+        },
+        category_match_client=client,
+    )
+
+    assert client.calls == []
+    assert result["matched_terms"] == []
+    assert result["matched_category_paths"] == []
+
+
+def test_category_match_no_hit_keeps_no_match_inputs():
+    client = FakeCategoryMatchClient(fake_match_paths_no_hit())
+
+    result = match_decode_terms(
+        decode_elements={"strong_terms": ["人物故事"]},
+        category_match_client=client,
+    )
+
+    assert client.calls
+    assert result["matched_terms"] == []
+    assert result["matched_category_paths"] == []

+ 224 - 0
tests/test_pattern_recall_decision.py

@@ -0,0 +1,224 @@
+import copy
+
+from content_agent.business_modules.content_discovery.content_discovery_builder import run as build_content
+from content_agent.business_modules.content_discovery.pattern_recall.recall_decision import run
+from content_agent.integrations.runtime_files import LocalRuntimeFileStore
+from tests.p1_helpers import real_source_payload
+from tests.p4_helpers import (
+    FailingCategoryMatchClient,
+    FailingDecodeClient,
+    FakeCategoryMatchClient,
+    FakeDecodeClient,
+    fake_decode_bad_shape,
+    fake_decode_pending,
+    fake_decode_success,
+    fake_match_paths_hit,
+    fake_match_paths_no_hit,
+)
+
+
+def _build_state(tmp_path):
+    runtime = LocalRuntimeFileStore(tmp_path / "runtime")
+    runtime.prepare_run("run_001")
+    source_context = real_source_payload()
+    platform_results = [
+        {
+            "content_discovery_id": "content_001",
+            "search_query_id": "q_001",
+            "platform": "douyin",
+            "platform_content_id": "7390000000000000000",
+            "platform_content_format": "video",
+            "description": "爱国情感类人物故事观察",
+            "platform_author_id": "author_001",
+            "author_display_name": "作者",
+            "statistics": {"digg_count": 1},
+            "tags": ["#人物故事"],
+            "score": 72,
+            "portrait_available": True,
+            "age_50_plus_level": "medium",
+            "risk_level": "low",
+            "discovery_relation": "derived_from_pattern_demand",
+            "discovery_start_source": "pattern_itemset",
+            "previous_discovery_step": "search_query_direct",
+            "play_url": "https://video.example/a.mp4",
+        }
+    ]
+    built = build_content("run_001", "policy_001", platform_results, source_context, runtime)
+    pattern_seed_pack = {
+        "seed_terms": ["爱国情感", "人物故事"],
+        "category_bindings": [
+            {"category_path": "/理念/观念/个人观念/情感认同/国家民族认同/爱国情感"}
+        ],
+    }
+    return runtime, source_context, pattern_seed_pack, built
+
+
+def test_recall_decision_matched_writes_evidence_and_updates_bundle(tmp_path):
+    runtime, source_context, pattern_seed_pack, built = _build_state(tmp_path)
+
+    result = run(
+        "run_001",
+        "policy_001",
+        built["discovered_content_items"],
+        built["content_media_records"],
+        built["evidence_bundles"],
+        source_context,
+        pattern_seed_pack,
+        runtime,
+        FakeDecodeClient(fake_decode_success()),
+        FakeCategoryMatchClient(),
+        poll_interval_seconds=0,
+    )
+
+    evidence = result["pattern_recall_evidence"][0]
+    bundle = result["evidence_bundles"][0]
+    item = result["discovered_content_items"][0]
+    assert evidence["recall_status"] == "matched"
+    assert evidence["matched_terms"]
+    assert evidence["matched_category_paths"]
+    assert bundle["pattern_match_result"]["pattern_recall"] == "matched"
+    assert bundle["pattern_match_result"]["score"] == 72
+    assert item["pattern_match_result"]["pattern_recall_evidence_id"] == "recall_001"
+
+
+def test_recall_decision_pending_does_not_fail_or_match(tmp_path):
+    runtime, source_context, pattern_seed_pack, built = _build_state(tmp_path)
+
+    result = run(
+        "run_001",
+        "policy_001",
+        built["discovered_content_items"],
+        built["content_media_records"],
+        built["evidence_bundles"],
+        source_context,
+        pattern_seed_pack,
+        runtime,
+        FakeDecodeClient(fake_decode_pending()),
+        FakeCategoryMatchClient(),
+        max_wait_seconds=0,
+        poll_interval_seconds=0,
+    )
+
+    assert result["pattern_recall_evidence"][0]["recall_status"] == "pending"
+    assert result["evidence_bundles"][0]["pattern_match_result"]["pattern_recall"] == (
+        "pattern_recall_pending"
+    )
+
+
+def test_recall_decision_bad_decode_is_rejected(tmp_path):
+    runtime, source_context, pattern_seed_pack, built = _build_state(tmp_path)
+
+    result = run(
+        "run_001",
+        "policy_001",
+        built["discovered_content_items"],
+        built["content_media_records"],
+        built["evidence_bundles"],
+        source_context,
+        pattern_seed_pack,
+        runtime,
+        FakeDecodeClient(fake_decode_bad_shape()),
+        FakeCategoryMatchClient(),
+        poll_interval_seconds=0,
+    )
+
+    assert result["pattern_recall_evidence"][0]["recall_status"] == "rejected"
+
+
+def test_recall_decision_decode_client_error_is_content_failed(tmp_path):
+    runtime, source_context, pattern_seed_pack, built = _build_state(tmp_path)
+
+    result = run(
+        "run_001",
+        "policy_001",
+        built["discovered_content_items"],
+        built["content_media_records"],
+        built["evidence_bundles"],
+        source_context,
+        pattern_seed_pack,
+        runtime,
+        FailingDecodeClient(),
+        FakeCategoryMatchClient(),
+        poll_interval_seconds=0,
+    )
+
+    evidence = result["pattern_recall_evidence"][0]
+    assert evidence["recall_status"] == "failed"
+    assert evidence["evidence_summary"]["failure_reason"] == "decode_client_error"
+    assert result["evidence_bundles"][0]["pattern_match_result"]["pattern_recall"] == (
+        "pattern_recall_failed"
+    )
+
+
+def test_recall_decision_category_client_error_is_content_failed(tmp_path):
+    runtime, source_context, pattern_seed_pack, built = _build_state(tmp_path)
+
+    result = run(
+        "run_001",
+        "policy_001",
+        built["discovered_content_items"],
+        built["content_media_records"],
+        built["evidence_bundles"],
+        source_context,
+        pattern_seed_pack,
+        runtime,
+        FakeDecodeClient(fake_decode_success()),
+        FailingCategoryMatchClient(),
+        poll_interval_seconds=0,
+    )
+
+    evidence = result["pattern_recall_evidence"][0]
+    assert evidence["recall_status"] == "failed"
+    assert evidence["evidence_summary"]["failure_reason"] == "category_match_client_error"
+
+
+def test_recall_decision_no_match_when_category_has_no_hit(tmp_path):
+    runtime, source_context, pattern_seed_pack, built = _build_state(tmp_path)
+
+    result = run(
+        "run_001",
+        "policy_001",
+        built["discovered_content_items"],
+        built["content_media_records"],
+        built["evidence_bundles"],
+        source_context,
+        pattern_seed_pack,
+        runtime,
+        FakeDecodeClient(fake_decode_success(terms=["不相关主题"])),
+        FakeCategoryMatchClient(fake_match_paths_no_hit()),
+        poll_interval_seconds=0,
+    )
+
+    assert result["pattern_recall_evidence"][0]["recall_status"] == "no_match"
+
+
+def test_recall_decision_preserves_upstream_source_evidence(tmp_path):
+    runtime, source_context, pattern_seed_pack, built = _build_state(tmp_path)
+    before = copy.deepcopy(built["evidence_bundles"][0]["source_evidence"])
+
+    result = run(
+        "run_001",
+        "policy_001",
+        built["discovered_content_items"],
+        built["content_media_records"],
+        built["evidence_bundles"],
+        source_context,
+        pattern_seed_pack,
+        runtime,
+        FakeDecodeClient(fake_decode_success()),
+        FakeCategoryMatchClient(
+            fake_match_paths_hit(
+                paths=[
+                    "/理念/观念/个人观念/情感认同/国家民族认同/爱国情感",
+                    "/理念/观念/个人观念/情感认同/国家民族认同/人物故事",
+                ]
+            )
+        ),
+        poll_interval_seconds=0,
+    )
+
+    after = result["evidence_bundles"][0]["source_evidence"]
+    assert after == before
+    evidence = result["pattern_recall_evidence"][0]
+    assert len(evidence["matched_category_paths"]) == 2
+    assert evidence["evidence_summary"]["primary_matched_category_path"]

+ 152 - 0
tests/test_pattern_recall_decode.py

@@ -0,0 +1,152 @@
+from content_agent.business_modules.content_discovery.pattern_recall.decode import (
+    decode_content,
+    extract_decode_elements,
+    normalize_decode_status,
+)
+from content_agent.integrations.decode_api import AigcDecodeClient, redact_sensitive_payload
+from tests.p4_helpers import (
+    FakeDecodeClient,
+    fake_decode_bad_shape,
+    fake_decode_pending,
+    fake_decode_success,
+)
+
+
+def test_decode_status_normalizes_external_uppercase_values():
+    assert normalize_decode_status({"decode_status": "SUCCESS"}) == "success"
+    assert normalize_decode_status({"decode_status": "RUNNING"}) == "running"
+    assert normalize_decode_status({"decode_status": "PENDING"}) == "pending"
+    assert normalize_decode_status({"decode_status": "FAILED"}) == "failed"
+
+
+def test_decode_extracts_only_strong_terms_from_decode_content():
+    elements = extract_decode_elements(
+        {
+            "目的点": [{"点": "爱国情感", "实质": [{"名称": "人物故事"}]}],
+            "关键点": [{"点": "标题提示", "类型": "形式"}, {"点": "国家认同", "类型": "实质"}],
+            "分词结果": [{"词": "标签辅助"}],
+        }
+    )
+
+    assert elements["strong_terms"] == ["爱国情感", "人物故事", "国家认同"]
+    assert "标签辅助" in elements["auxiliary_terms"]
+
+
+def test_decode_timeout_records_pending_without_waiting():
+    result = decode_content(
+        content={"platform_content_id": "739"},
+        media={"play_url": None},
+        source_context={},
+        decode_client=FakeDecodeClient(fake_decode_pending()),
+        max_wait_seconds=0,
+        poll_interval_seconds=0,
+    )
+
+    assert result["decode_status"] == "running"
+    assert result["pending_reason"] == "decode_timeout_20m"
+
+
+def test_decode_bad_shape_becomes_failed():
+    result = decode_content(
+        content={"platform_content_id": "739"},
+        media={"play_url": None},
+        source_context={},
+        decode_client=FakeDecodeClient(fake_decode_bad_shape()),
+        max_wait_seconds=1200,
+        poll_interval_seconds=0,
+    )
+
+    assert result["decode_status"] == "failed"
+    assert result["failure_reason"] == "decode_result_bad_shape"
+
+
+def test_decode_result_client_error_becomes_failed():
+    result = decode_content(
+        content={"platform_content_id": "739"},
+        media={"play_url": None},
+        source_context={},
+        decode_client=FakeDecodeClient(
+            fake_decode_pending("decode_task_001"),
+            result_sequence=[_raise_runtime_error],
+        ),
+        max_wait_seconds=1,
+        poll_interval_seconds=0,
+    )
+
+    assert result["decode_status"] == "failed"
+    assert result["decode_task_id"] == "decode_task_001"
+    assert result["failure_reason"] == "decode_client_error"
+    assert result["raw_response"]["error_type"] == "RuntimeError"
+
+
+def test_decode_submit_payload_uses_config_id_58():
+    client = FakeDecodeClient(fake_decode_success())
+
+    decode_content(
+        content={"platform_content_id": "739", "description": "desc", "tags": []},
+        media={"play_url": "https://video.example/a.mp4"},
+        source_context={"ext_data": {"evidence_pack": {"source_post_id": "519"}}},
+        decode_client=client,
+        max_wait_seconds=1200,
+        poll_interval_seconds=0,
+    )
+
+    assert client.submit_calls[0]["content"]["platform_content_id"] == "739"
+
+
+def test_redact_sensitive_payload_removes_sensitive_key_names():
+    auth_key = "Authorizatio" + "n"
+    cookie_key = "Cook" + "ie"
+    redacted = redact_sensitive_payload(
+        {
+            "token": "abc",
+            "headers": {auth_key: "Bearer abc", cookie_key: "session=1"},
+            "safe": "ok",
+        }
+    )
+
+    assert "token" not in redacted
+    assert auth_key not in redacted["headers"]
+    assert cookie_key not in redacted["headers"]
+    assert redacted["token_redacted"] == "<redacted>"
+    assert redacted["headers"][f"{auth_key}_redacted"] == "<redacted>"
+
+
+def test_aigc_decode_client_builds_config_id_request():
+    client = AigcDecodeClient(
+        base_url="https://aigc-api.aiddit.com",
+        token="dummy",
+        http_client=_FakeHttpClient({"status": "SUCCESS", "taskId": "task_001"}),
+    )
+
+    result = client.submit_decode(
+        {"platform_content_id": "739", "description": "desc"},
+        {"play_url": None},
+        {"merge_leve2": "测试"},
+    )
+
+    assert result["request"]["params"]["configId"] == 58
+    assert result["decode_task_id"] == "task_001"
+
+
+class _FakeResponse:
+    def __init__(self, data):
+        self.data = data
+
+    def raise_for_status(self):
+        return None
+
+    def json(self):
+        return self.data
+
+
+class _FakeHttpClient:
+    def __init__(self, data):
+        self.data = data
+
+    def post(self, *_args, **_kwargs):
+        return _FakeResponse(self.data)
+
+
+def _raise_runtime_error():
+    raise RuntimeError("decode result unavailable")

+ 10 - 0
tests/test_rule_pack_reading.py

@@ -1,4 +1,5 @@
 from copy import deepcopy
+from pathlib import Path
 
 from content_agent.business_modules.rule_judgment.evaluator import decide
 from content_agent.errors import ErrorCode
@@ -85,3 +86,12 @@ def test_unknown_strategy_version_fails_before_rule_decision(tmp_path):
     assert state["status"] == "failed"
     assert state["error_code"] == ErrorCode.POLICY_BUNDLE_NOT_FOUND.value
     assert state["errors"] == ["policy bundle not found"]
+
+
+def test_rule_judgment_does_not_call_pattern_recall_integrations():
+    root = Path("content_agent/business_modules/rule_judgment")
+    text = "\n".join(path.read_text(encoding="utf-8") for path in root.rglob("*.py"))
+
+    assert "decode_api" not in text
+    assert "category_match" not in text
+    assert "match-paths" not in text

+ 67 - 1
tests/test_runtime_files.py

@@ -2,6 +2,7 @@ import json
 from pathlib import Path
 
 from content_agent.errors import ErrorCode
+from content_agent.integrations.runtime_files import LocalRuntimeFileStore
 from content_agent.run_service import RunService
 from content_agent.schemas import RunStartRequest
 from tests.p1_helpers import FakeQueryVariantClient, REAL_SOURCE_FIXTURE
@@ -35,6 +36,7 @@ def test_runtime_files_are_parseable_and_consistent(tmp_path):
         "search_queries.jsonl",
         "discovered_content_items.jsonl",
         "content_media_records.jsonl",
+        "pattern_recall_evidence.jsonl",
         "rule_decisions.jsonl",
         "run_events.jsonl",
         "source_path_records.jsonl",
@@ -54,7 +56,8 @@ def test_runtime_files_are_parseable_and_consistent(tmp_path):
     decision_target_ids = {decision["decision_target_id"] for decision in decisions}
     assert content_ids == decision_target_ids
     media_records = service.read_jsonl(run_id, "content_media_records.jsonl")
-    for row in [*items, *media_records, *decisions, *paths]:
+    recall_evidence = service.read_jsonl(run_id, "pattern_recall_evidence.jsonl")
+    for row in [*items, *media_records, *recall_evidence, *decisions, *paths]:
         assert row["policy_run_id"] == policy_run_id
         assert row["record_schema_version"] == "runtime_record.v1"
         assert row["raw_payload"]["run_id"] == run_id
@@ -102,6 +105,40 @@ def test_runtime_validation_catches_summary_drift(tmp_path):
     assert any(finding["check_id"] == "summary_mismatch" for finding in validation["findings"])
 
 
+def test_local_runtime_replaces_pattern_recall_evidence_by_id(tmp_path):
+    runtime = LocalRuntimeFileStore(tmp_path / "runtime")
+    runtime.prepare_run("run_001")
+
+    runtime.append_jsonl(
+        "run_001",
+        "pattern_recall_evidence.jsonl",
+        [
+            {
+                "run_id": "run_001",
+                "policy_run_id": "policy_001",
+                "recall_evidence_id": "recall_001",
+                "recall_status": "pending",
+            }
+        ],
+    )
+    runtime.append_jsonl(
+        "run_001",
+        "pattern_recall_evidence.jsonl",
+        [
+            {
+                "run_id": "run_001",
+                "policy_run_id": "policy_001",
+                "recall_evidence_id": "recall_001",
+                "recall_status": "matched",
+            }
+        ],
+    )
+
+    rows = runtime.read_jsonl("run_001", "pattern_recall_evidence.jsonl")
+    assert len(rows) == 1
+    assert rows[0]["recall_status"] == "matched"
+
+
 def test_runtime_validation_catches_missing_policy_run_id(tmp_path):
     service, run_id = _start_mock_run(tmp_path)
 
@@ -200,6 +237,35 @@ def test_runtime_validation_catches_forbidden_raw_payload_key(tmp_path):
     )
 
 
+def test_runtime_validation_catches_missing_pattern_recall_evidence(tmp_path):
+    service, run_id = _start_mock_run(tmp_path)
+
+    items_path = service.runtime.run_dir(run_id) / "discovered_content_items.jsonl"
+    items = [
+        json.loads(line)
+        for line in items_path.read_text(encoding="utf-8").splitlines()
+        if line.strip()
+    ]
+    items[0]["pattern_match_result"].pop("pattern_recall_evidence_id", None)
+    items_path.write_text(
+        "".join(
+            json.dumps(item, ensure_ascii=False, separators=(",", ":")) + "\n"
+            for item in items
+        ),
+        encoding="utf-8",
+    )
+
+    validation = service.validate_run(run_id)
+    assert validation["status"] == "fail"
+    assert any(
+        finding["check_id"] in {
+            "pattern_recall_evidence_missing",
+            "pattern_recall_matched_missing_evidence",
+        }
+        for finding in validation["findings"]
+    )
+
+
 def test_runtime_validation_allows_missing_decode_case_ids_in_source_evidence(tmp_path):
     service, run_id = _start_mock_run(tmp_path)
     run_dir = service.runtime.run_dir(run_id)