Просмотр исходного кода

Implement P7 final output and asset persistence

Sam Lee 5 дней назад
Родитель
Сommit
a5f99a6563
46 измененных файлов с 3963 добавлено и 820 удалено
  1. 333 49
      content_agent/business_modules/result_source_lookup.py
  2. 200 1
      content_agent/business_modules/run_record/validation.py
  3. 21 0
      content_agent/integrations/composite_runtime.py
  4. 111 0
      content_agent/integrations/database_runtime.py
  5. 5 0
      content_agent/integrations/policy_json.py
  6. 17 0
      content_agent/integrations/runtime_files.py
  7. 9 0
      content_agent/interfaces.py
  8. 16 1
      content_agent/run_service.py
  9. 1 4
      product_documents/README.md
  10. 11 8
      product_documents/prd/V1落地版本.md
  11. 53 5
      product_documents/prd/V1落地版本细化版.md
  12. 12 13
      product_documents/prd/产品方案总表.md
  13. 0 602
      product_documents/抖音游走策略/douyin_available_walk_strategy.v1.json
  14. 1 2
      product_documents/抖音游走策略/douyin_evidence_bundle.v1.json
  15. 50 24
      product_documents/抖音游走策略/runtime_v1_records_schema.md
  16. 5 56
      product_documents/规则包/douyin_rule_packs.v1.json
  17. 3 3
      product_documents/规则包/抖音规则包V1.md
  18. 30 0
      scripts/validate_content_agent_db.py
  19. 4 4
      scripts/validate_schema_registry.py
  20. 46 0
      sql/content_agent_schema.sql
  21. 81 17
      tech_documents/工程落地/04_V1阶段开发计划.md
  22. 182 0
      tech_documents/工程落地/implementation_briefs/P7/00_P7_Brief_Index.md
  23. 122 0
      tech_documents/工程落地/implementation_briefs/P7/P7A_FinalOutput_View_And_ReviewRecords.md
  24. 122 0
      tech_documents/工程落地/implementation_briefs/P7/P7B_PublishJobs_DBOnly.md
  25. 188 0
      tech_documents/工程落地/implementation_briefs/P7/P7C_AuthorAssets_DB_And_Runtime.md
  26. 104 0
      tech_documents/工程落地/implementation_briefs/P7/P7D_DecisionToAsset_SourcePath_And_LineageValidation.md
  27. 122 0
      tech_documents/工程落地/implementation_briefs/P7/P7E_Policy_And_WalkStrategy_Version_Output.md
  28. 116 0
      tech_documents/工程落地/implementation_briefs/P7/P7F_DeprecatedAction_And_OldStrategy_DriftCleanup.md
  29. 104 0
      tech_documents/工程落地/implementation_briefs/P7/P7G_RunCompleteness_ValidationComputed.md
  30. 140 0
      tech_documents/工程落地/implementation_briefs/P7/P7H_P7_Acceptance_And_DriftGuards.md
  31. 36 0
      tech_documents/工程落地/implementation_briefs/P7/P7_completion_evidence.md
  32. 8 7
      tech_documents/数据库字段总览/00_数据库字段总览.md
  33. 7 5
      tech_documents/数据库字段总览/02_按数据库表查看.md
  34. 1019 6
      tech_documents/数据库字段总览/content_agent_schema_registry.json
  35. 27 6
      tech_documents/数据库字段总览/content_agent_schema_registry_count_report.json
  36. 6 4
      tech_documents/数据库字段总览/content_agent_schema_registry_count_report.md
  37. 126 0
      tests/test_database_runtime.py
  38. 26 0
      tests/test_p0d_p0g.py
  39. 172 0
      tests/test_p7_author_assets.py
  40. 66 0
      tests/test_p7_drift_guards.py
  41. 69 0
      tests/test_p7_final_output.py
  42. 98 0
      tests/test_p7_lineage_validation.py
  43. 44 0
      tests/test_p7_policy_walk_versions.py
  44. 40 0
      tests/test_p7_publish_jobs.py
  45. 5 2
      tests/test_runtime_files.py
  46. 5 1
      tests/test_v1_graph.py

+ 333 - 49
content_agent/business_modules/result_source_lookup.py

@@ -1,9 +1,11 @@
 from __future__ import annotations
 
+import hashlib
 from collections import Counter
 from typing import Any
 
 from content_agent.constants import RUNTIME_SCHEMA_VERSION
+from content_agent.business_modules.run_record.validation import compute_final_output_completeness
 from content_agent.interfaces import RuntimeFileStore
 
 
@@ -23,45 +25,32 @@ def run(
         media["platform_content_id"]: media for media in content_media_records
     }
     paths_by_content_id = _paths_by_content_id(source_path_records)
-
-    content_assets: list[dict[str, Any]] = []
-    reject_records: list[dict[str, Any]] = []
-    for item in discovered_content_items:
-        decision = decision_by_target_id[item["platform_content_id"]]
-        if decision["decision_action"] == "ADD_TO_CONTENT_POOL":
-            content_assets.append(
-                {
-                    "platform": item["platform"],
-                    "platform_content_id": item["platform_content_id"],
-                    "policy_run_id": policy_run_id,
-                    "content_discovery_id": item["content_discovery_id"],
-                    "final_asset_status": "pooled",
-                    "decision_id": decision["decision_id"],
-                    "rule_pack_id": decision["rule_pack_id"],
-                    "rule_pack_version": decision["rule_pack_version"],
-                    "strategy_version": decision["strategy_version"],
-                    "source_path_record_ids": paths_by_content_id[item["platform_content_id"]],
-                    "source_evidence": {
-                        **decision["source_evidence"],
-                        "source_path_record_ids": paths_by_content_id[
-                            item["platform_content_id"]
-                        ],
-                    },
-                    "content_media_status": media_by_platform_content_id[
-                        item["platform_content_id"]
-                    ]["content_media_status"],
-                }
-            )
-        if decision["decision_action"] == "REJECT_CONTENT":
-            reject_records.append(
-                {
-                    "decision_target_id": item["platform_content_id"],
-                    "policy_run_id": policy_run_id,
-                    "main_decision_reason_code": decision["decision_reason_code"],
-                    "decision_id": decision["decision_id"],
-                    "source_evidence": decision["source_evidence"],
-                }
-            )
+    content_assets = _build_content_assets(
+        policy_run_id,
+        discovered_content_items,
+        decision_by_target_id,
+        media_by_platform_content_id,
+        paths_by_content_id,
+    )
+    review_records = _build_review_records(
+        policy_run_id,
+        discovered_content_items,
+        decision_by_target_id,
+        media_by_platform_content_id,
+        paths_by_content_id,
+    )
+    reject_records = _build_reject_records(
+        policy_run_id,
+        discovered_content_items,
+        decision_by_target_id,
+    )
+    author_assets, author_asset_rows, author_role_rows = _build_author_assets(
+        run_id,
+        policy_run_id,
+        discovered_content_items,
+        decision_by_target_id,
+        paths_by_content_id,
+    )
 
     action_counts = Counter(decision["decision_action"] for decision in decisions)
     effect_status_counts = Counter(
@@ -71,19 +60,27 @@ def run(
         "schema_version": RUNTIME_SCHEMA_VERSION,
         "run_id": run_id,
         "policy_run_id": policy_run_id,
-        "policy_bundle_id": policy_bundle["policy_bundle_id"],
-        "strategy_id": policy_bundle["strategy_id"],
-        "strategy_version": policy_bundle["strategy_version"],
-        "rule_pack_id": policy_bundle["rule_pack_id"],
-        "rule_pack_version": policy_bundle["rule_pack_version"],
-        "policy_bundle_hash": policy_bundle["policy_bundle_hash"],
+        "policy": {
+            "policy_bundle_id": policy_bundle["policy_bundle_id"],
+            "strategy_id": policy_bundle["strategy_id"],
+            "strategy_version": policy_bundle["strategy_version"],
+            "rule_pack_id": policy_bundle["rule_pack_id"],
+            "rule_pack_version": policy_bundle["rule_pack_version"],
+            "policy_bundle_hash": policy_bundle["policy_bundle_hash"],
+            "strategy_source_ref": policy_bundle["strategy_source_ref"],
+            "rule_pack_source_ref": policy_bundle["rule_pack_source_ref"],
+        },
+        "walk_strategy": {
+            "walk_strategy_id": policy_bundle.get("walk_strategy_id"),
+            "walk_strategy_version": policy_bundle.get("walk_strategy_version"),
+            "walk_strategy_source_ref": policy_bundle.get("walk_strategy_source_ref"),
+        },
         "dispatch": policy_bundle.get("dispatch"),
         "dispatch_id": policy_bundle.get("dispatch_id"),
         "runtime_status_contract": policy_bundle.get("runtime_status_contract", {}),
-        "strategy_source_ref": policy_bundle["strategy_source_ref"],
-        "rule_pack_source_ref": policy_bundle["rule_pack_source_ref"],
         "content_assets": content_assets,
-        "author_assets": [],
+        "author_assets": author_assets,
+        "review_records": review_records,
         "decision_records": [
             {
                 "decision_id": decision["decision_id"],
@@ -123,13 +120,295 @@ def run(
                 "rule_blocked": effect_status_counts["rule_blocked"],
             },
             "policy_bundle_hash": policy_bundle["policy_bundle_hash"],
-            "run_path_complete": True,
         },
     }
+    completeness = compute_final_output_completeness(
+        final_output,
+        decisions,
+        source_path_records,
+    )
+    final_output["validation_status"] = completeness["validation_status"]
+    final_output["summary"]["run_path_complete"] = completeness["run_path_complete"]
+    final_output["summary"]["trace_complete"] = completeness["trace_complete"]
+    final_output["summary"]["validation_findings_summary"] = completeness["findings_summary"]
     runtime.write_json(run_id, "final_output.json", final_output)
+    runtime.write_publish_jobs(
+        run_id,
+        policy_run_id,
+        _build_publish_jobs(run_id, policy_run_id, content_assets),
+    )
+    runtime.write_author_assets(author_asset_rows)
+    runtime.write_author_asset_roles(author_role_rows)
     return final_output
 
 
+def _build_content_assets(
+    policy_run_id: str,
+    discovered_content_items: list[dict[str, Any]],
+    decision_by_target_id: dict[str, dict[str, Any]],
+    media_by_platform_content_id: dict[str, dict[str, Any]],
+    paths_by_content_id: dict[str, list[str]],
+) -> list[dict[str, Any]]:
+    content_assets: list[dict[str, Any]] = []
+    for item in discovered_content_items:
+        platform_content_id = item["platform_content_id"]
+        decision = decision_by_target_id[platform_content_id]
+        if decision["decision_action"] != "ADD_TO_CONTENT_POOL":
+            continue
+        path_ids = paths_by_content_id[platform_content_id]
+        content_assets.append(
+            {
+                "platform": item["platform"],
+                "platform_content_id": platform_content_id,
+                "policy_run_id": policy_run_id,
+                "content_discovery_id": item["content_discovery_id"],
+                "final_asset_status": "pooled",
+                "decision_id": decision["decision_id"],
+                "rule_pack_id": decision["rule_pack_id"],
+                "rule_pack_version": decision["rule_pack_version"],
+                "strategy_version": decision["strategy_version"],
+                "source_path_record_ids": path_ids,
+                "source_evidence": {
+                    **decision["source_evidence"],
+                    "source_path_record_ids": path_ids,
+                },
+                "content_media_status": media_by_platform_content_id[
+                    platform_content_id
+                ]["content_media_status"],
+            }
+        )
+    return content_assets
+
+
+def _build_review_records(
+    policy_run_id: str,
+    discovered_content_items: list[dict[str, Any]],
+    decision_by_target_id: dict[str, dict[str, Any]],
+    media_by_platform_content_id: dict[str, dict[str, Any]],
+    paths_by_content_id: dict[str, list[str]],
+) -> list[dict[str, Any]]:
+    review_records: list[dict[str, Any]] = []
+    for item in discovered_content_items:
+        platform_content_id = item["platform_content_id"]
+        decision = decision_by_target_id[platform_content_id]
+        if decision["decision_action"] != "KEEP_CONTENT_FOR_REVIEW":
+            continue
+        path_ids = paths_by_content_id[platform_content_id]
+        review_records.append(
+            {
+                "platform": item["platform"],
+                "platform_content_id": platform_content_id,
+                "policy_run_id": policy_run_id,
+                "content_discovery_id": item["content_discovery_id"],
+                "review_status": "pending_review",
+                "final_asset_status": "review_only",
+                "decision_id": decision["decision_id"],
+                "rule_pack_id": decision["rule_pack_id"],
+                "rule_pack_version": decision["rule_pack_version"],
+                "strategy_version": decision["strategy_version"],
+                "decision_reason_code": decision["decision_reason_code"],
+                "source_path_record_ids": path_ids,
+                "source_evidence": {
+                    **decision["source_evidence"],
+                    "source_path_record_ids": path_ids,
+                },
+                "content_media_status": media_by_platform_content_id[
+                    platform_content_id
+                ]["content_media_status"],
+            }
+        )
+    return review_records
+
+
+def _build_reject_records(
+    policy_run_id: str,
+    discovered_content_items: list[dict[str, Any]],
+    decision_by_target_id: dict[str, dict[str, Any]],
+) -> list[dict[str, Any]]:
+    reject_records: list[dict[str, Any]] = []
+    for item in discovered_content_items:
+        platform_content_id = item["platform_content_id"]
+        decision = decision_by_target_id[platform_content_id]
+        if decision["decision_action"] != "REJECT_CONTENT":
+            continue
+        reject_records.append(
+            {
+                "decision_target_id": platform_content_id,
+                "policy_run_id": policy_run_id,
+                "main_decision_reason_code": decision["decision_reason_code"],
+                "decision_id": decision["decision_id"],
+                "source_evidence": decision["source_evidence"],
+            }
+        )
+    return reject_records
+
+
+def _build_publish_jobs(
+    run_id: str,
+    policy_run_id: str,
+    content_assets: list[dict[str, Any]],
+) -> list[dict[str, Any]]:
+    jobs: list[dict[str, Any]] = []
+    for asset in content_assets:
+        publish_job_id = _stable_id(
+            "publish_job",
+            run_id,
+            policy_run_id,
+            asset["platform_content_id"],
+            asset["decision_id"],
+        )
+        jobs.append(
+            {
+                "publish_job_id": publish_job_id,
+                "platform_content_id": asset["platform_content_id"],
+                "job_status": "created",
+                "trigger_mode": "manual_review",
+                "request_payload": {
+                    "run_id": run_id,
+                    "policy_run_id": policy_run_id,
+                    "content_asset": asset,
+                    "decision_id": asset["decision_id"],
+                    "source_path_record_ids": asset["source_path_record_ids"],
+                },
+                "response_payload": {},
+            }
+        )
+    return jobs
+
+
+def _build_author_assets(
+    run_id: str,
+    policy_run_id: str,
+    discovered_content_items: list[dict[str, Any]],
+    decision_by_target_id: dict[str, dict[str, Any]],
+    paths_by_content_id: dict[str, list[str]],
+) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
+    by_author: dict[tuple[str, str], list[dict[str, Any]]] = {}
+    for item in discovered_content_items:
+        author_id = item.get("platform_author_id")
+        if not author_id:
+            continue
+        platform = item.get("platform", "douyin")
+        by_author.setdefault((platform, author_id), []).append(item)
+
+    summaries: list[dict[str, Any]] = []
+    asset_rows: list[dict[str, Any]] = []
+    role_rows: list[dict[str, Any]] = []
+    for (platform, author_id), items in sorted(by_author.items()):
+        decisions = [
+            decision_by_target_id[item["platform_content_id"]]
+            for item in items
+            if item["platform_content_id"] in decision_by_target_id
+        ]
+        qualified_decisions = [
+            decision for decision in decisions if decision["decision_action"] == "ADD_TO_CONTENT_POOL"
+        ]
+        sample_count = len(items)
+        qualified_count = len(qualified_decisions)
+        qualified_ratio = qualified_count / sample_count if sample_count else 0
+        age_level = _best_age_level(decisions)
+        if not _author_asset_eligible(sample_count, qualified_count, qualified_ratio, age_level):
+            continue
+
+        author_asset_id = _stable_id("author_asset", platform, author_id)
+        source_path_record_ids = sorted(
+            {
+                path_id
+                for item in items
+                for path_id in paths_by_content_id.get(item["platform_content_id"], [])
+            }
+        )
+        decision_ids = [decision["decision_id"] for decision in decisions]
+        tags = sorted({tag for item in items for tag in item.get("tags", [])})
+        display_name = next((item.get("author_display_name") for item in items if item.get("author_display_name")), "")
+        source_type = (
+            "new_discovery"
+            if any(item.get("previous_discovery_step") == "author_work" for item in items)
+            else "new_discovery"
+        )
+        evidence_refs = {
+            "decision_ids": decision_ids,
+            "content_discovery_ids": [item["content_discovery_id"] for item in items],
+            "source_path_record_ids": source_path_record_ids,
+        }
+        profile_snapshot = {
+            "sample_count": sample_count,
+            "qualified_content_count": qualified_count,
+            "qualified_content_ratio": qualified_ratio,
+            "age_50_plus_level": age_level,
+        }
+        summary = {
+            "author_asset_id": author_asset_id,
+            "platform": platform,
+            "platform_author_id": author_id,
+            "author_display_name": display_name,
+            "asset_status": "active",
+            "roles": ["author_asset", "source_seed", "high_50plus_profile"],
+            "eligible_as_source": True,
+            "source_path_record_ids": source_path_record_ids,
+            "decision_ids": decision_ids,
+            "evidence_refs": evidence_refs,
+        }
+        summaries.append(summary)
+        asset_rows.append(
+            {
+                "author_asset_id": author_asset_id,
+                "platform": platform,
+                "platform_author_id": author_id,
+                "author_display_name": display_name,
+                "author_profile_url": None,
+                "asset_status": "active",
+                "source_type": source_type,
+                "validation_status": "rule_validated",
+                "eligible_as_source": 1,
+                "elderly_ratio": None,
+                "elderly_tgi": None,
+                "content_tags": tags,
+                "source_run_id": run_id,
+                "source_policy_run_id": policy_run_id,
+                "profile_snapshot": profile_snapshot,
+                "evidence_refs": evidence_refs,
+                "raw_payload": {
+                    "final_output_summary": summary,
+                    "profile_snapshot": profile_snapshot,
+                },
+            }
+        )
+        role_rows.extend(
+            {
+                "author_asset_id": author_asset_id,
+                "role": role,
+                "role_status": "active",
+                "role_reason_code": "p7_author_asset_eligible",
+                "assigned_by": "system",
+                "source_run_id": run_id,
+                "raw_payload": {"evidence_refs": evidence_refs},
+            }
+            for role in summary["roles"]
+        )
+    return summaries, asset_rows, role_rows
+
+
+def _author_asset_eligible(
+    sample_count: int,
+    qualified_count: int,
+    qualified_ratio: float,
+    age_level: str,
+) -> bool:
+    return (
+        sample_count >= 9
+        and qualified_count >= 3
+        and qualified_ratio >= 1 / 3
+        and age_level in {"medium", "strong"}
+    )
+
+
+def _best_age_level(decisions: list[dict[str, Any]]) -> str:
+    priority = {"strong": 3, "medium": 2, "weak": 1, "missing": 0}
+    levels = [decision.get("age_50_plus_level", "missing") for decision in decisions]
+    return max(levels or ["missing"], key=lambda level: priority.get(level, 0))
+
+
 def _paths_by_content_id(source_path_records: list[dict[str, Any]]) -> dict[str, list[str]]:
     pattern_path_records = [
         path for path in source_path_records if path["source_path_type"] == "pattern_to_search_query"
@@ -148,3 +427,8 @@ def _paths_by_content_id(source_path_records: list[dict[str, Any]]) -> dict[str,
         elif path["source_path_type"] == "decision_to_asset":
             result.setdefault(path["to_node_id"], []).append(path["source_path_record_id"])
     return result
+
+
+def _stable_id(prefix: str, *parts: Any) -> str:
+    raw = ":".join(str(part) for part in parts)
+    return f"{prefix}_{hashlib.sha1(raw.encode('utf-8')).hexdigest()[:16]}"

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

@@ -114,9 +114,84 @@ def validate_run(run_id: str, runtime: RuntimeFileStore) -> dict[str, Any]:
     _check_source_evidence(data, findings)
     _check_source_paths(data, findings)
     _check_summary(data, findings)
+    _check_completeness(data, findings)
     return _result(run_id, findings)
 
 
+def compute_final_output_completeness(
+    final_output: dict[str, Any],
+    decisions: list[dict[str, Any]],
+    source_path_records: list[dict[str, Any]],
+) -> dict[str, Any]:
+    findings: list[str] = []
+    decision_ids = {decision.get("decision_id") for decision in decisions}
+    path_ids = {path.get("source_path_record_id") for path in source_path_records}
+    decision_asset_paths = {
+        (path.get("decision_id"), path.get("to_node_id")): path
+        for path in source_path_records
+        if path.get("source_path_type") == "decision_to_asset"
+    }
+
+    for asset in final_output.get("content_assets", []):
+        decision_id = asset.get("decision_id")
+        content_id = asset.get("platform_content_id")
+        asset_path_ids = set(asset.get("source_path_record_ids", []))
+        if decision_id not in decision_ids:
+            findings.append(f"content_asset missing decision: {content_id}")
+        missing_paths = sorted(asset_path_ids - path_ids)
+        if missing_paths:
+            findings.append(f"content_asset missing paths: {content_id}")
+        decision_asset = decision_asset_paths.get((decision_id, content_id))
+        if not decision_asset:
+            findings.append(f"content_asset missing decision_to_asset: {content_id}")
+        elif decision_asset.get("source_path_record_id") not in asset_path_ids:
+            findings.append(f"content_asset omits decision_to_asset: {content_id}")
+
+    for record in final_output.get("review_records", []):
+        if record.get("decision_id") not in decision_ids:
+            findings.append(f"review_record missing decision: {record.get('platform_content_id')}")
+        if set(record.get("source_path_record_ids", [])) - path_ids:
+            findings.append(f"review_record missing paths: {record.get('platform_content_id')}")
+
+    for record in final_output.get("reject_records", []):
+        if record.get("decision_id") not in decision_ids:
+            findings.append(f"reject_record missing decision: {record.get('decision_target_id')}")
+
+    final_decision_ids = {
+        record.get("decision_id") for record in final_output.get("decision_records", [])
+    }
+    if decision_ids - final_decision_ids:
+        findings.append("decision_records incomplete")
+
+    for author_asset in final_output.get("author_assets", []):
+        if set(author_asset.get("decision_ids", [])) - decision_ids:
+            findings.append(f"author_asset missing decisions: {author_asset.get('author_asset_id')}")
+        if set(author_asset.get("source_path_record_ids", [])) - path_ids:
+            findings.append(f"author_asset missing paths: {author_asset.get('author_asset_id')}")
+        evidence_refs = author_asset.get("evidence_refs") or {}
+        if evidence_refs.get("decision_ids") != author_asset.get("decision_ids"):
+            findings.append(f"author_asset evidence incomplete: {author_asset.get('author_asset_id')}")
+
+    required_sections = [
+        "content_assets",
+        "author_assets",
+        "review_records",
+        "decision_records",
+        "search_clues",
+        "reject_records",
+        "summary",
+    ]
+    missing_sections = [section for section in required_sections if section not in final_output]
+    findings.extend(f"final_output missing section: {section}" for section in missing_sections)
+    complete = not findings
+    return {
+        "validation_status": "pass" if complete else "fail",
+        "run_path_complete": complete,
+        "trace_complete": complete,
+        "findings_summary": findings,
+    }
+
+
 def _load_files(
     run_id: str,
     runtime: RuntimeFileStore,
@@ -365,13 +440,49 @@ def _check_references(data: dict[str, Any], findings: list[dict[str, Any]]) -> N
         for path_id in asset.get("source_path_record_ids", []):
             if path_id not in path_ids:
                 _fail(findings, "missing_path_ref", f"asset has unknown path_id: {path_id}")
-    for section in ["reject_records", "decision_records", "author_assets"]:
+    for record in final_output.get("review_records", []):
+        if record.get("decision_id") not in decision_ids:
+            _fail(
+                findings,
+                "missing_decision_ref",
+                f"review_records has unknown decision_id: {record.get('decision_id')}",
+            )
+        for path_id in record.get("source_path_record_ids", []):
+            if path_id not in path_ids:
+                _fail(
+                    findings,
+                    "missing_path_ref",
+                    f"review_records has unknown path_id: {path_id}",
+                )
+    for section in ["reject_records", "decision_records"]:
         for row in final_output.get(section, []):
             if row.get("decision_id") not in decision_ids:
                 _fail(
                     findings,
                     "missing_decision_ref",
                     f"{section} has unknown decision_id: {row.get('decision_id')}",
+                )
+    for author_asset in final_output.get("author_assets", []):
+        for decision_id in author_asset.get("decision_ids", []):
+            if decision_id not in decision_ids:
+                _fail(
+                    findings,
+                    "missing_decision_ref",
+                    f"author_assets has unknown decision_id: {decision_id}",
+                )
+        for path_id in author_asset.get("source_path_record_ids", []):
+            if path_id not in path_ids:
+                _fail(
+                    findings,
+                    "missing_path_ref",
+                    f"author_assets has unknown path_id: {path_id}",
+                )
+        evidence_refs = author_asset.get("evidence_refs") or {}
+        if evidence_refs.get("decision_ids") != author_asset.get("decision_ids"):
+            _fail(
+                findings,
+                "author_asset_evidence_incomplete",
+                f"author asset evidence refs do not match decisions: {author_asset.get('author_asset_id')}",
             )
 
 
@@ -458,6 +569,13 @@ def _check_source_evidence(data: dict[str, Any], findings: list[dict[str, Any]])
             evidence_pack,
             f"reject {record.get('decision_target_id')}",
         )
+    for record in final_output.get("review_records", []):
+        _check_one_source_evidence(
+            findings,
+            record.get("source_evidence") or {},
+            evidence_pack,
+            f"review {record.get('platform_content_id')}",
+        )
     for record in final_output.get("decision_records", []):
         _check_one_source_evidence(
             findings,
@@ -546,6 +664,11 @@ def _check_source_paths(data: dict[str, Any], findings: list[dict[str, Any]]) ->
         for path in paths
         if path.get("source_path_type") == "search_query_to_content"
     }
+    decision_asset_paths = {
+        (path.get("decision_id"), path.get("to_node_id")): path
+        for path in paths
+        if path.get("source_path_type") == "decision_to_asset"
+    }
 
     for decision in data.get("rule_decisions.jsonl", []):
         _check_content_source_path(
@@ -585,6 +708,59 @@ def _check_source_paths(data: dict[str, Any], findings: list[dict[str, Any]]) ->
         for path_id in path_ids:
             if path_id not in path_by_id:
                 _fail(findings, "source_path_broken", f"asset path missing: {path_id}")
+        decision_asset = decision_asset_paths.get(
+            (asset.get("decision_id"), platform_content_id)
+        )
+        if not decision_asset:
+            _fail(
+                findings,
+                "decision_to_asset_missing",
+                f"asset lacks decision_to_asset path: {platform_content_id}",
+            )
+            continue
+        if decision_asset.get("source_path_record_id") not in path_ids:
+            _fail(
+                findings,
+                "decision_to_asset_missing",
+                f"asset source paths omit decision_to_asset: {platform_content_id}",
+            )
+        if decision_asset.get("from_node_type") != "RuleDecision":
+            _fail(
+                findings,
+                "decision_to_asset_broken",
+                f"asset decision_to_asset starts from wrong node: {platform_content_id}",
+            )
+        if decision_asset.get("from_node_id") != asset.get("decision_id"):
+            _fail(
+                findings,
+                "decision_to_asset_broken",
+                f"asset decision_to_asset has wrong decision id: {platform_content_id}",
+            )
+        if decision_asset.get("to_node_type") != "ContentAsset":
+            _fail(
+                findings,
+                "decision_to_asset_broken",
+                f"asset decision_to_asset ends at wrong node: {platform_content_id}",
+            )
+
+    for record in data.get("final_output.json", {}).get("review_records", []):
+        platform_content_id = record.get("platform_content_id")
+        path_ids = set(record.get("source_path_record_ids", []))
+        query_content = query_content_paths.get(platform_content_id)
+        if not query_content or query_content.get("source_path_record_id") not in path_ids:
+            _fail(
+                findings,
+                "source_path_broken",
+                f"review record lacks search_query_to_content path: {platform_content_id}",
+            )
+            continue
+        pattern_query = pattern_query_paths.get(query_content.get("from_node_id"))
+        if not pattern_query or pattern_query.get("source_path_record_id") not in path_ids:
+            _fail(
+                findings,
+                "source_path_broken",
+                f"review record lacks pattern_to_search_query path: {platform_content_id}",
+            )
 
 
 def _check_content_source_path(
@@ -651,6 +827,29 @@ def _check_summary(data: dict[str, Any], findings: list[dict[str, Any]]) -> None
             )
 
 
+def _check_completeness(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
+    final_output = data.get("final_output.json", {})
+    expected = compute_final_output_completeness(
+        final_output,
+        data.get("rule_decisions.jsonl", []),
+        data.get("source_path_records.jsonl", []),
+    )
+    summary = final_output.get("summary", {})
+    for field in ["run_path_complete", "trace_complete"]:
+        if summary.get(field) != expected[field]:
+            _fail(
+                findings,
+                "completeness_mismatch",
+                f"summary.{field} expected {expected[field]}, got {summary.get(field)}",
+            )
+    if final_output.get("validation_status") != expected["validation_status"]:
+        _fail(
+            findings,
+            "completeness_mismatch",
+            f"validation_status expected {expected['validation_status']}, got {final_output.get('validation_status')}",
+        )
+
+
 def _result(run_id: str, findings: list[dict[str, Any]]) -> dict[str, Any]:
     return {
         "run_id": run_id,

+ 21 - 0
content_agent/integrations/composite_runtime.py

@@ -22,6 +22,10 @@ class CompositeRuntimeStore:
         self.primary.write_json(run_id, filename, data)
         return self.export.write_json(run_id, filename, data)
 
+    def update_json(self, run_id: str, filename: str, data: dict[str, Any]) -> Path:
+        self.primary.update_json(run_id, filename, data)
+        return self.export.update_json(run_id, filename, data)
+
     def append_jsonl(self, run_id: str, filename: str, rows: list[dict[str, Any]]) -> Path:
         self.primary.append_jsonl(run_id, filename, rows)
         return self.export.append_jsonl(run_id, filename, rows)
@@ -55,3 +59,20 @@ class CompositeRuntimeStore:
     ) -> None:
         self.primary.append_run_event_records(run_id, policy_run_id, rows)
         self.export.append_run_event_records(run_id, policy_run_id, rows)
+
+    def write_publish_jobs(
+        self,
+        run_id: str,
+        policy_run_id: str,
+        rows: list[dict[str, Any]],
+    ) -> None:
+        self.primary.write_publish_jobs(run_id, policy_run_id, rows)
+        self.export.write_publish_jobs(run_id, policy_run_id, rows)
+
+    def write_author_assets(self, rows: list[dict[str, Any]]) -> None:
+        self.primary.write_author_assets(rows)
+        self.export.write_author_assets(rows)
+
+    def write_author_asset_roles(self, rows: list[dict[str, Any]]) -> None:
+        self.primary.write_author_asset_roles(rows)
+        self.export.write_author_asset_roles(rows)

+ 111 - 0
content_agent/integrations/database_runtime.py

@@ -81,6 +81,14 @@ JSON_COLUMNS_BY_TABLE = {
     "content_agent_search_clues": {"raw_payload"},
     "content_agent_run_events": {"raw_payload"},
     "content_agent_final_outputs": {"summary", "final_output"},
+    "content_agent_publish_jobs": {"request_payload", "response_payload"},
+    "content_agent_author_assets": {
+        "content_tags",
+        "profile_snapshot",
+        "evidence_refs",
+        "raw_payload",
+    },
+    "content_agent_author_asset_roles": {"raw_payload"},
     "content_agent_strategy_reviews": {
         "summary",
         "effective_search_queries",
@@ -193,6 +201,20 @@ class DatabaseRuntimeStore:
         self._insert(table, record)
         return self.run_dir(run_id) / filename
 
+    def update_json(self, run_id: str, filename: str, data: dict[str, Any]) -> Path:
+        table, record = _record_for_json(filename, data)
+        if record["run_id"] != run_id:
+            raise ValueError(f"{filename} run_id does not match runtime run_id")
+        if filename == "final_output.json":
+            self._upsert(
+                table,
+                record,
+                key_columns=("run_id", "policy_run_id", "output_version"),
+            )
+            return self.run_dir(run_id) / filename
+        self._insert(table, record)
+        return self.run_dir(run_id) / filename
+
     def append_jsonl(self, run_id: str, filename: str, rows: list[dict[str, Any]]) -> Path:
         table = _table_for_runtime_file(filename)
         for row in rows:
@@ -277,6 +299,40 @@ class DatabaseRuntimeStore:
         ]
         self.append_jsonl(run_id, "run_events.jsonl", prepared_rows)
 
+    def write_publish_jobs(
+        self,
+        run_id: str,
+        policy_run_id: str,
+        rows: list[dict[str, Any]],
+    ) -> None:
+        for row in rows:
+            record = {
+                **row,
+                "run_id": run_id,
+                "policy_run_id": row.get("policy_run_id", policy_run_id),
+            }
+            self._upsert(
+                "content_agent_publish_jobs",
+                _with_db_schema(record),
+                key_columns=("run_id", "policy_run_id", "publish_job_id"),
+            )
+
+    def write_author_assets(self, rows: list[dict[str, Any]]) -> None:
+        for row in rows:
+            self._upsert(
+                "content_agent_author_assets",
+                _with_db_schema(row),
+                key_columns=("author_asset_id",),
+            )
+
+    def write_author_asset_roles(self, rows: list[dict[str, Any]]) -> None:
+        for row in rows:
+            self._upsert(
+                "content_agent_author_asset_roles",
+                _with_db_schema(row),
+                key_columns=("author_asset_id", "role"),
+            )
+
     def _insert(self, table: str, record: dict[str, Any]) -> None:
         sanitized = _sanitize_record(table, record)
         columns = list(sanitized)
@@ -670,6 +726,61 @@ TABLE_COLUMNS = {
         "created_at",
         "updated_at",
     },
+    "content_agent_publish_jobs": {
+        "schema_version",
+        "run_id",
+        "policy_run_id",
+        "publish_job_id",
+        "platform_content_id",
+        "job_status",
+        "trigger_mode",
+        "crawler_plan_id",
+        "produce_plan_id",
+        "publish_plan_id",
+        "request_payload",
+        "response_payload",
+        "error_code",
+        "error_message",
+        "created_at",
+        "updated_at",
+    },
+    "content_agent_author_assets": {
+        "schema_version",
+        "author_asset_id",
+        "platform",
+        "platform_author_id",
+        "author_display_name",
+        "author_profile_url",
+        "asset_status",
+        "source_type",
+        "validation_status",
+        "eligible_as_source",
+        "elderly_ratio",
+        "elderly_tgi",
+        "content_tags",
+        "source_run_id",
+        "source_policy_run_id",
+        "last_profile_fetch_at",
+        "last_works_fetch_at",
+        "last_validated_at",
+        "profile_snapshot",
+        "evidence_refs",
+        "raw_payload",
+        "created_at",
+        "updated_at",
+    },
+    "content_agent_author_asset_roles": {
+        "schema_version",
+        "author_asset_id",
+        "role",
+        "role_status",
+        "role_reason_code",
+        "assigned_by",
+        "source_run_id",
+        "raw_payload",
+        "created_at",
+        "updated_at",
+    },
     "content_agent_strategy_reviews": {
         "schema_version",
         "run_id",

+ 5 - 0
content_agent/integrations/policy_json.py

@@ -6,6 +6,7 @@ from pathlib import Path
 from typing import Any
 
 from content_agent.constants import DEFAULT_POLICY_BUNDLE_ID, EVIDENCE_BUNDLE_SCHEMA_VERSION, RUNTIME_SCHEMA_VERSION
+from content_agent.integrations.walk_strategy_json import WalkStrategyStore
 
 
 class JsonPolicyBundleStore:
@@ -26,6 +27,7 @@ class JsonPolicyBundleStore:
 
         dispatch = _select_dispatch(rule_package, actual_strategy_version)
         rule_pack = _find_rule_pack_by_dispatch(rule_package, dispatch)
+        walk_strategy = WalkStrategyStore(self.root_dir).load_walk_strategy()
         bundle = {
             "policy_bundle_id": DEFAULT_POLICY_BUNDLE_ID,
             "strategy_version": actual_strategy_version,
@@ -50,6 +52,9 @@ class JsonPolicyBundleStore:
             "strategy_id": (rule_package.get("strategy_binding") or {}).get(
                 "strategy_id", "douyin_content_find_v1"
             ),
+            "walk_strategy_id": walk_strategy.get("strategy_id"),
+            "walk_strategy_version": walk_strategy.get("walk_strategy_version"),
+            "walk_strategy_source_ref": walk_strategy.get("walk_strategy_source_ref"),
             "strategy_source_ref": {
                 "file": str(rule_pack_path),
                 "updated_at": rule_package.get("updated_at"),

+ 17 - 0
content_agent/integrations/runtime_files.py

@@ -43,6 +43,9 @@ class LocalRuntimeFileStore:
         path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
         return path
 
+    def update_json(self, run_id: str, filename: str, data: dict[str, Any]) -> Path:
+        return self.write_json(run_id, filename, data)
+
     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)
@@ -101,6 +104,20 @@ class LocalRuntimeFileStore:
     ) -> None:
         return None
 
+    def write_publish_jobs(
+        self,
+        run_id: str,
+        policy_run_id: str,
+        rows: list[dict[str, Any]],
+    ) -> None:
+        return None
+
+    def write_author_assets(self, rows: list[dict[str, Any]]) -> None:
+        return None
+
+    def write_author_asset_roles(self, rows: list[dict[str, Any]]) -> None:
+        return None
+
 
 def _replace_pattern_recall_rows(
     existing_rows: list[dict[str, Any]],

+ 9 - 0
content_agent/interfaces.py

@@ -9,6 +9,7 @@ class RuntimeStore(Protocol):
     def prepare_run(self, run_id: str) -> Path: ...
     def run_dir(self, run_id: str) -> Path: ...
     def write_json(self, run_id: str, filename: str, data: dict[str, Any]) -> Path: ...
+    def update_json(self, run_id: str, filename: str, data: dict[str, Any]) -> Path: ...
     def append_jsonl(self, run_id: str, filename: str, rows: list[dict[str, Any]]) -> Path: ...
     def read_json(self, run_id: str, filename: str) -> dict[str, Any]: ...
     def read_jsonl(self, run_id: str, filename: str) -> list[dict[str, Any]]: ...
@@ -22,6 +23,14 @@ class RuntimeStore(Protocol):
         policy_run_id: str,
         rows: list[dict[str, Any]],
     ) -> None: ...
+    def write_publish_jobs(
+        self,
+        run_id: str,
+        policy_run_id: str,
+        rows: list[dict[str, Any]],
+    ) -> None: ...
+    def write_author_assets(self, rows: list[dict[str, Any]]) -> None: ...
+    def write_author_asset_roles(self, rows: list[dict[str, Any]]) -> None: ...
 
 
 RuntimeFileStore = RuntimeStore

+ 16 - 1
content_agent/run_service.py

@@ -268,6 +268,7 @@ class RunService:
         state["status"] = final_status
         self.runtime.record_policy_run(_policy_run_record_from_state(state))
         validation = self.validate_run(state["run_id"])
+        self._update_final_output_validation(state["run_id"], validation)
         self.runtime.update_run_record(
             state["run_id"],
             {
@@ -294,6 +295,20 @@ class RunService:
             },
         )
 
+    def _update_final_output_validation(self, run_id: str, validation: dict[str, Any]) -> None:
+        final_output = self.runtime.read_json(run_id, "final_output.json")
+        validation_status = validation["status"]
+        findings = validation.get("findings", [])
+        final_output["validation_status"] = validation_status
+        summary = final_output.setdefault("summary", {})
+        summary["run_path_complete"] = validation_status == "pass"
+        summary["trace_complete"] = validation_status == "pass"
+        summary["validation_findings_summary"] = [
+            finding.get("message", str(finding)) if isinstance(finding, dict) else str(finding)
+            for finding in findings
+        ]
+        self.runtime.update_json(run_id, "final_output.json", final_output)
+
     def _record_failure_metadata(
         self,
         state: RunState,
@@ -496,7 +511,7 @@ def _policy_run_record_from_state(state: RunState) -> dict[str, Any]:
         "strategy_id": policy_bundle.get("strategy_id"),
         "strategy_version": state.get("strategy_version"),
         "rule_pack_version": policy_bundle.get("rule_pack_version"),
-        "walk_strategy_version": policy_bundle.get("strategy_version"),
+        "walk_strategy_version": policy_bundle.get("walk_strategy_version"),
         "policy_bundle_hash": policy_bundle.get("policy_bundle_hash"),
         "strategy_source_ref": policy_bundle.get("strategy_source_ref"),
         "rule_pack_source_ref": policy_bundle.get("rule_pack_source_ref"),

+ 1 - 4
product_documents/README.md

@@ -11,11 +11,9 @@
 | 3 | [prd/V1落地版本细化版.md](prd/V1落地版本细化版.md) | V1 的字段、状态、运行记录和验收口径 |
 | 4 | [抖音游走策略/runtime_v1_records_schema.md](抖音游走策略/runtime_v1_records_schema.md) | V1 DB 写入字段准则和本地兼容导出文件怎么保存 |
 | 5 | [规则包/抖音规则包V1.md](规则包/抖音规则包V1.md) | 抖音规则如何判断入池、待复看、待观察和淘汰 |
-| 6 | [抖音游走策略/douyin_walk_strategy.v1.json](抖音游走策略/douyin_walk_strategy.v1.json) | P6 抖音 bounded walk 的运行时策略 |
+| 6 | [抖音游走策略/douyin_walk_strategy.v1.json](抖音游走策略/douyin_walk_strategy.v1.json) | P6/P7 抖音 bounded walk 的当前运行时策略 |
 | 7 | [规则包/douyin_rule_packs.v1.json](规则包/douyin_rule_packs.v1.json) | 规则包的机器可读配置 |
 
-旧 `抖音游走策略/douyin_available_walk_strategy.v1.json` 只保留为 historical reference,不作为 P6 runtime source。
-
 ## 2. 当前主流程
 
 ```text
@@ -61,7 +59,6 @@ V1 的生产事实层是云上 MySQL 的 `content_agent_*` 表;本地 JSON / J
 | `search_query_effect_status` | 搜索词效果状态 |
 | `ADD_TO_CONTENT_POOL` | 入池 |
 | `KEEP_CONTENT_FOR_REVIEW` | 待复看 |
-| `HOLD_CONTENT_PENDING` | historical / deprecated old action;不进入新 V1 runtime |
 | `REJECT_CONTENT` | 淘汰 |
 | `source_path_records.jsonl` | 来源记录文件 |
 | `rule_decisions.jsonl` | 规则判断结果文件 |

+ 11 - 8
product_documents/prd/V1落地版本.md

@@ -4,7 +4,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`(待复看,映射 pending)、`REJECT_CONTENT`(淘汰);`HOLD_CONTENT_PENDING` 是 historical / deprecated old action,不进入新 V1 runtime。`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`(搜索词效果状态)、`ADD_TO_CONTENT_POOL`(入池)、`KEEP_CONTENT_FOR_REVIEW`(待复看,映射 pending)、`REJECT_CONTENT`(淘汰)。`source_path_records.jsonl`(来源记录文件)、`rule_decisions.jsonl`(规则判断结果文件)。
 
 
 ## 0. 结论和主干
@@ -38,7 +38,7 @@ V1 的产品主干:
 
 阶段边界:P1 只验收数据源与 Pattern seed pack;query 数量、query 模板、tag 扩散、作者扩展、真实抖音接入和 Pattern 回扣落表属于后续阶段,不代表 P1 默认启用。
 
-可工程化的边规则配置保留在:[douyin_available_walk_strategy.v1.json](../抖音游走策略/douyin_available_walk_strategy.v1.json)。
+可工程化的边规则配置保留在:[douyin_walk_strategy.v1.json](../抖音游走策略/douyin_walk_strategy.v1.json)。
 
 规则包配置保留在:[douyin_rule_packs.v1.json](../规则包/douyin_rule_packs.v1.json),人读版见:[抖音规则包V1.md](../规则包/抖音规则包V1.md)。
 
@@ -93,8 +93,8 @@ V1 目标:用一个票圈视频 Pattern 跑通从数据源到抖音内容入
 | Platform | 抖音关键词搜索 `/crawler/dou_yin/keyword` | 已验证 |
 | 判断 | 热点宝内容画像、账号画像 | 已验证 |
 | 游走 | 抖音作者作品 `/crawler/dou_yin/blogger` | 已验证 |
-| 内容资产 | `demand_find_content_result` | 已验证,只放最终结果 |
-| 作者资产 | `demand_find_author` | 已验证 |
+| 内容资产 | `content_agent_final_outputs` / `content_agent_publish_jobs` | P7 写最终快照和发布任务记录,不自动调用发布接口 |
+| 作者资产 | `content_agent_author_assets` / `content_agent_author_asset_roles` | P7 新建主事实表;旧 `demand_find_author` 只作为历史迁移源 |
 
 V1 使用云上 MySQL 的 `content_agent_*` 表保存生产事实;本地 JSON / JSONL 只作为开发调试、兼容验收和回放的导出。不接 show 可视化后端。
 
@@ -304,16 +304,18 @@ tag 扩散规则:
 最终内容写:
 
 ```text
-demand_find_content_result
+content_agent_final_outputs
+content_agent_publish_jobs
 ```
 
 优质作者写:
 
 ```text
-demand_find_author
+content_agent_author_assets
+content_agent_author_asset_roles
 ```
 
-全量发现内容、淘汰原因、来源关系、搜索线索和最终输出进入 `content_agent_*` 表;本地 JSON / JSONL 保留为兼容导出。V1 不要求和可视化后端联调。
+旧 `demand_find_content_result` 和 `demand_find_author` 只作为历史兼容或迁移来源;新版主事实必须先进入 `content_agent_*` 表。本地 JSON / JSONL 保留为兼容导出。V1 不要求和可视化后端联调。
 
 如果 `demand_find_content_result` 暂时没有 `source_evidence` 字段,必须写旁路来源记录文件,不能丢。
 
@@ -345,11 +347,12 @@ V1 策略学习不训练模型,只输出下一轮小改动:
 - 能产出可追溯 query。
 - 能用抖音关键词搜索拿到发现视频。
 - 能保存全量发现内容到 DB,并在需要时导出本地 JSON / JSONL 兼容文件。
-- 能对每条发现内容给出入池、待复看(pending)或淘汰;`HOLD_CONTENT_PENDING` 只作为 historical / deprecated old action 兼容解释
+- 能对每条发现内容给出入池、待复看(pending)或淘汰。
 - 能对可扩展作者拉一次作者作品。
 - 能把作者作品重新判断。
 - 能写出最终 `final_output.json`。
 - 能把最终入选内容写入 `content_agent_final_outputs` 快照,并生成后续手动发布所需的发布任务记录;V1 不自动调用发布接口。
+- 能把满足作者沉淀规则的作者写入 `content_agent_author_assets`,并用 `content_agent_author_asset_roles` 表示多角色。
 - 能从 run_context 里看清:这个内容来自哪个 Pattern、哪个 query、哪个作者、为什么入池。
 
 ## 9. V1 不解决的问题

+ 53 - 5
product_documents/prd/V1落地版本细化版.md

@@ -4,7 +4,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`(待复看,映射 pending)、`REJECT_CONTENT`(淘汰);`HOLD_CONTENT_PENDING` 是 historical / deprecated old action,不进入新 V1 runtime。`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`(搜索词效果状态)、`ADD_TO_CONTENT_POOL`(入池)、`KEEP_CONTENT_FOR_REVIEW`(待复看,映射 pending)、`REJECT_CONTENT`(淘汰)。`source_path_records.jsonl`(来源记录文件)、`rule_decisions.jsonl`(规则判断结果文件)。
 
 
 ## 0. 结论和主干
@@ -36,7 +36,7 @@ V1 默认启用的游走边:
 | `AuthorWorksPage -> Video` | 作者作品重新进入视频判断 |
 | `Video -> Hashtag -> Query` | 强相关 tag 生成新 query |
 | `Video / Author -> 画像与互动信号` | 进入判断信号汇总 |
-| `判断信号汇总 -> 规则包决策` | 输出入池、待复看(pending)、停止或淘汰;`HOLD_CONTENT_PENDING` 是 historical / deprecated old action |
+| `判断信号汇总 -> 规则包决策` | 输出入池、待复看(pending)、停止或淘汰 |
 
 P6 是行动 / 游走系统:原 P9 作者一跳、原 P10 tag 扩散已归并到 P6,不再作为独立阶段。P5 仍然是规则判断系统;P6 可以按 edge 人工绑定 P5 规则包,但不复制 P5 的 hard gate、scorecard、threshold。
 
@@ -56,7 +56,6 @@ V1 暂不默认启用:
 边规则和规则包不塞进本文正文,分别放在:
 
 - [douyin_walk_strategy.v1.json](../抖音游走策略/douyin_walk_strategy.v1.json)
-- [douyin_available_walk_strategy.v1.json](../抖音游走策略/douyin_available_walk_strategy.v1.json) 作为历史输入参考
 - [douyin_rule_packs.v1.json](../规则包/douyin_rule_packs.v1.json)
 - [抖音规则包V1.md](../规则包/抖音规则包V1.md)
 
@@ -658,6 +657,53 @@ tag 扩散重新生成的 query 回到抖音关键词搜索,并重新走视频
 
 tag 和 query 暂时不算正式资产,只算运行期搜索线索观测,写入 `search_clues.jsonl`。
 
+### 6.2.1 作者资产 DB 口径
+
+P7 正式启用作者资产,但不沿用旧 `demand_find_author` 作为新版主事实表。旧作者池可以作为 `legacy_import` 来源迁移;迁移后仍需重新拉热点宝账号画像和作者作品,再决定是否成为可用数据源。
+
+作者资产采用两张 `content_agent_*` 表:
+
+| 表 | 业务含义 |
+|---|---|
+| `content_agent_author_assets` | 作者资产主表,一位平台作者一条当前资产记录,记录作者是谁、当前是否可用、最初来源、验证进度和画像摘要 |
+| `content_agent_author_asset_roles` | 作者资产角色表,一个作者可以同时有多个角色,例如人工精选、旧库迁移、已验证作者资产、可作为后续数据源 |
+
+`content_agent_author_assets` 最小字段口径:
+
+| 字段 | 含义 |
+|---|---|
+| `author_asset_id` | 新版作者资产 ID,系统内部稳定 ID |
+| `platform` | 平台,V1 为 `douyin` |
+| `platform_author_id` | 平台作者 ID;热点宝账号画像接口使用该值作为 `account_id` |
+| `author_display_name` | 作者展示名 |
+| `author_profile_url` | 作者主页链接,可为空 |
+| `asset_status` | 资产可用状态,只表示能不能用:`candidate` / `active` / `paused` / `rejected` / `archived` |
+| `source_type` | 最初来源:`new_discovery` / `legacy_import` / `manual_added` / `oss_import` |
+| `validation_status` | 验证进度:`unverified` / `profile_fetched` / `works_fetched` / `works_validated` / `rule_validated` / `failed` |
+| `eligible_as_source` | 是否可作为后续数据源 |
+| `elderly_ratio` | 热点宝账号画像 50+ 占比,存小数 |
+| `elderly_tgi` | 热点宝账号画像 50+ TGI |
+| `content_tags` | 作者内容标签 |
+| `source_run_id` / `source_policy_run_id` | 如果来自某次 CFA run,记录来源运行 |
+| `last_profile_fetch_at` / `last_works_fetch_at` / `last_validated_at` | 最近画像、作品和规则验证时间 |
+| `profile_snapshot` | JSON,保存热点宝完整账号画像,如年龄、性别、省份 |
+| `evidence_refs` | JSON,保存 `source_path_record_ids`、`decision_id`、样例内容 ID 等证据引用 |
+| `raw_payload` | JSON,保留来源行、人工录入、OSS manifest 或接口扩展字段 |
+
+`content_agent_author_asset_roles` 最小字段口径:
+
+| 字段 | 含义 |
+|---|---|
+| `author_asset_id` | 关联作者资产主表 |
+| `role` | 作者角色:`author_asset` / `source_seed` / `manual_curated` / `legacy_imported` / `high_50plus_profile` |
+| `role_status` | 角色状态:`active` / `paused` / `removed` |
+| `role_reason_code` | 为什么获得该角色 |
+| `assigned_by` | 角色来源:`system` / `manual` / `migration` |
+| `source_run_id` | 如果角色来自某次 run,记录来源 |
+| `raw_payload` | JSON 扩展 |
+
+`asset_status` 管资产可用性;`role` 管作者身份。一个作者同一时间只有一个主要可用状态,但可以同时拥有多个角色。比如旧库迁移作者初始可为 `asset_status=candidate`、`role=legacy_imported`;重新拉画像和作品并验证通过后,可变为 `asset_status=active`,并增加 `author_asset`、`source_seed`、`high_50plus_profile` 等角色。
+
 ### 6.3 入库口径
 
 | 数据 | 处理 |
@@ -671,7 +717,7 @@ tag 和 query 暂时不算正式资产,只算运行期搜索线索观测,写
 | 策略复盘 | 写 `content_agent_strategy_reviews`,不自动改策略 |
 | 策略执行版本 | 写 `content_agent_policy_runs` 的最小版本记录 |
 | 发布任务 | 写 `content_agent_publish_jobs`,V1 不自动调用发布接口 |
-| 作者资产 | P6 作者边实现后再启用作者资产沉淀 |
+| 作者资产 | 写 `content_agent_author_assets` 和 `content_agent_author_asset_roles`;`final_output.author_assets` 只做最终视图摘要 |
 
 如果未来要同步到旧 `demand_find_content_result` 或 `demand_find_author`,必须从 `content_agent_*` 表派生,不能绕过来源路径和规则判断记录。
 
@@ -680,6 +726,8 @@ tag 和 query 暂时不算正式资产,只算运行期搜索线索观测,写
 - `final_output.json`
 - `content_agent_final_outputs`
 - `content_agent_publish_jobs`
+- `content_agent_author_assets`
+- `content_agent_author_asset_roles`
 
 ## 7. 策略学习
 
@@ -723,7 +771,7 @@ query / tag / 作者扩展统一使用效果状态:
 | `failed` | 无有效候选、平台失败或普通失败 | 降权或删除 |
 | `rule_blocked` | 主要被 hard gate 阻断 | 记录规则阻断,优先复盘规则或数据 |
 
-`weak_effective` / `blocked` 是 historical / deprecated old status,不进入新 V1 runtime。
+
 
 tag 扩散后的后续游走要同时记录两类来源:
 

+ 12 - 13
product_documents/prd/产品方案总表.md

@@ -4,7 +4,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`(待复看,映射 pending)、`REJECT_CONTENT`(淘汰);`HOLD_CONTENT_PENDING` 是 historical / deprecated old action,不进入新 V1 runtime。`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`(搜索词效果状态)、`ADD_TO_CONTENT_POOL`(入池)、`KEEP_CONTENT_FOR_REVIEW`(待复看,映射 pending)、`REJECT_CONTENT`(淘汰)。`source_path_records.jsonl`(来源记录文件)、`rule_decisions.jsonl`(规则判断结果文件)。
 
 
 ## 0. 一句话定义
@@ -135,7 +135,7 @@ DB 是生产事实层,本地 JSON / JSONL 是同结构兼容导出。后续需
 | Pattern | 稳定特征组合,适合精搜索 | 已验证 / 部分待接入 | V1 使用 |
 | Case | 历史优质素材和成功表达 | 已验证 | 完整 MVP 使用 |
 | 历史优质搜索记录 | 历史有效 query 和搜索路径 | 部分缺口 | 完整 MVP 使用 |
-| 历史沉淀账号 | 可复用作者资产 | 已验证 | 无需求源,先判断作者再游走 |
+| 历史沉淀账号 | 旧作者资产来源 | 已验证 | 作为 `legacy_import` 迁移源,重新拉画像和作品后进入新版作者资产表 |
 | 热点 | 时效修饰和轻量探索入口 | 已验证 | 无需求源,先预筛再游走到内容 |
 | 养号 | 推荐流采集入口 | 产品意图 | 无接口信息,暂不定顺序 |
 
@@ -155,7 +155,7 @@ Case 是具体历史素材,回答“它怎么表达、为什么有效”。完
 
 ### 3.4 历史沉淀账号
 
-历史沉淀账号是作者资产。接口返回的是作者对象,主要字段包括作者名、作者链接、作者 ID、平台、内容标签、50+ 占比、TGI、是否优质、run_context
+历史沉淀账号是旧版作者池,不直接作为新版主事实。新版作者资产主事实写入 `content_agent_author_assets`,多身份写入 `content_agent_author_asset_roles`。旧 `demand_find_author` 可作为 `legacy_import` 来源,迁移后重新拉热点宝账号画像和作者作品,再决定是否成为可用数据源
 
 推荐顺序:
 
@@ -284,10 +284,10 @@ Platform 回答:在哪个平台执行,以及能拿到什么。
 
 | 资产 | 产品含义 | 当前状态 |
 |---|---|---|
-| 内容资产 | 入池视频、待复看视频 | 最终结果表已验证,淘汰记录和全量发现内容列表缺口 |
-| 作者资产 | 入池作者、观察作者、淘汰作者 | 作者表已验证,分层规则待补 |
-| 来源关系 | 数据源、seed、query、视频、作者之间的路径 | 缺口 |
-| 搜索线索 | 有效 query、失败 query、标签、话题、相关搜索 | 缺口 |
+| 内容资产 | 入池视频、待复看视频 | P7 已有 `final_output.json` / `content_agent_final_outputs`,正式内容、待复看和淘汰摘要可见;跨 run 长期内容资产视图待后续 |
+| 作者资产 | 入池作者、观察作者、淘汰作者、可作为后续数据源的作者 | P7 使用 `content_agent_author_assets` + `content_agent_author_asset_roles`;旧作者表只做迁移源 |
+| 来源关系 | 数据源、seed、query、视频、作者之间的路径 | P6/P7 已有 `source_path_records` 和 `decision_to_asset` 运行事实;资产化复用视图待后续 |
+| 搜索线索 | 有效 query、失败 query、标签、话题、相关搜索 | P5/P6 已有 `search_clues` 运行线索;`SearchClueAsset` promotion 推迟到 P8 后 |
 
 这里先定义产品含义,不展开 DB schema。
 
@@ -365,8 +365,8 @@ created_at
 | 判断规则包 | 让入池、淘汰、继续、停止都可解释 |
 | 游走策略运行记录 | 记录从哪里走到哪里,以及为什么走 |
 | 全量发现内容列表 | 不只保存最终入选,也保存被淘汰和观察的发现内容 |
-| 来源关系资产 | 让内容、作者、query、seed 可以追溯 |
-| 搜索线索资产 | 保存有效和失败的 query,供下一轮学习 |
+| 来源关系资产 | P6/P7 已有运行级来源路径;后续需要把跨 run 可复用关系资产化 |
+| 搜索线索资产 | P5/P6 已有运行级搜索线索;后续需要把可复用线索沉淀为资产 |
 | 策略学习 run_context | 复盘输入、query、规则、游走和表现 |
 | source evidence 承载 | 从结果内容反查到 Pattern、Case、分类树节点 |
 
@@ -383,10 +383,9 @@ created_at
 
 仍然缺口:
 
-- 全量发现内容列表。
-- 判断 / 淘汰日志表。
-- 来源关系资产表。
-- `source_evidence` 字段或旁路文件 / 来源记录文件。
+- 跨 run 长期内容资产视图。
+- 判断 / 淘汰的长期分析视图。
+- 来源关系的跨 run 资产表。
 - 搜索线索资产表。
 - show 前端真实后端 API。
 - TikHub key。

+ 0 - 602
product_documents/抖音游走策略/douyin_available_walk_strategy.v1.json

@@ -1,602 +0,0 @@
-{
-  "schema_version": "walk_strategy.v1",
-  "strategy_id": "douyin_available_walk_strategy_v1",
-  "strategy_name": "douyin available walk strategy",
-  "strategy_name_zh": "抖音可用游走策略 V1",
-  "description": "核心对象说明:EvidenceBundle 是判断证据包,RuleDecision 是规则判断结果,WalkAction 是下一步动作,platform_content_id 是平台内容 ID,抖音下等于抖音视频 ID,platform_author_id 是抖音作者 ID,source_evidence 是来源证据,run_context 是运行追踪记录。",
-  "strategy_aliases": [
-    "douyin available work strategy",
-    "douyin available walk strategy"
-  ],
-  "updated_at": "2026-06-04",
-  "scope": {
-    "source": "PatternSeed",
-    "platform": "douyin",
-    "default_version": "V1",
-    "default_goal": "从票圈视频 Pattern 出发,只走抖音渠道,跑通可追溯的内容发现闭环",
-    "p1_stage_boundary": "P1 只验收真实 demand_content 输入和 Pattern seed pack;不启用 tag query、query 数量策略、真实抖音接入或 Pattern 回扣落表。"
-  },
-  "document_links": {
-    "prd": "../prd/V1落地版本.md",
-    "detailed_prd": "../prd/V1落地版本细化版.md"
-  },
-  "principles": [
-    "next_cursor 是分页状态节点,不是视频、作者或 tag 这类业务节点。",
-    "ContentPortrait、AccountPortrait、互动表现是判断信号,不能单独决定入池或淘汰。",
-    "判断信号必须先汇总为 EvidenceBundle(判断证据包),再由规则包输出决策动作。",
-    "完整 V1 目标包含 Video -> Hashtag -> Query tag 扩散,但 P1 不启用 tag query;单次 run 最多允许 10 次 tag 扩散跳跃是后续阶段约束。",
-    "所有沉淀结果必须保留 source_evidence(来源证据)和 run_context(运行上下文)。"
-  ],
-  "nodes": [
-    {
-      "id": "PatternSeed",
-      "label": "Pattern 策略种子",
-      "type": "traversal",
-      "key_fields": [
-        "seed_terms",
-        "itemset_items",
-        "category_bindings",
-        "element_bindings"
-      ],
-      "can_expand": true
-    },
-    {
-      "id": "Query",
-      "label": "抖音 Query",
-      "type": "traversal",
-      "key_fields": [
-        "keyword"
-      ],
-      "can_expand": true
-    },
-    {
-      "id": "SearchPage",
-      "label": "搜索结果页",
-      "type": "traversal",
-      "key_fields": [
-        "platform_content_id",
-        "author.platform_author_id",
-        "has_more",
-        "next_cursor"
-      ],
-      "can_expand": true
-    },
-    {
-      "id": "SearchCursor",
-      "label": "搜索分页状态",
-      "type": "cursor",
-      "key_fields": [
-        "keyword",
-        "next_cursor"
-      ],
-      "can_expand": true
-    },
-    {
-      "id": "Content",
-      "label": "抖音视频节点",
-      "type": "traversal",
-      "key_fields": [
-        "platform_content_id",
-        "desc",
-        "author.platform_author_id",
-        "statistics.*",
-        "cha_list",
-        "text_extra"
-      ],
-      "can_expand": true
-    },
-    {
-      "id": "Author",
-      "label": "抖音作者节点",
-      "type": "traversal",
-      "key_fields": [
-        "author.platform_author_id",
-        "author.nickname"
-      ],
-      "can_expand": true
-    },
-    {
-      "id": "AuthorWorksPage",
-      "label": "作者作品页",
-      "type": "traversal",
-      "key_fields": [
-        "platform_content_id",
-        "author.*",
-        "statistics.*",
-        "has_more",
-        "next_cursor"
-      ],
-      "can_expand": true
-    },
-    {
-      "id": "AuthorWorksCursor",
-      "label": "作者作品分页状态",
-      "type": "cursor",
-      "key_fields": [
-        "account_id",
-        "next_cursor"
-      ],
-      "can_expand": true
-    },
-    {
-      "id": "Hashtag",
-      "label": "视频 hashtag",
-      "type": "traversal",
-      "key_fields": [
-        "tag_text"
-      ],
-      "can_expand": true
-    },
-    {
-      "id": "ContentPortrait",
-      "label": "内容画像",
-      "type": "signal",
-      "key_fields": [
-        "content_id",
-        "age_distribution",
-        "tgi"
-      ],
-      "can_expand": false
-    },
-    {
-      "id": "AccountPortrait",
-      "label": "账号画像",
-      "type": "signal",
-      "key_fields": [
-        "account_id",
-        "fans_age_distribution",
-        "tgi"
-      ],
-      "can_expand": false
-    },
-    {
-      "id": "InteractionPerformance",
-      "label": "互动表现",
-      "type": "signal",
-      "key_fields": [
-        "statistics.*"
-      ],
-      "can_expand": false
-    },
-    {
-      "id": "EvidenceBundle",
-      "label": "判断信号汇总",
-      "type": "decision_input",
-      "key_fields": [
-        "source_evidence",
-        "content_audience_profile",
-        "author_audience_profile",
-        "content_engagement_metrics",
-        "pattern_match_result",
-        "content_risk_check",
-        "run_context"
-      ],
-      "can_expand": false
-    },
-    {
-      "id": "RuleDecision",
-      "label": "规则包决策",
-      "type": "decision",
-      "key_fields": [
-        "triggered_blocking_rules",
-        "scorecard",
-        "decision_action",
-        "decision_reason_code"
-      ],
-      "can_expand": false
-    },
-    {
-      "id": "WalkAction",
-      "label": "游走动作",
-      "type": "walk_decision_action",
-      "key_fields": [
-        "walk_next_step",
-        "target_refs",
-        "source_path_record_basis",
-        "budget_effect",
-        "decision_reason_code"
-      ],
-      "can_expand": true
-    },
-    {
-      "id": "RunRecord",
-      "label": "运行记录",
-      "type": "run_record",
-      "key_fields": [
-        "run_events",
-        "source_path_records",
-        "search_clues",
-        "idempotency_key"
-      ],
-      "can_expand": false
-    },
-    {
-      "id": "AssetCommit",
-      "label": "资产提交",
-      "type": "asset_commit",
-      "key_fields": [
-        "rule_decision_id",
-        "source_path_record_ids",
-        "final_asset_status"
-      ],
-      "can_expand": false
-    },
-    {
-      "id": "OutputAsset",
-      "label": "沉淀结果",
-      "type": "output",
-      "key_fields": [
-        "content_asset",
-        "author_asset",
-        "search_clue",
-        "reject_record",
-        "source_evidence"
-      ],
-      "can_expand": false
-    }
-  ],
-  "path_rules": [
-    {
-      "id": "e01",
-      "from": "PatternSeed",
-      "to": "Query",
-      "input_fields": [
-        "seed_terms"
-      ],
-      "interface_or_action": "local_search_query_build",
-      "creates_new_node": true,
-      "can_loop": false,
-      "v1_default_enabled": true,
-      "control": "search_query_count_limit"
-    },
-    {
-      "id": "e02",
-      "from": "Query",
-      "to": "SearchPage",
-      "input_fields": [
-        "keyword",
-        "cursor=0"
-      ],
-      "interface_or_action": "/crawler/dou_yin/keyword",
-      "creates_new_node": true,
-      "can_loop": true,
-      "v1_default_enabled": true,
-      "control": "max_pages_per_search_query"
-    },
-    {
-      "id": "e03",
-      "from": "SearchPage",
-      "to": "SearchCursor",
-      "input_fields": [
-        "next_cursor"
-      ],
-      "interface_or_action": "read_pagination_state",
-      "creates_new_node": true,
-      "can_loop": true,
-      "v1_default_enabled": true,
-      "control": "cursor_dedup"
-    },
-    {
-      "id": "e04",
-      "from": "SearchCursor",
-      "to": "SearchPage",
-      "input_fields": [
-        "keyword",
-        "next_cursor"
-      ],
-      "interface_or_action": "/crawler/dou_yin/keyword",
-      "creates_new_node": true,
-      "can_loop": true,
-      "v1_default_enabled": true,
-      "control": "stop_when_continuous_low_quality"
-    },
-    {
-      "id": "path_005",
-      "from": "SearchPage",
-      "to": "Content",
-      "input_fields": [
-        "platform_content_id"
-      ],
-      "interface_or_action": "split_search_result",
-      "creates_new_node": true,
-      "can_loop": true,
-      "v1_default_enabled": true,
-      "control": "video_dedup"
-    },
-    {
-      "id": "e06",
-      "from": "SearchPage",
-      "to": "Author",
-      "input_fields": [
-        "author.platform_author_id"
-      ],
-      "interface_or_action": "split_search_result",
-      "creates_new_node": true,
-      "can_loop": true,
-      "v1_default_enabled": true,
-      "control": "author_dedup"
-    },
-    {
-      "id": "e07",
-      "from": "Content",
-      "to": "Author",
-      "input_fields": [
-        "author.platform_author_id"
-      ],
-      "interface_or_action": "read_video_author_field",
-      "creates_new_node": true,
-      "can_loop": true,
-      "v1_default_enabled": true,
-      "control": "author_budget"
-    },
-    {
-      "id": "e08",
-      "from": "Author",
-      "to": "AuthorWorksPage",
-      "input_fields": [
-        "account_id=author.platform_author_id",
-        "cursor=0"
-      ],
-      "interface_or_action": "/crawler/dou_yin/blogger",
-      "creates_new_node": true,
-      "can_loop": true,
-      "v1_default_enabled": true,
-      "control": "max_authors_and_works_per_author"
-    },
-    {
-      "id": "e09",
-      "from": "AuthorWorksPage",
-      "to": "AuthorWorksCursor",
-      "input_fields": [
-        "next_cursor"
-      ],
-      "interface_or_action": "read_pagination_state",
-      "creates_new_node": true,
-      "can_loop": true,
-      "v1_default_enabled": true,
-      "control": "author_cursor_dedup"
-    },
-    {
-      "id": "e10",
-      "from": "AuthorWorksCursor",
-      "to": "AuthorWorksPage",
-      "input_fields": [
-        "account_id",
-        "next_cursor"
-      ],
-      "interface_or_action": "/crawler/dou_yin/blogger",
-      "creates_new_node": true,
-      "can_loop": true,
-      "v1_default_enabled": true,
-      "control": "max_author_work_pages"
-    },
-    {
-      "id": "e11",
-      "from": "AuthorWorksPage",
-      "to": "Content",
-      "input_fields": [
-        "platform_content_id"
-      ],
-      "interface_or_action": "split_author_work_result",
-      "creates_new_node": true,
-      "can_loop": true,
-      "v1_default_enabled": true,
-      "control": "video_and_author_dedup"
-    },
-    {
-      "id": "e12",
-      "from": "Content",
-      "to": "Hashtag",
-      "input_fields": [
-        "desc",
-        "cha_list",
-        "text_extra"
-      ],
-      "interface_or_action": "local_tag_extract",
-      "creates_new_node": true,
-      "can_loop": true,
-      "v1_default_enabled": true,
-      "control": "max_tags_per_video_and_relevance_gate"
-    },
-    {
-      "id": "e13",
-      "from": "Hashtag",
-      "to": "Query",
-      "input_fields": [
-        "tag_text"
-      ],
-      "interface_or_action": "local_search_query_from_tag",
-      "creates_new_node": true,
-      "can_loop": true,
-      "v1_default_enabled": true,
-      "control": "drift_stop_and_search_query_dedup"
-    },
-    {
-      "id": "e14",
-      "from": "Content",
-      "to": "ContentPortrait",
-      "input_fields": [
-        "content_id=platform_content_id"
-      ],
-      "interface_or_action": "hotspot_content_portrait",
-      "creates_new_node": false,
-      "can_loop": false,
-      "v1_default_enabled": true,
-      "control": "signal_only"
-    },
-    {
-      "id": "e15",
-      "from": "Author",
-      "to": "AccountPortrait",
-      "input_fields": [
-        "account_id=author.platform_author_id"
-      ],
-      "interface_or_action": "hotspot_account_portrait",
-      "creates_new_node": false,
-      "can_loop": false,
-      "v1_default_enabled": true,
-      "control": "signal_only"
-    },
-    {
-      "id": "e16",
-      "from": "Content",
-      "to": "InteractionPerformance",
-      "input_fields": [
-        "statistics.*"
-      ],
-      "interface_or_action": "read_metrics",
-      "creates_new_node": false,
-      "can_loop": false,
-      "v1_default_enabled": true,
-      "control": "signal_only"
-    },
-    {
-      "id": "e17",
-      "from": "ContentPortrait / AccountPortrait / InteractionPerformance / relevance / risk",
-      "to": "EvidenceBundle",
-      "input_fields": [
-        "source_evidence",
-        "content_audience_profile",
-        "author_audience_profile",
-        "content_engagement_metrics",
-        "pattern_match_result",
-        "content_risk_check",
-        "run_context"
-      ],
-      "interface_or_action": "merge_decision_evidence",
-      "creates_new_node": false,
-      "can_loop": false,
-      "v1_default_enabled": true,
-      "control": "no_single_signal_decision"
-    },
-    {
-      "id": "e18",
-      "from": "EvidenceBundle",
-      "to": "RuleDecision",
-      "input_fields": [
-        "EvidenceBundle"
-      ],
-      "interface_or_action": "hard_gates_and_scorecard",
-      "creates_new_node": false,
-      "can_loop": false,
-      "v1_default_enabled": true,
-      "control": "decision_thresholds"
-    },
-    {
-      "id": "e19",
-      "from": "RuleDecision",
-      "to": "WalkAction",
-      "input_fields": [
-        "RuleDecision",
-        "PathState",
-        "WalkStrategyVersion"
-      ],
-      "interface_or_action": "plan_walk_decision_action",
-      "creates_new_node": false,
-      "can_loop": false,
-      "v1_default_enabled": true,
-      "control": "strategy_version_required"
-    },
-    {
-      "id": "e20",
-      "from": "WalkAction",
-      "to": "RunRecord / next_hop",
-      "input_fields": [
-        "WalkAction",
-        "source_path_record_basis",
-        "target_refs"
-      ],
-      "interface_or_action": "record_run_and_schedule_next_step",
-      "creates_new_node": false,
-      "can_loop": true,
-      "v1_default_enabled": true,
-      "control": "run_context_required"
-    },
-    {
-      "id": "e21",
-      "from": "RuleDecision / RunRecord",
-      "to": "AssetCommit",
-      "input_fields": [
-        "RuleDecision",
-        "source_path_record_ids",
-        "final_asset_status"
-      ],
-      "interface_or_action": "commit_asset_snapshot",
-      "creates_new_node": false,
-      "can_loop": false,
-      "v1_default_enabled": true,
-      "control": "final_asset_status_required"
-    },
-    {
-      "id": "e22",
-      "from": "AssetCommit",
-      "to": "OutputAsset",
-      "input_fields": [
-        "content_asset",
-        "author_asset",
-        "search_clue_asset",
-        "source_path_record_ids"
-      ],
-      "interface_or_action": "write_final_output",
-      "creates_new_node": false,
-      "can_loop": false,
-      "v1_default_enabled": true,
-      "control": "run_context_required"
-    }
-  ],
-  "decision_outputs": [
-    "视频是否入池",
-    "作者是否值得扩展",
-    "tag 是否生成新 search_query",
-    "当前路径是否停止",
-    "是否降预算待观察"
-  ],
-  "v1_default_path": [
-    "PatternSeed -> Query",
-    "Query -> SearchPage",
-    "SearchPage -> Video",
-    "Video -> Author",
-    "Author -> AuthorWorksPage",
-    "AuthorWorksPage -> Video",
-    "Video -> Hashtag -> Query (P6/tag expansion, not P2 query generation)",
-    "Video/Author -> EvidenceBundle -> RuleDecision",
-    "RuleDecision + PathState -> WalkAction",
-    "WalkAction -> RunRecord + next_hop",
-    "RuleDecision + source_path_record refs -> AssetCommit -> OutputAsset"
-  ],
-  "v1_default_budget": {
-    "max_queries_per_pattern": 5,
-    "max_pages_per_query": 3,
-    "max_discovered_content_items_per_pattern": 150,
-    "max_expand_authors": 10,
-    "max_author_work_pages": 3,
-    "max_author_works_per_author": 20,
-    "max_tag_expansion_hops": 10,
-    "max_depth": 3
-  },
-  "not_default_in_v1": [
-    "相关搜索",
-    "相似作者",
-    "共创作者",
-    "不受预算控制的深层循环"
-  ],
-  "runtime_run_files": [
-    "source_context.json",
-    "pattern_seed_pack.json",
-    "search_queries.jsonl",
-    "discovered_content_items.jsonl",
-    "content_media_records.jsonl",
-    "rule_decisions.jsonl",
-    "source_path_records.jsonl",
-    "search_clues.jsonl",
-    "run_events.jsonl",
-    "final_output.json",
-    "strategy_review.json"
-  ],
-  "runtime_run_file_notes": {
-    "rule_decisions.jsonl": "规则判断结果文件",
-    "source_path_records.jsonl": "来源记录文件",
-    "run_events.jsonl": "运行事件文件",
-    "final_output.json": "最终结果文件"
-  }
-}

+ 1 - 2
product_documents/抖音游走策略/douyin_evidence_bundle.v1.json

@@ -3,7 +3,7 @@
   "bundle_id": "douyin_evidence_bundle_v1",
   "bundle_name": "抖音 EvidenceBundle V1",
   "updated_at": "2026-06-08",
-  "strategy_id": "douyin_available_walk_strategy_v1",
+  "strategy_id": "douyin_content_find_v1",
   "platform": "douyin",
   "source": "PatternSeed",
   "purpose": "把抖音搜索、作者作品、视频解构、画像、互动、风险和 run_context(运行上下文)汇总成 EvidenceBundle(判断证据包),供规则包输出 RuleDecision(规则判断结果)。",
@@ -16,7 +16,6 @@
     "search_query_effect_status": "搜索词效果状态;新 V1 只使用 success / pending / failed / rule_blocked",
     "ADD_TO_CONTENT_POOL": "入池",
     "KEEP_CONTENT_FOR_REVIEW": "待复看,映射 pending",
-    "HOLD_CONTENT_PENDING": "historical / deprecated old action,不进入新 V1 runtime",
     "REJECT_CONTENT": "淘汰"
   },
   "blocks_order": [

+ 50 - 24
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`(搜索词效果状态)、`pattern_recall_evidence.jsonl`(Pattern 回扣证据文件)、`ADD_TO_CONTENT_POOL`(入池)、`KEEP_CONTENT_FOR_REVIEW`(待复看,映射 pending)、`REJECT_CONTENT`(淘汰);`HOLD_CONTENT_PENDING` 是 historical / deprecated old action,不进入新 V1 runtime。`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`(待复看,映射 pending)、`REJECT_CONTENT`(淘汰)。`source_path_records.jsonl`(来源记录文件)、`rule_decisions.jsonl`(规则判断结果文件)。
 
 本文定义 V1 本地 JSON / JSONL 兼容导出格式,不定义生产事实层。生产事实层是云上 MySQL 的 `content_agent_*` 表;本地文件用于开发调试、兼容验收和回放,不是 show 后端联调数据。默认目录:
 
@@ -37,7 +37,7 @@ P6 runtime 文件:
 |---|---|---|
 | `walk_actions.jsonl` | 记录每条 P6 edge 的 success / pending / failed / skipped / rule_blocked 动作事实 | P6 已启用 |
 
-`walk_actions.jsonl` 对应正式 DB 表 `content_agent_walk_actions`。当前 strict DB validator 期望 16 张 `content_agent_*` 表。
+`walk_actions.jsonl` 对应正式 DB 表 `content_agent_walk_actions`。P7 后 strict DB validator 期望 18 张 `content_agent_*` 表,新增作者资产主表和作者资产角色表。
 
 ## 2. 通用字段
 
@@ -60,7 +60,7 @@ P6 runtime 文件:
 |---|---|
 | `search_query_effect_status` | `success` / `pending` / `failed` / `rule_blocked` |
 | `age_50_plus_level` | `strong` / `medium` / `weak` / `missing` |
-| `content_pool_decision` | `ADD_TO_CONTENT_POOL`(入池) / `KEEP_CONTENT_FOR_REVIEW`(待复看) / `REJECT_CONTENT`(淘汰);`HOLD_CONTENT_PENDING` 是 historical / deprecated old action,不进入新 V1 runtime |
+| `content_pool_decision` | `ADD_TO_CONTENT_POOL`(入池) / `KEEP_CONTENT_FOR_REVIEW`(待复看) / `REJECT_CONTENT`(淘汰) |
 | `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` |
@@ -68,7 +68,7 @@ P6 runtime 文件:
 | `recall_status` | `matched` / `pending` / `failed` / `rejected` / `no_match` |
 | `final_asset_status` | `pooled` / `review_asset` / `stored_author` / `clue_only` |
 
-`search_query_effect_status` 含义:`success` 表示 query 下至少产生一条入池内容;`pending` 表示没有入池但有待复看内容;`failed` 表示无有效候选或普通淘汰;`rule_blocked` 表示候选主要被 hard gate 阻断。`weak_effective` / `blocked` 是 historical / deprecated old status,不进入新 V1 runtime。
+`search_query_effect_status` 含义:`success` 表示 query 下至少产生一条入池内容;`pending` 表示没有入池但有待复看内容;`failed` 表示无有效候选或普通淘汰;`rule_blocked` 表示候选主要被 hard gate 阻断。
 
 `source_context.json` 是 run 级输入事实,不写 `policy_run_id`。从 `pattern_seed_pack.json` 开始,所有策略相关产物都必须写 `policy_run_id`,保证未来同一批输入可以跑多套策略并逐条复盘。
 
@@ -76,7 +76,7 @@ P6 runtime 文件:
 
 用途:保存本次从 DemandAgent / `demand_content` 领取到的信息。
 
-必填:`schema_version`、`run_id`、`demand_content_id`、`merge_leve2`、`name`、`ext_data.evidence_pack`。  
+必填:`schema_version`、`run_id`、`demand_content_id`、`merge_leve2`、`name`、`ext_data.evidence_pack`。
 可空:`suggestion`、`score`、`dt`。
 
 最小样例:
@@ -118,7 +118,7 @@ P6 runtime 文件:
 
 用途:把本次使用的 Pattern 证据整理成 Query 和判断都能复用的 seed 包。
 
-必填:`schema_version`、`run_id`、`policy_run_id`、`pattern_source_system`、`pattern_execution_id`、`mining_config_id`、`source_post_id`、`case_id_type`、`itemsets`、`seed_terms`、`category_bindings` 或 `element_bindings`、`support`、`absolute_support`、`matched_post_ids`、`video_ids`、`case_ids`、`source_certainty`、`validation_status`。  
+必填:`schema_version`、`run_id`、`policy_run_id`、`pattern_source_system`、`pattern_execution_id`、`mining_config_id`、`source_post_id`、`case_id_type`、`itemsets`、`seed_terms`、`category_bindings` 或 `element_bindings`、`support`、`absolute_support`、`matched_post_ids`、`video_ids`、`case_ids`、`source_certainty`、`validation_status`。
 兼容归因字段:`decode_case_ids` 可为空数组,主链路不因它为空而失败;后续归因分析或回看上游已解构样本时再使用。
 
 最小样例:
@@ -159,7 +159,7 @@ P6 runtime 文件:
 
 用途:记录每条生成 query。query 不需要重复记住来自哪些 Pattern 词,但要能通过 `run_id` 和 `policy_run_id` 反查到 `pattern_seed_pack.json`。
 
-必填:`record_schema_version`、`run_id`、`policy_run_id`、`search_query_id`、`search_query`、`search_query_generation_method`、`discovery_start_source`、`previous_discovery_step`、`search_query_effect_status`、`query_source_terms`、`query_source_fields`。  
+必填:`record_schema_version`、`run_id`、`policy_run_id`、`search_query_id`、`search_query`、`search_query_generation_method`、`discovery_start_source`、`previous_discovery_step`、`search_query_effect_status`、`query_source_terms`、`query_source_fields`。
 枚举:当前运行记录至少支持 `item_single`;后续 P2 详细生成方式以 implementation brief 为准。
 
 样例:
@@ -172,7 +172,7 @@ P6 runtime 文件:
 
 用途:保存抖音关键词搜索和作者作品接口返回的发现视频 / 作者作品元数据。
 
-必填:`record_schema_version`、`run_id`、`policy_run_id`、`content_discovery_id`、`search_query_id` 或 `platform_author_id`、`platform_content_id`、`description`、`platform_author_id`、`statistics`、`previous_discovery_step`。  
+必填:`record_schema_version`、`run_id`、`policy_run_id`、`content_discovery_id`、`search_query_id` 或 `platform_author_id`、`platform_content_id`、`description`、`platform_author_id`、`statistics`、`previous_discovery_step`。
 可空:`cha_list`、`text_extra`、`create_time`、`next_cursor`。
 
 样例:
@@ -186,7 +186,7 @@ P6 runtime 文件:
 
 用途:记录视频媒体文件状态。V1 默认不下载视频、不上传 OSS,只保留 metadata。
 
-必填:`record_schema_version`、`run_id`、`policy_run_id`、`platform`、`platform_content_id`、`content_media_status`、`content_metadata_source`、`raw_payload`。  
+必填:`record_schema_version`、`run_id`、`policy_run_id`、`platform`、`platform_content_id`、`content_media_status`、`content_metadata_source`、`raw_payload`。
 可空:`play_url`、`local_path`、`oss_url`、`failure_reason`。
 
 样例:
@@ -202,7 +202,7 @@ P6 runtime 文件:
 
 写入边界:由 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`。  
+必填:`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`。
 
 关键约束:
@@ -225,7 +225,7 @@ P6 runtime 文件:
 
 用途:保存规则包判断。每条视频、作者、tag、路径停止判断都写一行。
 
-必填:`record_schema_version`、`run_id`、`policy_run_id`、`decision_id`、`policy_bundle_id`、`rule_pack_id`、`rule_pack_version`、`strategy_version`、`decision_target_type`、`decision_target_id`、`triggered_blocking_rules`、`scorecard`、`decision_action`、`decision_reason_code`、`source_evidence`、`decision_input_snapshot_id`、`decision_evidence_refs`、`decision_replay_data`。  
+必填:`record_schema_version`、`run_id`、`policy_run_id`、`decision_id`、`policy_bundle_id`、`rule_pack_id`、`rule_pack_version`、`strategy_version`、`decision_target_type`、`decision_target_id`、`triggered_blocking_rules`、`scorecard`、`decision_action`、`decision_reason_code`、`source_evidence`、`decision_input_snapshot_id`、`decision_evidence_refs`、`decision_replay_data`。
 可空:`score`、`age_50_plus_level`、`search_query_effect_status`。
 
 `decision_replay_data` 至少包含:`policy_run_id`、`policy_bundle_id`、`strategy_version`、`rule_pack_version`、`runtime_record_schema_version`、`decision_evidence_refs`,并记录命中的 gate 或 threshold,便于复盘。
@@ -244,7 +244,7 @@ P6 runtime 文件:
 
 写入边界:由运行记录模块追加。游走模块只提供下一步动作和来源/目标引用,资产沉淀只读取并归并来源关系。
 
-必填:`record_schema_version`、`run_id`、`policy_run_id`、`source_path_record_id`、`from_node_type`、`from_node_id`、`to_node_type`、`to_node_id`、`source_path_type`、`rule_pack_id`、`decision_id`。  
+必填:`record_schema_version`、`run_id`、`policy_run_id`、`source_path_record_id`、`from_node_type`、`from_node_id`、`to_node_type`、`to_node_id`、`source_path_type`、`rule_pack_id`、`decision_id`。
 枚举:`source_path_type = pattern_to_search_query / search_query_to_content / video_to_author / author_to_work / video_to_tag / tag_to_query / decision_to_asset`。
 
 样例:
@@ -260,7 +260,7 @@ P6 runtime 文件:
 
 写入边界:由运行记录模块追加。Query 模块只生成 `search_queries.jsonl`,资产沉淀只把确认可复用的线索写入最终输出或后续线索资产。
 
-必填:`record_schema_version`、`run_id`、`policy_run_id`、`clue_id`、`search_query_id`、`search_query`、`discovery_start_source`、`previous_discovery_step`、`search_query_effect_status`、`query_aggregation_id`、`result_count`。  
+必填:`record_schema_version`、`run_id`、`policy_run_id`、`clue_id`、`search_query_id`、`search_query`、`discovery_start_source`、`previous_discovery_step`、`search_query_effect_status`、`query_aggregation_id`、`result_count`。
 可空:`pooled_content_count`、`review_content_count`、`pending_content_count`、`rejected_content_count`、`walk_next_step`。
 
 样例:
@@ -274,7 +274,7 @@ P6 runtime 文件:
 
 用途:流水日志,记录每个关键动作是否成功。
 
-必填:`record_schema_version`、`run_id`、`policy_run_id`、`event_id`、`event_type`、`status`、`input_ref`、`output_ref`、`raw_payload`。  
+必填:`record_schema_version`、`run_id`、`policy_run_id`、`event_id`、`event_type`、`status`、`input_ref`、`output_ref`、`raw_payload`。
 枚举:`status = success / pending / failed`。旧样例里的 `blocked` 是 historical / deprecated old status;规则阻断结果在业务效果字段里写 `rule_blocked`。
 
 样例:
@@ -288,9 +288,22 @@ P6 runtime 文件:
 
 用途:一次 V1 运行 的最终复盘和资产输出。
 
-必填:`schema_version`、`run_id`、`policy_run_id`、`policy_bundle_id`、`strategy_id`、`strategy_version`、`rule_pack_id`、`rule_pack_version`、`policy_bundle_hash`、`content_assets`、`author_assets`、`decision_records`、`search_clues`、`reject_records`、`summary`。  
+必填:`schema_version`、`run_id`、`policy_run_id`、`policy`、`walk_strategy`、`content_assets`、`author_assets`、`review_records`、`decision_records`、`search_clues`、`reject_records`、`summary`、`validation_status`。
+`policy` 表达 P5 规则策略口径:`policy_bundle_id`、`strategy_id`、`strategy_version`、`rule_pack_id`、`rule_pack_version`、`policy_bundle_hash`。
+`walk_strategy` 表达 P6 游走策略口径:`walk_strategy_id`、`walk_strategy_version`、`walk_strategy_source_ref`。
 要求:每个 `content_asset` 必须能通过 `source_path_record_ids` 反查到 `source_context.json` 和 `pattern_seed_pack.json`。
 
+P7 后,`author_assets` 是最终视图摘要,不是作者资产主事实。作者资产主事实写入:
+
+| 表 | 用途 |
+|---|---|
+| `content_agent_author_assets` | 作者资产主表,记录作者身份、可用状态、来源、验证进度、画像摘要和证据引用 |
+| `content_agent_author_asset_roles` | 作者角色表,允许一个作者同时是人工精选、旧库迁移、已验证资产、后续数据源等多种角色 |
+
+`content_agent_author_assets.asset_status` 只表示资产可用性,建议值为 `candidate` / `active` / `paused` / `rejected` / `archived`。
+`content_agent_author_asset_roles.role` 表示身份,建议值为 `author_asset` / `source_seed` / `manual_curated` / `legacy_imported` / `high_50plus_profile`。
+旧 `demand_find_author` 只能作为 `legacy_import` 来源,不作为新版主事实表;若未来同步旧表,必须从 `content_agent_*` 表派生。
+
 最小样例:
 
 ```json
@@ -298,12 +311,21 @@ P6 runtime 文件:
   "schema_version": "runtime_record.v1",
   "run_id": "v1_run_001",
   "policy_run_id": "policy_run_001",
-  "policy_bundle_id": "douyin_policy_bundle_v1",
-  "strategy_id": "douyin_available_walk_strategy_v1",
-  "strategy_version": "V1",
-  "rule_pack_id": "douyin_content_discovery_rule_pack_v1",
-  "rule_pack_version": "1.0.0",
-  "policy_bundle_hash": "<sha256>",
+  "policy": {
+    "policy_bundle_id": "douyin_policy_bundle_v1",
+    "strategy_id": "douyin_content_find_v1",
+    "strategy_version": "V1",
+    "rule_pack_id": "douyin_content_discovery_rule_pack_v1",
+    "rule_pack_version": "1.0.0",
+    "policy_bundle_hash": "<sha256>"
+  },
+  "walk_strategy": {
+    "walk_strategy_id": "douyin_walk_strategy_v1",
+    "walk_strategy_version": "V1.0",
+    "walk_strategy_source_ref": {
+      "file": "product_documents/抖音游走策略/douyin_walk_strategy.v1.json"
+    }
+  },
   "content_assets": [
     {
       "platform_content_id": "7390000000000000000",
@@ -315,6 +337,8 @@ P6 runtime 文件:
   ],
   "author_assets": [
   ],
+  "review_records": [
+  ],
   "decision_records": [
     {
       "decision_id": "d_001",
@@ -395,10 +419,12 @@ P6 runtime 文件:
     "query_count": 2,
     "pooled_content_count": 1,
     "review_content_count": 1,
-    "pending_content_count": 1,
+    "pending_content_count": 0,
     "rejected_content_count": 1,
-    "run_path_complete": true
-  }
+    "run_path_complete": true,
+    "trace_complete": true
+  },
+  "validation_status": "pass"
 }
 ```
 

+ 5 - 56
product_documents/规则包/douyin_rule_packs.v1.json

@@ -2,10 +2,10 @@
   "schema_version": "rule_pack.v1",
   "package_id": "douyin_rule_packs_v1",
   "package_name": "抖音规则包 V1",
-  "description": "核心对象说明:EvidenceBundle 是判断证据包,RuleDecision 是规则判断结果,platform_content_id 是平台内容 ID,抖音下等于抖音视频 ID,platform_author_id 是抖音作者 ID,run_id 是本次运行 ID,policy_run_id 是本次策略执行 ID,record_schema_version 是运行记录结构版本,source_evidence 是来源证据,search_query_effect_status 是搜索词效果状态。 V1 runtime 只使用 success / pending / failed / rule_blocked 四类效果状态;weak_effective / blocked 是历史旧口径。ADD_TO_CONTENT_POOL 是入池,KEEP_CONTENT_FOR_REVIEW 是待复看并映射 pending,REJECT_CONTENT 是淘汰;HOLD_CONTENT_PENDING 已 deprecated,不作为 Content V1 正常输出。",
+  "description": "核心对象说明:EvidenceBundle 是判断证据包,RuleDecision 是规则判断结果,platform_content_id 是平台内容 ID,platform_author_id 是抖音作者 ID,run_id 是本次运行 ID,policy_run_id 是本次策略执行 ID,record_schema_version 是运行记录结构版本,source_evidence 是来源证据,search_query_effect_status 是搜索词效果状态。Content V1 runtime 只使用 success / pending / failed / rule_blocked 四类效果状态。ADD_TO_CONTENT_POOL 是入池,KEEP_CONTENT_FOR_REVIEW 是待复看并映射 pending,REJECT_CONTENT 是淘汰。",
   "updated_at": "2026-06-08",
   "strategy_binding": {
-    "strategy_id": "douyin_available_walk_strategy_v1",
+    "strategy_id": "douyin_content_find_v1",
     "strategy_version": "V1",
     "platform": "douyin",
     "source": "PatternSeed",
@@ -99,14 +99,10 @@
         "KEEP_CONTENT_FOR_REVIEW",
         "REJECT_CONTENT"
       ],
-      "deprecated_actions": [
-        "HOLD_CONTENT_PENDING"
-      ],
       "action_notes": {
         "ADD_TO_CONTENT_POOL": "入池成功,映射 content_effect_status=success。",
         "KEEP_CONTENT_FOR_REVIEW": "待人工复看,映射 content_effect_status=pending,不是弱成功。",
-        "REJECT_CONTENT": "淘汰;按原因映射 failed 或 rule_blocked。",
-        "HOLD_CONTENT_PENDING": "历史待观察动作,deprecated,不进入新 V1 runtime。"
+        "REJECT_CONTENT": "淘汰;按原因映射 failed 或 rule_blocked。"
       }
     },
     {
@@ -222,12 +218,6 @@
         "weak",
         "missing"
       ]
-    },
-    "deprecated_enums": {
-      "search_query_effect_status": {
-        "weak_effective": "历史弱有效口径,不进入新 V1 runtime。",
-        "blocked": "历史阻断口径,新 V1 runtime 使用 rule_blocked。"
-      }
     }
   },
   "global_budgets": {
@@ -628,18 +618,6 @@
         "age_50_plus_weak",
         "content_score_reject",
         "high_risk_content"
-      ],
-      "deprecated_thresholds": [
-        {
-          "min_score": 50,
-          "max_score": 59,
-          "decision_action": "HOLD_CONTENT_PENDING",
-          "decision_reason_code": "content_score_pending",
-          "effect_status": "failed",
-          "priority": 999,
-          "enabled": false,
-          "notes": "历史旧口径,deprecated,不进入新 V1 runtime。"
-        }
       ]
     },
     {
@@ -1196,18 +1174,6 @@
           "decision_reason_code": "high_duplicate_rate",
           "priority": 100
         },
-        {
-          "gate_id": "pending_not_reject",
-          "label": "上游视频判断为待观察但未淘汰",
-          "when": {
-            "field": "upstream_rule_decisions.content_pool.decision_action",
-            "op": "eq",
-            "value": "HOLD_CONTENT_PENDING"
-          },
-          "decision_action": "HOLD_LOW_BUDGET_PENDING",
-          "decision_reason_code": "pending_not_reject",
-          "priority": 100
-        },
         {
           "gate_id": "near_tag_hop_limit",
           "label": "接近 tag 扩散上限",
@@ -1305,11 +1271,7 @@
       "failed",
       "rule_blocked"
     ],
-    "deprecated_old_status": [
-      "weak_effective",
-      "blocked"
-    ],
-    "notes": "Content V1 runtime 只输出四类效果状态;旧状态只能作为历史数据解释。"
+    "notes": "Content V1 runtime 只输出 success / pending / failed / rule_blocked 四类效果状态。"
   },
   "effect_status_mapping": [
     {
@@ -1333,8 +1295,7 @@
       "content_effect_status": "pending",
       "query_effect_status": "pending",
       "priority": 20,
-      "enabled": true,
-      "notes": "待复看就是 pending,不再映射 weak_effective。"
+      "enabled": true
     },
     {
       "mapping_id": "map_reject_rule_blocked",
@@ -1359,18 +1320,6 @@
       "priority": 40,
       "enabled": true,
       "notes": "分数不达标、score 缺失等非 hard gate 淘汰属于普通失败。"
-    },
-    {
-      "mapping_id": "map_hold_content_pending_deprecated",
-      "target_level": "content",
-      "decision_action": "HOLD_CONTENT_PENDING",
-      "reason_category": "deprecated_old_pending",
-      "is_hard_gate": false,
-      "content_effect_status": "failed",
-      "query_effect_status": "failed",
-      "priority": 999,
-      "enabled": false,
-      "notes": "历史待观察动作,deprecated,不作为 Content V1 正常输出。"
     }
   ],
   "query_effect_aggregation": [

+ 3 - 3
product_documents/规则包/抖音规则包V1.md

@@ -4,7 +4,7 @@
 
 ## 阅读约定
 
-本文第一次出现核心字段或枚举时,会用括号补一句中文解释。常见字段包括:`platform_content_id`(平台内容 ID,抖音下等于抖音视频 ID)、`platform_author_id`(抖音作者 ID)、`run_id`(本次运行 ID)、`policy_run_id`(本次策略执行 ID)、`record_schema_version`(JSONL 单行结构版本)、`policy_bundle_id`(策略组合包 ID)、`strategy_version`(策略组合版本)、`source_evidence`(来源证据)、`search_query_effect_status`(搜索词效果状态)、`ADD_TO_CONTENT_POOL`(入池)、`KEEP_CONTENT_FOR_REVIEW`(待复看,映射 `pending`)、`REJECT_CONTENT`(淘汰)、`source_path_records.jsonl`(来源记录文件)、`rule_decisions.jsonl`(规则判断结果文件)。`HOLD_CONTENT_PENDING` 是 historical / deprecated old action,不进入新 V1 runtime。
+本文第一次出现核心字段或枚举时,会用括号补一句中文解释。常见字段包括:`platform_content_id`(平台内容 ID,抖音下等于抖音视频 ID)、`platform_author_id`(抖音作者 ID)、`run_id`(本次运行 ID)、`policy_run_id`(本次策略执行 ID)、`record_schema_version`(JSONL 单行结构版本)、`policy_bundle_id`(策略组合包 ID)、`strategy_version`(策略组合版本)、`source_evidence`(来源证据)、`search_query_effect_status`(搜索词效果状态)、`ADD_TO_CONTENT_POOL`(入池)、`KEEP_CONTENT_FOR_REVIEW`(待复看,映射 `pending`)、`REJECT_CONTENT`(淘汰)、`source_path_records.jsonl`(来源记录文件)、`rule_decisions.jsonl`(规则判断结果文件)。
 
 
 ## 1. 总原则
@@ -113,7 +113,7 @@ V1 统一口径:`matched` 表示 Pattern 回扣通过;`strong` 不再作为
 | 60-69 | 待复看(`KEEP_CONTENT_FOR_REVIEW`,映射 `pending`,不是弱成功) |
 | 0-59 | 淘汰 |
 
-score 缺失时直接淘汰,原因码为 `missing_score`。`HOLD_CONTENT_PENDING` 是 historical / deprecated old threshold action,不作为 Content V1 正常输出。
+score 缺失时直接淘汰,原因码为 `missing_score`。
 
 ### 例子
 
@@ -177,7 +177,7 @@ Pattern 是“早上好祝福 + 好运健康”。
 ### 例子
 
 - 作者长期发早安祝福、节日问候,粉丝画像偏 50+:扩作者。
-- 视频本身不错,但作者主页大多是年轻娱乐混剪:视频可以待复看(`KEEP_CONTENT_FOR_REVIEW`)或不扩;`HOLD_CONTENT_PENDING` 是 historical / deprecated old action
+- 视频本身不错,但作者主页大多是年轻娱乐混剪:视频可以待复看(`KEEP_CONTENT_FOR_REVIEW`)或不扩。
 - 作者作品不可爬:不扩。
 
 ## 5. tag 扩散规则包

+ 30 - 0
scripts/validate_content_agent_db.py

@@ -21,6 +21,8 @@ EXPECTED_TABLES = [
     "content_agent_run_events",
     "content_agent_final_outputs",
     "content_agent_publish_jobs",
+    "content_agent_author_assets",
+    "content_agent_author_asset_roles",
     "content_agent_pattern_recall_evidence",
     "content_agent_strategy_reviews",
     "content_agent_policy_runs",
@@ -31,6 +33,8 @@ COMMON_COLUMNS = {"id", "schema_version", "run_id", "created_at"}
 POLICY_RUN_TABLES = set(EXPECTED_TABLES) - {
     "content_agent_runs",
     "content_agent_source_contexts",
+    "content_agent_author_assets",
+    "content_agent_author_asset_roles",
 }
 REQUIRED_COLUMNS_BY_TABLE = {
     table: set(COMMON_COLUMNS) | ({"policy_run_id"} if table in POLICY_RUN_TABLES else set())
@@ -51,6 +55,27 @@ REQUIRED_COLUMNS_BY_TABLE["content_agent_policy_runs"].update(
     }
 )
 REQUIRED_COLUMNS_BY_TABLE["content_agent_content_media_records"].add("platform")
+REQUIRED_COLUMNS_BY_TABLE["content_agent_author_assets"].update(
+    {
+        "author_asset_id",
+        "platform",
+        "platform_author_id",
+        "asset_status",
+        "source_type",
+        "validation_status",
+        "eligible_as_source",
+    }
+)
+REQUIRED_COLUMNS_BY_TABLE["content_agent_author_assets"].discard("run_id")
+REQUIRED_COLUMNS_BY_TABLE["content_agent_author_asset_roles"].update(
+    {
+        "author_asset_id",
+        "role",
+        "role_status",
+        "assigned_by",
+    }
+)
+REQUIRED_COLUMNS_BY_TABLE["content_agent_author_asset_roles"].discard("run_id")
 REQUIRED_COLUMNS_BY_TABLE["content_agent_walk_actions"].update(
     {
         "walk_action_id",
@@ -101,6 +126,11 @@ REQUIRED_UNIQUE_INDEXES_BY_TABLE = {
     "content_agent_run_events": [{"run_id", "policy_run_id", "event_id"}],
     "content_agent_final_outputs": [{"run_id", "policy_run_id", "output_version"}],
     "content_agent_publish_jobs": [{"run_id", "policy_run_id", "publish_job_id"}],
+    "content_agent_author_assets": [
+        {"author_asset_id"},
+        {"platform", "platform_author_id"},
+    ],
+    "content_agent_author_asset_roles": [{"author_asset_id", "role"}],
     "content_agent_pattern_recall_evidence": [
         {"run_id", "policy_run_id", "recall_evidence_id"}
     ],

+ 4 - 4
scripts/validate_schema_registry.py

@@ -14,10 +14,10 @@ from count_schema_registry import (
 
 ROOT = Path(__file__).resolve().parents[1]
 EXPECTED_COUNTS = {
-    "table_count": 16,
-    "sql_column_count": 269,
-    "json_column_count": 51,
-    "unique_index_count": 19,
+    "table_count": 18,
+    "sql_column_count": 304,
+    "json_column_count": 56,
+    "unique_index_count": 22,
     "secondary_index_count": 32,
     "business_module_count": 10,
     "runtime_file_count": 13,

+ 46 - 0
sql/content_agent_schema.sql

@@ -314,6 +314,52 @@ CREATE TABLE IF NOT EXISTS content_agent_publish_jobs (
   KEY idx_content_agent_publish_jobs_content (platform_content_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
 
+CREATE TABLE IF NOT EXISTS content_agent_author_assets (
+  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+  schema_version VARCHAR(80) NOT NULL DEFAULT 'content_agent.v1',
+  author_asset_id VARCHAR(80) NOT NULL,
+  platform VARCHAR(32) NOT NULL DEFAULT 'douyin',
+  platform_author_id VARCHAR(256) NOT NULL,
+  author_display_name VARCHAR(256) NULL,
+  author_profile_url TEXT NULL,
+  asset_status VARCHAR(32) NOT NULL DEFAULT 'candidate',
+  source_type VARCHAR(64) NOT NULL,
+  validation_status VARCHAR(64) NOT NULL DEFAULT 'unverified',
+  eligible_as_source TINYINT(1) NOT NULL DEFAULT 0,
+  elderly_ratio DECIMAL(8,4) NULL,
+  elderly_tgi DECIMAL(12,4) NULL,
+  content_tags JSON NULL,
+  source_run_id VARCHAR(80) NULL,
+  source_policy_run_id VARCHAR(80) NULL,
+  last_profile_fetch_at DATETIME(3) NULL,
+  last_works_fetch_at DATETIME(3) NULL,
+  last_validated_at DATETIME(3) NULL,
+  profile_snapshot JSON NULL,
+  evidence_refs JSON NULL,
+  raw_payload JSON NULL,
+  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+  updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_ca_author_assets_asset (author_asset_id),
+  UNIQUE KEY uk_ca_author_assets_platform_author (platform, platform_author_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS content_agent_author_asset_roles (
+  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+  schema_version VARCHAR(80) NOT NULL DEFAULT 'content_agent.v1',
+  author_asset_id VARCHAR(80) NOT NULL,
+  role VARCHAR(64) NOT NULL,
+  role_status VARCHAR(32) NOT NULL DEFAULT 'active',
+  role_reason_code VARCHAR(160) NULL,
+  assigned_by VARCHAR(32) NOT NULL DEFAULT 'system',
+  source_run_id VARCHAR(80) NULL,
+  raw_payload JSON NULL,
+  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+  updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_ca_author_asset_roles_asset_role (author_asset_id, role)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
 CREATE TABLE IF NOT EXISTS content_agent_pattern_recall_evidence (
   id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
   schema_version VARCHAR(80) NOT NULL DEFAULT 'content_agent.v1',

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

@@ -960,58 +960,89 @@ P5 与 P6 的关系:
 
 ## P7 结果沉淀与来源反查模块
 
-目标:生成可交付的 `final_output.json`。
+目标:生成可交付的 `final_output.json`,把同一最终视图写入 `content_agent_final_outputs`,并从入池内容生成 `content_agent_publish_jobs` DB 任务记录。P7 不调用真实发布接口,不真实发布任务
 
 当前真实状态:
 
 - 已实现:`final_output.json` 写 `content_assets`、`reject_records`、`decision_records`、`search_clues`、`summary`。
 - 已实现:`author_assets` 字段存在。
+- 已实现:`final_output.json` 已映射到 DB 表 `content_agent_final_outputs`。
+- 已实现:`source_path_records` 代码侧已能读取 `decision_to_asset` 来源类型。
 - 未实现 / 部分实现:没有作者资产沉淀。
 - 未实现 / 部分实现:没有 KEEP_CONTENT_FOR_REVIEW 小预算复看计划视图;历史 `HOLD_CONTENT_PENDING` 不再规划新 V1 待观察记录视图。
 - 未实现 / 部分实现:没有 SearchClueAsset promotion。
 - 未实现 / 部分实现:没有长期 SourceLineage。
-- 未实现 / 部分实现:没有 DB 资产表。
+- 未实现 / 部分实现:没有 `content_agent_author_assets` 和 `content_agent_author_asset_roles`;当前只有 `final_output.author_assets` 空数组。
+- 未实现 / 部分实现:没有从 `final_output` 生成 `content_agent_publish_jobs`。
+- 未实现 / 部分实现:`decision_to_asset` 尚未在 P6 walk strategy `walk_source_path_mapping` 中闭合成正式 mapping。
+- 未实现 / 部分实现:`final_output` 还没有按 `policy` 和 `walk_strategy` 分组展示版本信息。
 - 当前代码默认值:只有 `ADD_TO_CONTENT_POOL` 进入 `content_assets`。
 - 当前代码默认值:`REJECT_CONTENT` 进入 `reject_records`。
 - 当前代码默认值:`author_assets=[]`。
-- 当前代码默认值:`trace_complete=True` 直接写入 summary
+- 当前代码默认值:没有 `trace_complete` 字段;`summary.run_path_complete=True` 直接写死
 
 已拍板:
 
 - `final_output.json` 是 V1 本地最小交付结果。
 - `final_output.json` 必须带 `decision_records` 和来源反查信息。
-- `KEEP_CONTENT_FOR_REVIEW` 不进入正式内容资产,进入小预算复看计划或复看记录。
-- `HOLD_CONTENT_PENDING` 是 historical / deprecated old action,不进入新 V1 runtime;如历史数据出现,只保留解释。
+- `content_agent_final_outputs` 是 P7 的正式 DB 快照表。
+- P7 生成 `content_agent_publish_jobs`,但只写 DB 任务记录;不调用真实发布接口,不真实发布任务。
+- `KEEP_CONTENT_FOR_REVIEW` 进入 `final_output.json` 可见列表,但必须标注为待复看,不进入正式内容资产。
+- `HOLD_CONTENT_PENDING` 已废弃;P7 必须把该旧动作从文档、JSON、数据库合同、代码和测试默认路径中删除,并增加删除 / 漂移检查。
+- P7 正式启用作者资产;满足作者沉淀条件的作者必须进入 `final_output.author_assets`,并写入正式 DB 表或明确 DB 映射。不得只停留在空数组或本地 JSON。
+- 作者资产 DB 落点已拍板为两张表:`content_agent_author_assets` 作者资产主表,`content_agent_author_asset_roles` 作者角色表。主表管作者是谁、是否可用、来源、验证进度和画像摘要;角色表支持一个作者同时拥有人工精选、旧库迁移、已验证资产、后续数据源等多种身份。
+- `SearchClueAsset` promotion 推迟到 P8;P7 只保留 `search_clues` 运行线索,不沉淀为可复用资产。
+- `decision_to_asset` 必须写入 `source_path_records`,并补齐 P6 walk strategy 的 `walk_source_path_mapping`。
+- `final_output` 中版本信息按 `policy` 和 `walk_strategy` 分组展示;`policy.strategy_version` 表示 P5 规则判断版本,`walk_strategy.walk_strategy_version` 表示 P6 游走策略版本。
+- `douyin_available_walk_strategy_v1` 已废弃;P7 必须像清理 `HOLD_CONTENT_PENDING` 一样,让它从当前文档、JSON、数据库合同、代码、测试和新 run 输出中彻底消失,并增加删除 / 漂移检查。
+- `trace_complete` / `run_path_complete` 必须由 validation 结果计算,不能写死。
+
+作者资产 DB 最小字段:
+
+| 表 | 最小字段 | 含义 |
+|---|---|---|
+| `content_agent_author_assets` | `author_asset_id`、`platform`、`platform_author_id`、`author_display_name`、`author_profile_url` | 作者资产身份字段;`platform_author_id` 对齐 `content_agent_discovered_content_items.platform_author_id`,热点宝账号画像使用该值作为 `account_id` |
+| `content_agent_author_assets` | `asset_status`、`source_type`、`validation_status`、`eligible_as_source` | 资产可用性、最初来源、验证进度、是否可作为后续数据源 |
+| `content_agent_author_assets` | `elderly_ratio`、`elderly_tgi`、`content_tags`、`last_profile_fetch_at`、`last_works_fetch_at`、`last_validated_at` | 热点宝账号画像摘要、内容标签和验证时间 |
+| `content_agent_author_assets` | `source_run_id`、`source_policy_run_id`、`profile_snapshot`、`evidence_refs`、`raw_payload` | 来源运行、完整画像 JSON、来源路径 / 决策 / 样例内容证据和扩展载荷 |
+| `content_agent_author_asset_roles` | `author_asset_id`、`role`、`role_status`、`role_reason_code`、`assigned_by`、`source_run_id`、`raw_payload` | 多角色字段;同一个作者可同时是 `legacy_imported`、`manual_curated`、`author_asset`、`source_seed`、`high_50plus_profile` |
 
 开工前必须拍板:
 
-- `KEEP_CONTENT_FOR_REVIEW` 的小预算复看计划是否进入 `final_output.json` 可见列表。
-- 历史 `HOLD_CONTENT_PENDING` 是否需要在 `final_output.json` 里以 deprecated old action 标注展示。
-- 作者资产何时启用。
-- `SearchClueAsset` 何时从运行线索 promotion 为可复用资产。
-- `trace_complete=True` 是否需要由 validation 结果计算,而不是写死。
+- 无。
 
 可先按当前默认值推进:
 
-- 在 P6 作者边未启用前,`author_assets=[]` 可以保留。
-- 在人工复看视图未拍板前,先只沉淀 `ADD_TO_CONTENT_POOL` 视频到 `content_assets`。
-- 在 DB Store 未启动前,`final_output.json` 继续作为本地最小交付视图;DB 映射已由 `content_agent_final_outputs` 承接。
+- P7 实现前,`final_output.json` 继续作为本地最小交付视图;`content_agent_final_outputs` 已确定为 P7 的正式 DB 快照落点,具体写入与字段闭合在 P7 实现。
+- P7 brief 落地前,当前代码仍可能保持 `author_assets=[]` 和写死 `run_path_complete=True`;这些只是当前缺口,P7 实现时必须改掉。
 
 本阶段不处理:
 
-- 长期资产 DB 表。
-- 作者资产沉淀。
+- 跨 run 长期资产库 / 多运行资产表;这不包括 P7 已拍板的作者资产 DB 落点。
 - SearchClueAsset promotion。
 - 多运行结果合并视图。
+- 自动调用发布接口。
 
 涉及文件:
 
 - `content_agent/business_modules/result_source_lookup.py`
+- `content_agent/business_modules/walk_strategy.py`
+- `content_agent/business_modules/run_record/validation.py`
+- `content_agent/integrations/database_runtime.py`
+- `sql/content_agent_schema.sql`
+- `scripts/validate_schema_registry.py`
+- `scripts/validate_content_agent_db.py`
+- `tech_documents/数据库字段总览/content_agent_schema_registry.json`
+- `product_documents/抖音游走策略/runtime_v1_records_schema.md`
+- `product_documents/抖音游走策略/douyin_walk_strategy.v1.json`
 
 主要函数:
 
 - `run()`
 - `_edges_by_decision_target_id()`
+- 后续 `_build_review_records()` 或等价复看视图构造函数
+- 后续 `_build_author_assets()`
+- 后续 validation 驱动的 trace / path completeness 计算函数
 
 开发顺序:
 
@@ -1021,16 +1052,39 @@ P5 与 P6 的关系:
 4. 汇总 `reject_records`。
 5. 汇总 `decision_records`。
 6. 汇总 `search_clues`。
-7. 生成 `summary`。
+7. 生成 `review_records` / 小预算复看可见列表;`KEEP_CONTENT_FOR_REVIEW` 必须标注为待复看,不进入正式内容资产。
+8. 生成 `author_assets`;只有作者沉淀条件满足时写入 final output,并写入 `content_agent_author_assets` / `content_agent_author_asset_roles`。
+9. 删除 `HOLD_CONTENT_PENDING` 新系统残留,并加入文档、JSON、DB 合同、代码和测试 drift guard。
+10. 保留 `search_clues` 运行线索;不做 `SearchClueAsset` promotion。
+11. 补齐 `decision_to_asset` 来源路径,保证最终资产能反查到判断结果。
+12. 将 final output 版本信息拆成 `policy` 和 `walk_strategy` 两组,分别承载 P5 规则判断版本和 P6 游走策略版本。
+13. 删除 `douyin_available_walk_strategy_v1` 当前系统残留,并加入文档、JSON、DB 合同、代码和测试 drift guard。
+14. 用 validation 结果生成 `trace_complete` / `run_path_complete`。
+15. 生成 `summary`。
+16. 写入 `content_agent_final_outputs`。
+17. 从入池内容生成 `content_agent_publish_jobs` DB 任务记录,不调用真实发布接口。
 
 达到效果:
 
 - `final_output.json` 是本次运行最小可交付结果。
-- `author_assets` 可以先为空,等 P6 作者边启用。
+- `content_agent_final_outputs` 有同结构最终快照。
+- `content_agent_publish_jobs` 有入池内容对应的 DB 任务记录,但没有真实发布副作用。
+- `KEEP_CONTENT_FOR_REVIEW` 在 final output 中可见,并被明确标注为待复看。
+- `HOLD_CONTENT_PENDING` 不再作为字段、动作或默认展示口径残留。
+- `author_assets` 正式启用,主事实落在 `content_agent_author_assets` 和 `content_agent_author_asset_roles`。
+- `search_clues` 仍是运行线索,不升级为 `SearchClueAsset`。
+- 每条正式内容资产都能反查到 `decision_to_asset`。
+- final output 中 `policy` 和 `walk_strategy` 分组清晰,规则判断版本和游走策略版本不混用。
+- `douyin_available_walk_strategy_v1` 不再作为当前策略名、字段值或默认引用残留。
+- `summary` 里的 trace / path completeness 不是写死值。
 
 输出:
 
 - `final_output.json`
+- `content_agent_final_outputs`
+- `content_agent_publish_jobs`
+- `content_agent_author_assets`
+- `content_agent_author_asset_roles`
 
 验证:
 
@@ -1038,6 +1092,16 @@ P5 与 P6 的关系:
 - `content_assets` 带 `source_path_record_ids` 和 `source_evidence`。
 - `reject_records` 能解释淘汰原因。
 - `summary` 与上游文件一致。
+- `final_output.json` 和 `content_agent_final_outputs.final_output` 同结构。
+- `content_agent_publish_jobs` 只建 DB 任务记录,不调用真实发布接口。
+- `KEEP_CONTENT_FOR_REVIEW` 出现在 final output 待复看可见列表中,并带待复看标注。
+- `HOLD_CONTENT_PENDING` 在 P7 正式文档、JSON、DB 合同、代码和测试默认路径中无残留;漂移检查必须覆盖。
+- `author_assets` 启用时必须有作者沉淀证据、来源路径,并能在 `content_agent_author_assets` / `content_agent_author_asset_roles` 查到。
+- `SearchClueAsset` 不在 P7 输出;`search_clues` 仍作为运行线索输出。
+- `decision_to_asset` 来源路径完整,并能在 `source_path_records` 反查。
+- final output 必须包含 `policy` 和 `walk_strategy` 两组版本信息。
+- `douyin_available_walk_strategy_v1` 在 P7 正式文档、JSON、DB 合同、代码、测试默认路径和新 run 输出中无残留;漂移检查必须覆盖。
+- `trace_complete` / `run_path_complete` 由 validation 计算。
 
 测试用例:
 

+ 182 - 0
tech_documents/工程落地/implementation_briefs/P7/00_P7_Brief_Index.md

@@ -0,0 +1,182 @@
+# P7 Implementation Brief Index
+
+状态:本目录是 P7 结果沉淀与来源反查实施前的短实施简报集合。它不是新的产品计划,也不替代 `tech_documents/工程落地/04_V1阶段开发计划.md`;长期事实仍以 V1 阶段开发计划、产品 PRD、runtime schema、当前代码、SQL、schema registry 和真实 DB validator 为准。
+
+完成后事实:P7 已完成实现与验收,当前完成证据见 `P7_completion_evidence.md`。本目录各 brief 中的“当前 / 现有证据 / 失败归因”描述保留为 P7 开工前基线,不得作为 P8 开工时的当前事实引用。
+
+## 目标
+
+为 P7 保留可执行 implementation brief,颗粒度固定到文件级、函数 / 类级、数据合同级、验证级和失败归因级。
+
+P7 只处理:
+
+1. P7A:`final_output.json` 最终视图和 `KEEP_CONTENT_FOR_REVIEW` 待复看可见列表。
+2. P7B:从入池内容生成 `content_agent_publish_jobs` DB-only 任务记录,不调用真实发布接口。
+3. P7C:启用作者资产两张主事实表和 final output 作者摘要。
+4. P7D:强校验 `decision_to_asset` 来源路径闭环。
+5. P7E:final output 按 `policy` 和 `walk_strategy` 拆分版本信息。
+6. P7F:清理 `HOLD_CONTENT_PENDING` 和 `douyin_available_walk_strategy_v1` 活跃残留。
+7. P7G:`trace_complete / run_path_complete` 由 validation 计算。
+8. P7H:P7 总验收、DB smoke、回归测试和 drift guard。
+
+本目录不处理:
+
+- SearchClueAsset promotion;P7 只保留 `search_clues` 运行线索。
+- 真实发布接口调用、自动发布、发布重试。
+- 旧 `demand_find_author` 写入或旧作者库同步。
+- 多运行长期资产合并视图。
+- P8 策略学习。
+
+## 现有证据
+
+- `content_agent/business_modules/result_source_lookup.py::run()` 已生成 `final_output.json`,写 `content_assets / reject_records / decision_records / search_clues / summary`。
+- `result_source_lookup.py::run()` 当前固定写 `author_assets=[]`。
+- `result_source_lookup.py::run()` 当前固定写 `summary.run_path_complete=True`。
+- `content_agent/business_modules/walk_strategy.py::run()` 已为 `ADD_TO_CONTENT_POOL` 生成 `decision_to_asset` source path basis。
+- `content_agent/business_modules/run_record/recorder.py::run()` 已写 `walk_actions.jsonl / run_events.jsonl / source_path_records.jsonl / search_clues.jsonl`。
+- `content_agent/business_modules/run_record/validation.py::validate_run()` 已检查 runtime 文件、schema、引用、source evidence、source path、summary,但未把 completeness 回写 final output。
+- `content_agent/integrations/database_runtime.py::DatabaseRuntimeStore` 已将 `final_output.json` 映射到 `content_agent_final_outputs`。
+- `sql/content_agent_schema.sql` 已有 `content_agent_final_outputs` 和 `content_agent_publish_jobs`。
+- `scripts/validate_content_agent_db.py` 当前 strict validator 期望 16 张 `content_agent_*` 表,真实 DB validator 已通过 16/16。
+- `content_agent_publish_jobs` 真实 DB 表存在,但当前主链路没有 publish job writer。
+- `content_agent_author_assets` 和 `content_agent_author_asset_roles` 只在产品 / 计划文档中拍板,SQL、registry、validator、真实 DB、runtime writer 都还没有。
+- `product_documents/规则包/douyin_rule_packs.v1.json`、`product_documents/抖音游走策略/runtime_v1_records_schema.md` 等仍有 `HOLD_CONTENT_PENDING` 活跃合同残留。
+- `douyin_available_walk_strategy_v1` 仍出现在规则包、runtime 样例、旧 JSON 和部分产品文档中。
+- 本轮只读基线:schema registry pass;真实 DB 16/16 ready;SQL/runtime 计数 16/13;`uv run pytest -q` 为 140 passed,1 warning。
+
+sub-agent 交叉验证结论:
+
+- 代码侧确认:P7 不是从零实现 final output,而是补 review view、publish job writer、author assets、版本分组和 validation completeness。
+- 文档 / JSON 侧确认:P7 必须专门清理 `HOLD_CONTENT_PENDING` 和 `douyin_available_walk_strategy_v1` 活跃残留。
+- DB / schema 侧确认:`content_agent_final_outputs` 与 `content_agent_publish_jobs` 已存在;作者资产两表不存在,P7C 必须升级 SQL / registry / validator / 真实 DB。
+- 测试 / drift 侧确认:P7 默认测试必须继续 fake 外部服务,真实 DB validator 只作为显式 smoke 命令。
+
+## 修改范围
+
+后续执行 P7 时允许修改:
+
+- `content_agent/business_modules/result_source_lookup.py`
+- `content_agent/business_modules/run_record/validation.py`
+- `content_agent/integrations/database_runtime.py`
+- `content_agent/interfaces.py`
+- `content_agent/run_service.py`
+- `sql/content_agent_schema.sql`
+- `scripts/validate_content_agent_db.py`
+- `scripts/validate_schema_registry.py`
+- `tech_documents/数据库字段总览/content_agent_schema_registry.json`
+- `product_documents/抖音游走策略/runtime_v1_records_schema.md`
+- `product_documents/抖音游走策略/douyin_walk_strategy.v1.json`
+- `product_documents/规则包/douyin_rule_packs.v1.json`
+- `tests/test_runtime_files.py`
+- `tests/test_database_runtime.py`
+- `tests/test_v1_graph.py`
+- 后续 P7 专用测试文件。
+
+## 不修改范围
+
+- 不降低 P0-P6 测试覆盖。
+- 不让默认测试访问真实 OpenRouter、Crawapi、AIGC、分类树、真实发布接口或真实 DB。
+- 不把 publish job 设计成真实发布动作。
+- 不把 `KEEP_CONTENT_FOR_REVIEW` 放入正式 `content_assets`。
+- 不把 `HOLD_CONTENT_PENDING` 作为 historical 兼容逻辑继续留在当前 runtime JSON。
+- 不读取或写入旧 `demand_find_author`。
+- 不做 `SearchClueAsset` promotion。
+
+## 涉及文件 / 函数 / 类
+
+关键代码:
+
+- `content_agent/business_modules/result_source_lookup.py::run()`
+- 后续 `result_source_lookup._build_review_records()`
+- 后续 `result_source_lookup._build_author_assets()`
+- 后续 `result_source_lookup._build_publish_jobs()`
+- `content_agent/business_modules/walk_strategy.py::run()`
+- `content_agent/business_modules/run_record/recorder.py::run()`
+- `content_agent/business_modules/run_record/validation.py::validate_run()`
+- 后续 validation completeness helper
+- `content_agent/integrations/database_runtime.py::DatabaseRuntimeStore`
+- `content_agent/run_service.py::_policy_run_record_from_state()`
+
+关键数据合同:
+
+- `final_output.json`
+- `content_agent_final_outputs`
+- `content_agent_publish_jobs`
+- `content_agent_author_assets`
+- `content_agent_author_asset_roles`
+- `content_agent_source_path_records`
+- `content_agent_rule_decisions`
+- `content_agent_walk_actions`
+
+## 数据合同
+
+P7 final output 必须包含:
+
+- `content_assets`:只放 `ADD_TO_CONTENT_POOL` 正式内容资产。
+- `review_records` 或等价可见列表:只放 `KEEP_CONTENT_FOR_REVIEW` 待复看内容,必须标注待复看,不进入 `content_assets`。
+- `author_assets`:作者资产最终视图摘要;主事实落 DB 作者资产两表。
+- `decision_records`:覆盖全部规则判断。
+- `search_clues`:仍是运行线索,不升级为 SearchClueAsset。
+- `reject_records`:解释淘汰原因。
+- `policy`:承载 P5 规则判断版本,`strategy_version=V1`。
+- `walk_strategy`:承载 P6 游走策略版本,`walk_strategy_version=V1.0`。
+- `summary.trace_complete` 和 `summary.run_path_complete`:由 validation 计算。
+
+P7 DB 合同:
+
+- `content_agent_final_outputs` 继续保存 final output 同结构快照。
+- `content_agent_publish_jobs` 只保存 DB 任务记录,默认 `job_status=created`。
+- `content_agent_author_assets` 保存作者资产主事实。
+- `content_agent_author_asset_roles` 保存作者多身份。
+
+## 开发顺序
+
+1. P7A 先改 final output 视图和待复看列表。
+2. P7B 再接 publish jobs DB-only writer,不碰真实发布接口。
+3. P7C 接作者资产两表、DB runtime、validator 和 final output 摘要。
+4. P7D 强化 `decision_to_asset` 来源路径校验。
+5. P7E 拆 final output 版本信息。
+6. P7F 清理旧 action / 旧策略名活跃残留。
+7. P7G 改 completeness 为 validation 计算。
+8. P7H 做总验收、drift guard 和 sub-agent 复核。
+
+每个阶段必须先读本目录对应 brief,再实施,不允许跨阶段顺手改。
+
+## 验证命令
+
+```bash
+find tech_documents/工程落地/implementation_briefs/P7 -maxdepth 1 -type f | sort
+rg -n "Implementation Brief|sub-agent|验证命令|失败归因|content_agent_author_assets|content_agent_publish_jobs" tech_documents/工程落地/implementation_briefs/P7
+uv run pytest tests/test_p7_final_output.py tests/test_p7_publish_jobs.py tests/test_p7_author_assets.py tests/test_p7_lineage_validation.py tests/test_p7_policy_walk_versions.py tests/test_p7_drift_guards.py -q
+uv run pytest tests/test_runtime_files.py tests/test_database_runtime.py tests/test_v1_graph.py -q
+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 -q
+rg -n "HOLD_CONTENT_PENDING|weak_effective|search_query_effect_status\\s*[=:]\\s*[\"']blocked[\"']" content_agent tests sql product_documents tech_documents
+rg -n "douyin_available_walk_strategy(_v1)?|douyin_available_walk_strategy\\.v1\\.json" content_agent tests sql product_documents tech_documents
+rg -n "demand_find_author|similar_author|co_author|相似作者|共创作者" content_agent tests
+rg -n "SearchClueAsset|search_clue_asset|clue_asset|promote.*search_clue" content_agent tests product_documents tech_documents
+rg -n "run_path_complete\\s*[:=]\\s*True|trace_complete\\s*[:=]\\s*True" content_agent tests
+rg -n "decision_to_asset" content_agent tests product_documents/抖音游走策略/douyin_walk_strategy.v1.json tech_documents
+```
+
+## 失败归因
+
+- P7 brief 文件缺失:本目录落地问题。
+- P7 专用测试文件不存在:P7H 未完成,不归因运行链路。
+- `content_agent_publish_jobs` 0 写入:P7B writer 未接入,不是 DB 表缺失。
+- 作者资产两表 validator 缺失:P7C SQL / registry / DB validator 未完成。
+- `author_assets=[]` 仍通过验收:P7C final output 摘要和 validation 未补。
+- `decision_to_asset` 缺失仍 pass:P7D validation 未补。
+- final output 顶层仍展示旧 strategy:P7E 未完成。
+- `HOLD_CONTENT_PENDING` 或旧策略名仍出现在活跃 JSON:P7F 未完成。
+- `run_path_complete=True` 仍写死:P7G 未完成。
+- 默认测试访问真实发布接口:P7B drift guard 失败。
+
+## sub-agent 交叉验证要点
+
+- 每个 P7 小阶段执行前,关闭旧 sub-agent;若无可关闭 ID,直接开新一批 explorer。
+- 代码侧 explorer:核查 `result_source_lookup / validation / database_runtime / run_service` 是否符合当前 brief。
+- 文档 / JSON explorer:核查产品文档、runtime schema、规则包、walk strategy 是否无旧口径冲突。
+- DB / schema explorer:核查 SQL、registry、validator、真实 DB 和唯一键。
+- 测试 / drift explorer:核查每个 brief 的验证命令、默认测试外部服务边界和 drift guard。

+ 122 - 0
tech_documents/工程落地/implementation_briefs/P7/P7A_FinalOutput_View_And_ReviewRecords.md

@@ -0,0 +1,122 @@
+# P7A FinalOutput View And ReviewRecords Implementation Brief
+
+状态:本 brief 覆盖 P7 final output 最终视图和 `KEEP_CONTENT_FOR_REVIEW` 待复看可见列表。P7A 不写 publish jobs,不新增作者资产表。
+
+## 目标
+
+- 保留 `final_output.json` 作为本地最小交付视图。
+- 继续让 DB runtime 将 `final_output.json` 写入 `content_agent_final_outputs`。
+- 将 `KEEP_CONTENT_FOR_REVIEW` 内容放入 final output 可见待复看列表。
+- 待复看内容必须有 `review_status=pending_review` 或等价标注。
+- 待复看内容不得进入正式 `content_assets`。
+- 不恢复 `HOLD_CONTENT_PENDING`。
+
+## 现有证据
+
+- `content_agent/business_modules/result_source_lookup.py::run()` 当前遍历 `discovered_content_items` 和 `decisions`。
+- `result_source_lookup.py::run()` 当前只把 `ADD_TO_CONTENT_POOL` 写入 `content_assets`。
+- `result_source_lookup.py::run()` 当前只把 `REJECT_CONTENT` 写入 `reject_records`。
+- `result_source_lookup.py::run()` 当前 `summary.review_content_count` 已统计 `KEEP_CONTENT_FOR_REVIEW`,但没有可见 review 列表。
+- `tests/test_v1_graph.py` 已断言 mock run 中 `review_content_count == 1`。
+- `tests/test_runtime_files.py` 已验证 final output、decision records、source path 和 summary 一致。
+- `content_agent/integrations/database_runtime.py::DatabaseRuntimeStore` 已把 `final_output.json` 映射到 `content_agent_final_outputs`。
+
+sub-agent 交叉验证结论:
+
+- 代码侧确认:final output 已有基础结构,待复看只在 count 里可见。
+- 文档 / JSON 侧确认:P7 已拍板 `KEEP_CONTENT_FOR_REVIEW` 可见,但不进入正式内容资产。
+- 测试侧确认:可在现有 mock full run 上扩展 P7 final output 断言。
+
+## 修改范围
+
+后续执行 P7A 时允许修改:
+
+- `content_agent/business_modules/result_source_lookup.py`
+- `content_agent/business_modules/run_record/validation.py`
+- `product_documents/抖音游走策略/runtime_v1_records_schema.md`
+- `tests/test_p7_final_output.py`
+- `tests/test_runtime_files.py`
+- `tests/test_v1_graph.py`
+
+## 不修改范围
+
+- 不写 `content_agent_publish_jobs`。
+- 不新增 `content_agent_author_assets` 或 `content_agent_author_asset_roles`。
+- 不改变 P5 decision action 枚举。
+- 不让 `KEEP_CONTENT_FOR_REVIEW` 进入 `content_assets`。
+- 不把 `HOLD_CONTENT_PENDING` 当作待复看兼容输入。
+
+## 涉及文件 / 函数 / 类
+
+函数:
+
+- `result_source_lookup.run()`
+- 后续 `result_source_lookup._build_content_assets()`
+- 后续 `result_source_lookup._build_review_records()`
+- `validation.validate_run()`
+- 后续 validation review records check
+
+数据:
+
+- `rule_decisions.jsonl.decision_action`
+- `final_output.json.content_assets`
+- `final_output.json.review_records`
+- `final_output.json.decision_records`
+- `final_output.json.summary.review_content_count`
+- `content_agent_final_outputs.final_output`
+
+## 数据合同
+
+`review_records` 最小字段:
+
+- `platform`
+- `platform_content_id`
+- `policy_run_id`
+- `content_discovery_id`
+- `review_status`
+- `review_reason_code`
+- `decision_id`
+- `rule_pack_id`
+- `rule_pack_version`
+- `source_path_record_ids`
+- `source_evidence`
+
+字段约束:
+
+- `review_status` 固定使用 `pending_review`。
+- `review_reason_code` 来自 `decision_reason_code`。
+- `source_path_record_ids` 必须能在 `source_path_records.jsonl` 中找到。
+- `decision_id` 必须能在 `rule_decisions.jsonl` 中找到。
+
+## 开发顺序
+
+1. 在 `result_source_lookup.py` 拆出 `_build_content_assets()`,保持只处理 `ADD_TO_CONTENT_POOL`。
+2. 新增 `_build_review_records()`,只处理 `KEEP_CONTENT_FOR_REVIEW`。
+3. 在 final output 顶层加入 `review_records`。
+4. 保持 `summary.review_content_count` 与 `review_records` 数量一致。
+5. 在 validation 中增加 review records 引用校验。
+6. 更新 runtime schema 文档中的 `final_output.json` 必填 / 可选字段说明。
+7. 增加 P7A 单元测试和 mock full run 集成断言。
+
+## 验证命令
+
+```bash
+uv run pytest tests/test_p7_final_output.py tests/test_runtime_files.py tests/test_v1_graph.py -q
+uv run python scripts/validate_schema_registry.py
+rg -n "review_records|pending_review|KEEP_CONTENT_FOR_REVIEW" content_agent tests product_documents/抖音游走策略/runtime_v1_records_schema.md
+rg -n "HOLD_CONTENT_PENDING" content_agent tests
+```
+
+## 失败归因
+
+- `review_content_count > 0` 但 `review_records` 为空:P7A final output 视图未实现。
+- `KEEP_CONTENT_FOR_REVIEW` 出现在 `content_assets`:正式资产边界错误。
+- `review_records.source_path_record_ids` 无法反查:P7A validation 或 source path 汇总错误。
+- `HOLD_CONTENT_PENDING` 出现在 P7A 新测试 fixture:测试口径错误。
+- DB validator 失败:P7A 不改 DB,优先归因现有 schema drift。
+
+## sub-agent 交叉验证要点
+
+- 代码侧确认:`ADD_TO_CONTENT_POOL` 和 `KEEP_CONTENT_FOR_REVIEW` 分别进入不同 final output 列表。
+- 文档 / JSON 侧确认:runtime schema 没把待复看说成正式内容资产。
+- 测试侧确认:mock run 中 review 内容可见,且不进入 `content_assets`。

+ 122 - 0
tech_documents/工程落地/implementation_briefs/P7/P7B_PublishJobs_DBOnly.md

@@ -0,0 +1,122 @@
+# P7B PublishJobs DBOnly Implementation Brief
+
+状态:本 brief 覆盖从入池内容生成 `content_agent_publish_jobs` DB-only 任务记录。P7B 不调用真实发布接口,不新增发布 runtime 文件。
+
+## 目标
+
+- 对每个 `ADD_TO_CONTENT_POOL` 内容生成一条 `content_agent_publish_jobs` 记录。
+- 任务记录只写 DB,不触发真实发布。
+- 默认 `job_status=created`。
+- `request_payload` 必须保留 final asset、decision、source path 可追溯信息。
+- 默认测试使用 fake DB connection,不访问真实 DB 或真实发布接口。
+
+## 现有证据
+
+- `sql/content_agent_schema.sql` 已有 `content_agent_publish_jobs`。
+- `scripts/validate_content_agent_db.py` 已把 `content_agent_publish_jobs` 纳入 16 表 strict validator。
+- 真实 DB validator 已通过 16/16,`content_agent_publish_jobs` 存在。
+- `content_agent/integrations/database_runtime.py::RUNTIME_FILE_TABLES` 没有 publish job 映射。
+- `content_agent/interfaces.py::RuntimeFileStore` 当前没有 publish job 专用方法。
+- `content_agent/business_modules/result_source_lookup.py::run()` 当前只写 `final_output.json`,不写 publish job。
+- `tests/test_database_runtime.py` 已有 `FakeConnection` / `FakeCursor`,适合测试 DB-only insert。
+
+sub-agent 交叉验证结论:
+
+- DB 侧确认:`content_agent_publish_jobs` 表已存在,P7B 缺的是业务 writer。
+- 代码侧确认:当前没有 publish job 写入入口。
+- 测试侧确认:P7B 必须断言不调用真实 publish API。
+
+## 修改范围
+
+后续执行 P7B 时允许修改:
+
+- `content_agent/interfaces.py`
+- `content_agent/integrations/database_runtime.py`
+- `content_agent/integrations/composite_runtime.py`
+- `content_agent/business_modules/result_source_lookup.py`
+- `tests/test_p7_publish_jobs.py`
+- `tests/test_database_runtime.py`
+- `tests/test_p7_drift_guards.py`
+
+## 不修改范围
+
+- 不新增 `publish_jobs.jsonl` runtime 文件。
+- 不调用真实发布接口。
+- 不新增队列 worker。
+- 不写 `response_payload` 成功结果。
+- 不改 `content_agent_publish_jobs` 已有 DDL,除非 P7B 测试证明字段无法承载 DB-only 任务。
+
+## 涉及文件 / 函数 / 类
+
+函数 / 类:
+
+- `RuntimeFileStore`
+- `DatabaseRuntimeStore`
+- `CompositeRuntimeStore`
+- 后续 `DatabaseRuntimeStore.write_publish_jobs()`
+- 后续 `CompositeRuntimeStore.write_publish_jobs()`
+- `result_source_lookup.run()`
+- 后续 `result_source_lookup._build_publish_jobs()`
+
+DB 字段:
+
+- `publish_job_id`
+- `run_id`
+- `policy_run_id`
+- `platform_content_id`
+- `job_status`
+- `trigger_mode`
+- `crawler_plan_id`
+- `produce_plan_id`
+- `publish_plan_id`
+- `request_payload`
+- `response_payload`
+- `error_code`
+- `error_message`
+
+## 数据合同
+
+P7B publish job 记录:
+
+- `publish_job_id` 稳定生成:`publish_{short_hash(run_id, policy_run_id, platform_content_id)}` 或等价 deterministic ID。
+- `platform_content_id` 来自 `content_assets[].platform_content_id`。
+- `job_status=created`。
+- `trigger_mode=manual_review`。
+- `request_payload.final_asset_status=pooled`。
+- `request_payload.decision_id` 来自 content asset。
+- `request_payload.source_path_record_ids` 来自 content asset。
+- `response_payload` 默认为空。
+
+## 开发顺序
+
+1. 在 `RuntimeFileStore` 增加可选 / 默认 no-op 的 publish job 写入接口,或新增独立 DB writer adapter;业务层不得直接拼 SQL。
+2. 在 `DatabaseRuntimeStore` 实现 `content_agent_publish_jobs` insert / upsert。
+3. 在 `CompositeRuntimeStore` 将 publish job 写入转发到 DB store;本地 runtime store 可 no-op。
+4. 在 `result_source_lookup.run()` final output 生成后,从 `content_assets` 构造 publish jobs。
+5. 确保 DB runtime disabled 时本地 mock run 仍成功,只是不写 publish job DB。
+6. 增加 fake DB 测试,断言 SQL insert 目标表和字段。
+7. 增加 drift guard,禁止真实发布 API 调用。
+
+## 验证命令
+
+```bash
+uv run pytest tests/test_p7_publish_jobs.py tests/test_database_runtime.py -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 "content_agent_publish_jobs|publish_job_id|write_publish" content_agent tests sql tech_documents/数据库字段总览
+rg -n "publish_api|real publish|真实发布|自动调用发布接口|requests\\.|httpx\\.|publish\\(" content_agent tests
+```
+
+## 失败归因
+
+- `content_agent_publish_jobs` 表缺失:DB 基线漂移,不是 P7B writer 问题。
+- fake DB 未写 insert:P7B writer 未接入。
+- mock full run 因 DB disabled 失败:writer 边界错误。
+- 测试命中真实 HTTP publish 调用:P7B 边界失败。
+- `response_payload` 被写成成功发布结果:误把 DB task 当真实发布。
+
+## sub-agent 交叉验证要点
+
+- 代码侧确认:业务层通过 store/writer 写 DB,不直接散落 SQL。
+- DB 侧确认:字段和唯一键沿用现有 `content_agent_publish_jobs`。
+- 测试侧确认:默认测试只用 fake DB,不访问真实发布接口。

+ 188 - 0
tech_documents/工程落地/implementation_briefs/P7/P7C_AuthorAssets_DB_And_Runtime.md

@@ -0,0 +1,188 @@
+# P7C AuthorAssets DB And Runtime Implementation Brief
+
+状态:本 brief 覆盖 P7 作者资产正式启用。P7C 新增两张作者资产 DB 表、schema registry、DB validator、DB runtime writer 和 final output 作者摘要。P7C 不写旧 `demand_find_author`。
+
+## 目标
+
+- 新增 `content_agent_author_assets` 作者资产主表。
+- 新增 `content_agent_author_asset_roles` 作者多角色表。
+- 满足作者沉淀条件时写 DB 主事实,并在 `final_output.author_assets` 中给出摘要。
+- 一个作者可以同时拥有多个角色。
+- 旧 `demand_find_author` 只能作为未来迁移来源,不作为 P7 写入目标。
+- P7C 完成后 strict DB validator 从 16/16 升级到 18/18。
+
+## 现有证据
+
+- `product_documents/prd/V1落地版本细化版.md` 已拍板两张作者资产表和最小字段口径。
+- `tech_documents/工程落地/04_V1阶段开发计划.md` 已拍板 P7 正式启用作者资产。
+- `content_agent/business_modules/result_source_lookup.py::run()` 当前固定 `author_assets=[]`。
+- `content_agent/business_modules/walk_engine.py::_execute_author_edges()` 已能通过作者作品回流产生新内容、media、evidence、decisions。
+- `content_agent/integrations/douyin.py` 已归一 `platform_author_id` 和 `author_display_name`。
+- `sql/content_agent_schema.sql` 当前没有 `content_agent_author_assets` 和 `content_agent_author_asset_roles`。
+- `tech_documents/数据库字段总览/content_agent_schema_registry.json` 当前没有作者资产两表。
+- `scripts/validate_content_agent_db.py` 当前 `EXPECTED_TABLES` 是 16 表。
+- 真实 DB 当前无作者资产两表。
+
+sub-agent 交叉验证结论:
+
+- 代码侧确认:P6 作者边已有作者作品回流,但没有资产沉淀。
+- DB 侧确认:作者资产两表不存在,P7C 是 DDL / registry / validator 小阶段。
+- 文档侧确认:`final_output.author_assets` 是摘要,DB 两表才是作者资产主事实。
+
+## 修改范围
+
+后续执行 P7C 时允许修改:
+
+- `sql/content_agent_schema.sql`
+- `tech_documents/数据库字段总览/content_agent_schema_registry.json`
+- `tech_documents/数据库字段总览/content_agent_schema_registry_count_report.json`
+- `tech_documents/数据库字段总览/content_agent_schema_registry_count_report.md`
+- `scripts/validate_content_agent_db.py`
+- `scripts/validate_schema_registry.py`
+- `content_agent/integrations/database_runtime.py`
+- `content_agent/integrations/composite_runtime.py`
+- `content_agent/interfaces.py`
+- `content_agent/business_modules/result_source_lookup.py`
+- `content_agent/business_modules/run_record/validation.py`
+- `tests/test_p7_author_assets.py`
+- `tests/test_database_runtime.py`
+- `tests/test_runtime_files.py`
+
+## 不修改范围
+
+- 不写旧 `demand_find_author`。
+- 不读取旧作者库作为默认 P7 输入。
+- 不把未验证作者写入 `final_output.author_assets`。
+- 不把作者本身送 P4 Pattern 回扣;只有作者作品视频进入 P4/P5。
+- 不在 P7C 做相似作者、共创作者。
+
+## 涉及文件 / 函数 / 类
+
+函数 / 类:
+
+- `result_source_lookup.run()`
+- 后续 `result_source_lookup._build_author_assets()`
+- 后续 `DatabaseRuntimeStore.write_author_assets()`
+- 后续 `DatabaseRuntimeStore.write_author_asset_roles()`
+- `validation.validate_run()`
+- 后续 author asset validation helper
+
+DB 表:
+
+- `content_agent_author_assets`
+- `content_agent_author_asset_roles`
+
+## 数据合同
+
+`content_agent_author_assets` 最小字段:
+
+- `id`
+- `schema_version`
+- `author_asset_id`
+- `platform`
+- `platform_author_id`
+- `author_display_name`
+- `author_profile_url`
+- `asset_status`
+- `source_type`
+- `validation_status`
+- `eligible_as_source`
+- `elderly_ratio`
+- `elderly_tgi`
+- `content_tags`
+- `source_run_id`
+- `source_policy_run_id`
+- `last_profile_fetch_at`
+- `last_works_fetch_at`
+- `last_validated_at`
+- `profile_snapshot`
+- `evidence_refs`
+- `raw_payload`
+- `created_at`
+- `updated_at`
+
+`content_agent_author_asset_roles` 最小字段:
+
+- `id`
+- `schema_version`
+- `author_asset_id`
+- `role`
+- `role_status`
+- `role_reason_code`
+- `assigned_by`
+- `source_run_id`
+- `raw_payload`
+- `created_at`
+- `updated_at`
+
+唯一键:
+
+- `content_agent_author_assets`:`(platform, platform_author_id)`。
+- `content_agent_author_assets`:`(author_asset_id)`。
+- `content_agent_author_asset_roles`:`(author_asset_id, role)`。
+
+枚举:
+
+- `asset_status`: `candidate / active / paused / rejected / archived`。
+- `source_type`: `new_discovery / legacy_import / manual_added / oss_import`。
+- `validation_status`: `unverified / profile_fetched / works_fetched / works_validated / rule_validated / failed`。
+- `role`: `author_asset / source_seed / manual_curated / legacy_imported / high_50plus_profile`。
+- `role_status`: `active / paused / removed`。
+- `assigned_by`: `system / manual / migration`。
+
+`final_output.author_assets` 摘要最小字段:
+
+- `author_asset_id`
+- `platform`
+- `platform_author_id`
+- `author_display_name`
+- `asset_status`
+- `roles`
+- `eligible_as_source`
+- `source_path_record_ids`
+- `decision_ids`
+- `evidence_refs`
+
+## 开发顺序
+
+1. 在 SQL 中新增两张作者资产表。
+2. 更新 schema registry 和 count report。
+3. 更新 `scripts/validate_content_agent_db.py`,expected table count 升到 18,补两表必需列和唯一键。
+4. 对当前 `.env` 指向测试库执行两表 DDL。
+5. 在 DB runtime 中实现作者资产两表写入。
+6. 在 result source lookup 中从作者作品和规则判断结果构造作者资产候选。
+7. 只有满足作者沉淀条件时写 DB 和 final output 摘要。
+8. validation 校验 `final_output.author_assets` 能反查 DB 写入所需证据。
+9. 增加 fake DB、runtime、mock full run 测试。
+
+## 验证命令
+
+```bash
+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_p7_author_assets.py tests/test_database_runtime.py tests/test_runtime_files.py -q
+uv run python - <<'PY'
+from scripts.count_schema_registry import parse_sql_schema, parse_runtime_filenames
+print(len(parse_sql_schema()), len(parse_runtime_filenames()))
+print("content_agent_author_assets" in parse_sql_schema())
+print("content_agent_author_asset_roles" in parse_sql_schema())
+PY
+rg -n "content_agent_author_assets|content_agent_author_asset_roles" sql content_agent tests tech_documents/数据库字段总览 product_documents
+rg -n "demand_find_author|similar_author|co_author|相似作者|共创作者" content_agent tests
+```
+
+## 失败归因
+
+- schema validator 仍是 16 表:P7C registry / validator 未升级。
+- DB validator missing author tables:P7C DDL 未执行或执行到错误库。
+- DB validator unique key 失败:作者资产表唯一键设计未落。
+- `final_output.author_assets=[]` 但有满足条件作者:P7C summary builder 未实现。
+- 出现 `demand_find_author` 写入:P7C 边界失败。
+- 默认测试访问真实热点宝:测试边界失败;P7C 默认只能 fake。
+
+## sub-agent 交叉验证要点
+
+- 代码侧确认:作者资产来自 P6 作者边和作者作品重新 P3/P4/P5 后的证据。
+- DB 侧确认:两张表、字段、唯一键、真实 DB 18/18 全部一致。
+- 文档侧确认:旧 `demand_find_author` 只作为 migration source,不是 P7 写入目标。
+- 测试侧确认:作者不满足条件时不会进入 final output。

+ 104 - 0
tech_documents/工程落地/implementation_briefs/P7/P7D_DecisionToAsset_SourcePath_And_LineageValidation.md

@@ -0,0 +1,104 @@
+# P7D DecisionToAsset SourcePath And LineageValidation Implementation Brief
+
+状态:本 brief 覆盖 `decision_to_asset` 来源路径闭环。当前代码已生成该 source path,但 validation 还需要强校验每个正式内容资产都能反查 `RuleDecision -> decision_to_asset -> ContentAsset`。
+
+## 目标
+
+- 每个 `final_output.content_assets[]` 必须包含 `decision_to_asset` source path。
+- `decision_to_asset.from_node_type=RuleDecision`。
+- `decision_to_asset.from_node_id` 必须等于 content asset 的 `decision_id`。
+- `decision_to_asset.to_node_type=ContentAsset`。
+- `decision_to_asset.to_node_id` 必须等于 content asset 的 `platform_content_id`。
+- validation 缺失时必须 fail。
+
+## 现有证据
+
+- `content_agent/business_modules/walk_strategy.py::run()` 已在 `ADD_TO_CONTENT_POOL` 时生成 `source_path_type=decision_to_asset`。
+- `walk_strategy.py::run()` 已将 `walk_action_id` 写入 decision_to_asset basis。
+- `content_agent/business_modules/run_record/recorder.py::run()` 将 source path basis 编号并写入 `source_path_records.jsonl`。
+- `content_agent/business_modules/result_source_lookup.py::_paths_by_content_id()` 已把 `decision_to_asset` 加入 asset 的 `source_path_record_ids`。
+- `content_agent/business_modules/run_record/validation.py` 已检查 source path 基础引用,但未强制每个 content asset 都有 `decision_to_asset`。
+- `product_documents/抖音游走策略/douyin_walk_strategy.v1.json` 已有 `decision_to_asset` edge。
+
+sub-agent 交叉验证结论:
+
+- 代码侧确认:生成逻辑已有,强校验不足。
+- 文档侧确认:P7 已拍板必须闭合“判断结果到资产”的最后一跳。
+- 测试侧确认:可通过篡改 `source_path_records.jsonl` 验证 validation fail。
+
+## 修改范围
+
+后续执行 P7D 时允许修改:
+
+- `content_agent/business_modules/run_record/validation.py`
+- `content_agent/business_modules/result_source_lookup.py`
+- `tests/test_p7_lineage_validation.py`
+- `tests/test_runtime_files.py`
+- `product_documents/抖音游走策略/runtime_v1_records_schema.md`
+
+## 不修改范围
+
+- 不改变 P6 edge catalog。
+- 不改变 `content_agent_source_path_records` 表结构,除非测试证明 raw payload 无法承载 `walk_action_id`。
+- 不把 `KEEP_CONTENT_FOR_REVIEW` 强制要求 `decision_to_asset`,因为它不是正式资产。
+- 不为 `REJECT_CONTENT` 生成 asset path。
+
+## 涉及文件 / 函数 / 类
+
+函数:
+
+- `walk_strategy.run()`
+- `run_record.run()`
+- `result_source_lookup._paths_by_content_id()`
+- `validation.validate_run()`
+- 后续 `validation._check_decision_to_asset_paths()`
+
+数据:
+
+- `source_path_records.jsonl`
+- `final_output.json.content_assets[].source_path_record_ids`
+- `rule_decisions.jsonl`
+- `walk_actions.jsonl`
+
+## 数据合同
+
+必备 source path:
+
+- `source_path_type=decision_to_asset`
+- `from_node_type=RuleDecision`
+- `from_node_id=<decision_id>`
+- `to_node_type=ContentAsset`
+- `to_node_id=<platform_content_id>`
+- `decision_id=<decision_id>`
+- `walk_action_id` 可在 row 或 `raw_payload` 中反查
+
+## 开发顺序
+
+1. 在 validation 中建立 `source_path_record_id -> path` 索引。
+2. 对每个 `content_assets[]` 检查至少一个 source path 是 `decision_to_asset`。
+3. 校验该 path 的 `from_node_id / to_node_id / decision_id` 与 content asset 一致。
+4. 校验 content asset 的 `decision_id` 能在 `rule_decisions.jsonl` 找到。
+5. 校验 `source_evidence.source_path_record_ids` 与 content asset 顶层 `source_path_record_ids` 一致或互为包含。
+6. 增加篡改测试:删除 `decision_to_asset` path 后 validation fail。
+7. 更新 runtime schema 的验收条款。
+
+## 验证命令
+
+```bash
+uv run pytest tests/test_p7_lineage_validation.py tests/test_runtime_files.py -q
+uv run pytest tests/test_v1_graph.py -q
+rg -n "decision_to_asset" content_agent tests product_documents/抖音游走策略/douyin_walk_strategy.v1.json product_documents/抖音游走策略/runtime_v1_records_schema.md tech_documents
+```
+
+## 失败归因
+
+- content asset 无 `decision_to_asset` 仍 pass:P7D validation 未接入。
+- `decision_to_asset` 存在但 from/to 不匹配:walk_strategy 或 result_source_lookup 聚合错误。
+- `KEEP_CONTENT_FOR_REVIEW` 被要求 asset path:P7D 资产边界写错。
+- `walk_action_id` 无法反查:P6/P7 source path 合同不完整。
+
+## sub-agent 交叉验证要点
+
+- 代码侧确认:validation 能证伪缺失 `decision_to_asset`。
+- 文档侧确认:runtime schema 明确最终资产最后一跳。
+- 测试侧确认:篡改 source path 的负例稳定失败。

+ 122 - 0
tech_documents/工程落地/implementation_briefs/P7/P7E_Policy_And_WalkStrategy_Version_Output.md

@@ -0,0 +1,122 @@
+# P7E Policy And WalkStrategy Version Output Implementation Brief
+
+状态:本 brief 覆盖 final output 中 P5 规则策略版本和 P6 游走策略版本的展示口径。P7E 不改变 P5 规则判断逻辑,不改变 P6 edge 执行逻辑。
+
+## 目标
+
+- final output 拆出 `policy` 分组。
+- final output 拆出 `walk_strategy` 分组。
+- `policy.strategy_version` 表示 P5 规则判断版本,当前为 `V1`。
+- `walk_strategy.walk_strategy_version` 表示 P6 游走策略版本,当前为 `V1.0`。
+- 不再用顶层 `strategy_id / strategy_version` 表达当前 runtime 策略。
+- 不再展示 `douyin_available_walk_strategy_v1` 作为当前策略。
+
+## 现有证据
+
+- `content_agent/business_modules/result_source_lookup.py::run()` 当前 final output 顶层写 `strategy_id` 和 `strategy_version`。
+- `content_agent/run_service.py::_policy_run_record_from_state()` 当前写 `walk_strategy_version`,但值来自 `policy_bundle.get("strategy_version")`,存在混淆风险。
+- `content_agent/integrations/walk_strategy_json.py` 已将 walk strategy config 导出为 `walk_strategy_version`。
+- `tests/test_walk_strategy_config.py` 已断言 walk strategy version 是 `V1.0`。
+- `product_documents/抖音游走策略/runtime_v1_records_schema.md` final output 样例仍有旧顶层策略字段。
+- `product_documents/规则包/douyin_rule_packs.v1.json` 仍有旧 `strategy_id`。
+
+sub-agent 交叉验证结论:
+
+- 文档 / JSON 侧确认:P7 已拍板 final output 拆成 `policy` 和 `walk_strategy`。
+- 代码侧确认:当前 final output 顶层字段仍混在一起。
+- DB 侧确认:`content_agent_policy_runs.walk_strategy_version` 字段已存在,但写入值需要核准。
+
+## 修改范围
+
+后续执行 P7E 时允许修改:
+
+- `content_agent/business_modules/result_source_lookup.py`
+- `content_agent/run_service.py`
+- `content_agent/integrations/policy_json.py`
+- `content_agent/integrations/walk_strategy_json.py`
+- `product_documents/抖音游走策略/runtime_v1_records_schema.md`
+- `product_documents/规则包/douyin_rule_packs.v1.json`
+- `tests/test_p7_policy_walk_versions.py`
+- `tests/test_runtime_files.py`
+- `tests/test_v1_graph.py`
+
+## 不修改范围
+
+- 不把 P5 `strategy_version=V1` 改成 `V1.0`。
+- 不把 P6 `walk_strategy_version=V1.0` 写进 P5 `strategy_version`。
+- 不改变 rule pack evaluator。
+- 不改变 walk edge catalog。
+
+## 涉及文件 / 函数 / 类
+
+函数:
+
+- `result_source_lookup.run()`
+- `RunService._policy_run_record_from_state()`
+- `JsonPolicyBundleStore.load_policy_bundle()`
+- `WalkStrategyStore.load()` 或等价方法
+
+数据:
+
+- `final_output.json.policy`
+- `final_output.json.walk_strategy`
+- `content_agent_policy_runs.strategy_version`
+- `content_agent_policy_runs.walk_strategy_version`
+- `content_agent_final_outputs.final_output`
+
+## 数据合同
+
+`final_output.policy` 最小字段:
+
+- `policy_bundle_id`
+- `strategy_id`
+- `strategy_version`
+- `rule_pack_id`
+- `rule_pack_version`
+- `policy_bundle_hash`
+- `strategy_source_ref`
+- `rule_pack_source_ref`
+
+`final_output.walk_strategy` 最小字段:
+
+- `walk_strategy_id`
+- `walk_strategy_version`
+- `walk_strategy_source_ref`
+
+兼容期:
+
+- P7 实现时可以短期保留顶层旧字段作为 derived compatibility,但 P7H drift guard 必须保证当前 runtime 展示口径以 `policy / walk_strategy` 为准。
+- `douyin_available_walk_strategy_v1` 不得出现在新 run 的 current strategy 字段值里。
+
+## 开发顺序
+
+1. 梳理 policy bundle 与 walk strategy config 的来源。
+2. 在 final output 中新增 `policy` 和 `walk_strategy` 分组。
+3. 将原顶层 policy 字段迁移到 `policy`。
+4. 从 walk strategy store 或 policy source ref 中填充 `walk_strategy`。
+5. 修正 `_policy_run_record_from_state()` 中 `walk_strategy_version` 的来源。
+6. 更新 runtime schema 样例。
+7. 增加版本输出测试和旧策略名 drift guard。
+
+## 验证命令
+
+```bash
+uv run pytest tests/test_p7_policy_walk_versions.py tests/test_runtime_files.py tests/test_v1_graph.py -q
+uv run python scripts/validate_schema_registry.py
+rg -n "policy|walk_strategy|walk_strategy_version|strategy_version" content_agent tests product_documents/抖音游走策略/runtime_v1_records_schema.md
+rg -n "douyin_available_walk_strategy_v1|douyin_available_walk_strategy\\.v1\\.json" content_agent tests product_documents tech_documents
+```
+
+## 失败归因
+
+- final output 仍只有顶层 `strategy_version`:P7E 未实现。
+- `policy.strategy_version` 变成 `V1.0`:P5/P6 版本混淆。
+- `walk_strategy.walk_strategy_version` 变成 `V1`:P5/P6 版本混淆。
+- 新 run 出现 `douyin_available_walk_strategy_v1`:旧策略名清理未完成。
+- policy run 表写错 walk version:`_policy_run_record_from_state()` 来源错误。
+
+## sub-agent 交叉验证要点
+
+- 代码侧确认:final output 与 policy run 版本来源一致。
+- 文档 / JSON 侧确认:runtime schema 不再用旧策略名样例。
+- 测试侧确认:mock run 断言 P5/P6 版本分组。

+ 116 - 0
tech_documents/工程落地/implementation_briefs/P7/P7F_DeprecatedAction_And_OldStrategy_DriftCleanup.md

@@ -0,0 +1,116 @@
+# P7F DeprecatedAction And OldStrategy DriftCleanup Implementation Brief
+
+状态:本 brief 覆盖 `HOLD_CONTENT_PENDING` 和 `douyin_available_walk_strategy_v1` 的 P7 清理。P7F 是 drift cleanup 阶段,不新增业务能力。
+
+## 目标
+
+- `HOLD_CONTENT_PENDING` 从当前文档、机器可读 JSON、代码、测试默认路径和新 run 输出中消失。
+- `douyin_available_walk_strategy_v1` 从当前 runtime 策略字段、活跃文档、机器可读 JSON、代码和测试默认路径中消失。
+- 历史说明只能放在明确归档 / 迁移语境,不能留在当前 runtime contract。
+- P7H 必须有 drift guard。
+
+## 现有证据
+
+- `tech_documents/工程落地/04_V1阶段开发计划.md` 已拍板 P7 清理 `HOLD_CONTENT_PENDING`。
+- `tech_documents/工程落地/04_V1阶段开发计划.md` 已拍板 P7 清理 `douyin_available_walk_strategy_v1`。
+- `product_documents/规则包/douyin_rule_packs.v1.json` 仍有 `HOLD_CONTENT_PENDING` 残留。
+- `product_documents/抖音游走策略/runtime_v1_records_schema.md` 仍解释 `HOLD_CONTENT_PENDING`,且 final output 样例仍有旧策略名。
+- `product_documents/抖音游走策略/douyin_available_walk_strategy.v1.json` 仍存在旧 strategy JSON。
+- 当前代码 / 测试需要通过 `rg` 验证是否仍读取旧 JSON 或输出旧 action。
+
+sub-agent 交叉验证结论:
+
+- 文档 / JSON 侧确认:旧 action 和旧策略名仍有活跃残留。
+- 测试 / drift 侧确认:P7F 必须新增专用 drift guard。
+- 代码侧确认:清理后不能影响 P5 正常 action:`ADD_TO_CONTENT_POOL / KEEP_CONTENT_FOR_REVIEW / REJECT_CONTENT`。
+
+## 修改范围
+
+后续执行 P7F 时允许修改:
+
+- `product_documents/规则包/douyin_rule_packs.v1.json`
+- `product_documents/规则包/抖音规则包V1.md`
+- `product_documents/抖音游走策略/runtime_v1_records_schema.md`
+- `product_documents/抖音游走策略/douyin_evidence_bundle.v1.json`
+- `product_documents/抖音游走策略/douyin_available_walk_strategy.v1.json` 或归档位置
+- `product_documents/README.md`
+- `product_documents/prd/*.md`
+- `tech_documents/**/*.md`
+- `content_agent/**/*.py`
+- `tests/**/*.py`
+- `tests/test_p7_drift_guards.py`
+
+## 不修改范围
+
+- 不删除合法的 `KEEP_CONTENT_FOR_REVIEW`。
+- 不删除 P5 `strategy_version=V1`。
+- 不删除 P6 `douyin_walk_strategy_v1`。
+- 不把历史旧 JSON 作为 current runtime source。
+- 不改真实 DB 数据。
+
+## 涉及文件 / 函数 / 类
+
+文件:
+
+- `product_documents/规则包/douyin_rule_packs.v1.json`
+- `product_documents/抖音游走策略/runtime_v1_records_schema.md`
+- `product_documents/抖音游走策略/douyin_available_walk_strategy.v1.json`
+- `content_agent/integrations/policy_json.py`
+- `content_agent/integrations/walk_strategy_json.py`
+- `tests/test_p6_drift_guards.py`
+- 后续 `tests/test_p7_drift_guards.py`
+
+## 数据合同
+
+P7 当前 runtime action 只允许:
+
+- `ADD_TO_CONTENT_POOL`
+- `KEEP_CONTENT_FOR_REVIEW`
+- `REJECT_CONTENT`
+
+P7 当前 strategy name 只允许:
+
+- P5 policy: `douyin_policy_bundle_v1` / `strategy_version=V1`
+- P6 walk strategy: `douyin_walk_strategy_v1` / `walk_strategy_version=V1.0`
+
+禁止 current runtime 出现:
+
+- `HOLD_CONTENT_PENDING`
+- `weak_effective`
+- `search_query_effect_status="blocked"`
+- `douyin_available_walk_strategy_v1`
+- `douyin_available_walk_strategy.v1.json` 作为当前 source ref
+
+## 开发顺序
+
+1. 先跑 drift `rg`,记录所有命中。
+2. 区分 current runtime contract 与历史归档说明。
+3. 从机器可读 JSON 中删除 `HOLD_CONTENT_PENDING` 当前输出路径。
+4. 从 runtime schema 当前字段说明和样例中删除旧 action / 旧策略名。
+5. 将旧 JSON 从 current source ref 中移除;如保留文件,必须标注 historical archive,且 current code/tests 不读取。
+6. 增加 `tests/test_p7_drift_guards.py`。
+7. 跑 P5/P6/P7 相关回归,确认正常 action 未被误删。
+
+## 验证命令
+
+```bash
+uv run pytest tests/test_p7_drift_guards.py tests/test_rule_pack_reading.py tests/test_policy_dispatch.py -q
+uv run pytest tests/test_runtime_files.py tests/test_v1_graph.py -q
+rg -n "HOLD_CONTENT_PENDING|weak_effective|search_query_effect_status\\s*[=:]\\s*[\"']blocked[\"']" content_agent tests sql product_documents tech_documents
+rg -n "douyin_available_walk_strategy(_v1)?|douyin_available_walk_strategy\\.v1\\.json" content_agent tests sql product_documents tech_documents
+rg -n "ADD_TO_CONTENT_POOL|KEEP_CONTENT_FOR_REVIEW|REJECT_CONTENT" product_documents/规则包/douyin_rule_packs.v1.json content_agent tests
+```
+
+## 失败归因
+
+- 旧 action 仍在机器可读 JSON:P7F 清理未完成。
+- 旧策略名仍在 final output 样例:runtime schema 未更新。
+- 旧 JSON 仍被代码读取:runtime source 迁移失败。
+- `KEEP_CONTENT_FOR_REVIEW` 被误删:清理过度。
+- drift guard 只能人工 rg,无测试:P7F 测试未落。
+
+## sub-agent 交叉验证要点
+
+- 文档 / JSON 侧确认:旧口径只存在归档语境。
+- 代码侧确认:current runtime 不读取旧 JSON。
+- 测试侧确认:drift guard 能拦住旧 action / 旧策略名回流。

+ 104 - 0
tech_documents/工程落地/implementation_briefs/P7/P7G_RunCompleteness_ValidationComputed.md

@@ -0,0 +1,104 @@
+# P7G RunCompleteness ValidationComputed Implementation Brief
+
+状态:本 brief 覆盖 `trace_complete` 和 `run_path_complete` 从写死值改为 validation 计算。P7G 必须让 completeness 可以被测试证伪。
+
+## 目标
+
+- `summary.run_path_complete` 不能写死 `True`。
+- `summary.trace_complete` 必须由 validation 结果或等价 completeness helper 计算。
+- `content_agent_final_outputs.validation_status` 必须能拿到 final validation 状态。
+- 删除 source path、decision record、content asset 引用后,validation 必须 fail 或 completeness 为 false。
+
+## 现有证据
+
+- `content_agent/business_modules/result_source_lookup.py::run()` 当前 `summary.run_path_complete=True`。
+- 当前 final output 没有 `trace_complete`。
+- `content_agent/business_modules/run_record/validation.py::validate_run()` 已返回 `pass/fail` 和 findings。
+- `content_agent/integrations/database_runtime.py::_record_for_json()` 会从 final output 读取 `validation_status`,但 final output 当前没有该字段,DB 值会为空。
+- `RunService._record_success_metadata()` 在 graph 完成后调用 `validate_run()` 并更新 run record。
+
+sub-agent 交叉验证结论:
+
+- 代码侧确认:validation 已有,但没有驱动 final output completeness。
+- DB 侧确认:`content_agent_final_outputs.validation_status` 字段已存在但当前无来源。
+- 测试侧确认:P7G 需要负例证明 completeness 不是写死。
+
+## 修改范围
+
+后续执行 P7G 时允许修改:
+
+- `content_agent/business_modules/result_source_lookup.py`
+- `content_agent/business_modules/run_record/validation.py`
+- `content_agent/graph.py`
+- `content_agent/run_service.py`
+- `content_agent/integrations/database_runtime.py`
+- `tests/test_p7_final_output.py`
+- `tests/test_p7_lineage_validation.py`
+- `tests/test_database_runtime.py`
+- `tests/test_runtime_files.py`
+
+## 不修改范围
+
+- 不降低 validation 严格度。
+- 不因为 completeness fail 而吞掉 runtime/schema 写入异常。
+- 不把单 edge failed 等同整 run failed。
+- 不为通过测试硬编码 `validation_status=pass`。
+
+## 涉及文件 / 函数 / 类
+
+函数:
+
+- `result_source_lookup.run()`
+- `validation.validate_run()`
+- 后续 `validation.compute_run_completeness()`
+- `RunService._record_success_metadata()`
+- `DatabaseRuntimeStore._record_for_json()`
+
+数据:
+
+- `final_output.json.summary.run_path_complete`
+- `final_output.json.summary.trace_complete`
+- `final_output.json.validation_status`
+- `content_agent_final_outputs.validation_status`
+- `content_agent_runs.validation_status`
+
+## 数据合同
+
+计算口径:
+
+- `validation_status=pass`:没有 fail finding。
+- `run_path_complete=true`:content assets、review records、reject records、decision records、search clues 的引用链通过 validation。
+- `trace_complete=true`:source context、pattern seed、query、content、decision、walk action、source path、final output 的关键引用都通过 validation。
+- 任一关键引用缺失时,对应 complete 字段必须为 false 或 validation fail。
+
+## 开发顺序
+
+1. 在 validation 模块新增可复用 completeness 计算结果,返回 `validation_status / run_path_complete / trace_complete / findings_summary`。
+2. 调整 final output 生成流程,让 `summary` 使用 completeness 结果。
+3. 如果 final output 写入发生在 validation 前,增加二次更新 final output 或调整 graph 顺序。
+4. 确保 DB runtime 最终写入 `content_agent_final_outputs.validation_status`。
+5. 增加正例:mock full run complete 为 true。
+6. 增加负例:删除 source path 后 validation fail / completeness false。
+7. 增加 drift guard:禁止 `run_path_complete=True` / `trace_complete=True` 写死。
+
+## 验证命令
+
+```bash
+uv run pytest tests/test_p7_final_output.py tests/test_p7_lineage_validation.py tests/test_database_runtime.py tests/test_runtime_files.py -q
+uv run pytest tests/test_v1_graph.py -q
+rg -n "run_path_complete\\s*[:=]\\s*True|trace_complete\\s*[:=]\\s*True" content_agent tests
+rg -n "validation_status|trace_complete|run_path_complete" content_agent tests sql tech_documents/数据库字段总览
+```
+
+## 失败归因
+
+- `run_path_complete=True` 仍被 rg 命中:P7G 写死值未清。
+- DB `validation_status` 为空:final output 写 DB 时机或字段来源未闭合。
+- 删除 source path 后仍 pass:validation 引用检查不足。
+- 单 edge failed 导致 `trace_complete=false` 但 run 应为 partial success:completeness 口径过严,需要区分 edge failure 和 trace 缺失。
+
+## sub-agent 交叉验证要点
+
+- 代码侧确认:complete 字段来自 validation helper。
+- DB 侧确认:`content_agent_final_outputs.validation_status` 有写入来源。
+- 测试侧确认:负例能稳定证伪写死 completeness。

+ 140 - 0
tech_documents/工程落地/implementation_briefs/P7/P7H_P7_Acceptance_And_DriftGuards.md

@@ -0,0 +1,140 @@
+# P7H P7 Acceptance And DriftGuards Implementation Brief
+
+状态:本 brief 覆盖 P7 完成后的总验收、集成测试、DB runtime smoke、真实 DB validator 和 drift guard。P7H 必须证明 P7 完成后仍不破坏 P0-P6 合同。
+
+## 目标
+
+- 验收 `final_output.json`、`content_agent_final_outputs`、`content_agent_publish_jobs`、作者资产两表和来源路径闭环。
+- 验收 P7 不调用真实发布接口。
+- 验收 `KEEP_CONTENT_FOR_REVIEW` 可见且不进正式资产。
+- 验收 `HOLD_CONTENT_PENDING` 和 `douyin_available_walk_strategy_v1` 不在当前 runtime 回流。
+- 验收 final output 版本分组清晰。
+- 验收 completeness 由 validation 计算。
+- 验收 P0-P6 回归通过。
+
+## 现有证据
+
+- 当前默认全量测试是 `140 passed, 1 warning`。
+- 当前 schema registry pass。
+- 当前真实 DB validator 是 16/16 ready。
+- 当前 `content_agent_publish_jobs` 表存在但主链路不写。
+- 当前作者资产两表不存在。
+- 当前 final output `author_assets=[]`。
+- 当前 final output `summary.run_path_complete=True` 写死。
+- 当前旧 action / 旧策略名仍有活跃残留。
+
+sub-agent 交叉验证结论:
+
+- 代码侧确认:P7H 必须覆盖 result source lookup、DB writer、validation 和 run service。
+- 文档 / JSON 侧确认:P7H 必须覆盖旧 action / 旧策略名漂移。
+- DB 侧确认:P7C 后真实 DB validator 必须升级到 18/18。
+- 测试侧确认:默认测试不访问真实外部服务,真实 DB 只在显式 validator 命令里访问。
+
+## 修改范围
+
+后续执行 P7H 时允许新增 / 更新:
+
+- `tests/test_p7_final_output.py`
+- `tests/test_p7_publish_jobs.py`
+- `tests/test_p7_author_assets.py`
+- `tests/test_p7_lineage_validation.py`
+- `tests/test_p7_policy_walk_versions.py`
+- `tests/test_p7_drift_guards.py`
+- `tests/test_runtime_files.py`
+- `tests/test_database_runtime.py`
+- `tests/test_v1_graph.py`
+- `tests/test_api.py`
+- `tests/test_p0d_p0g.py`
+
+## 不修改范围
+
+- 不降低 P0-P6 断言。
+- 不把真实 DB validator 放进默认 pytest。
+- 不让默认测试依赖真实 OpenRouter、Crawapi、AIGC、分类树、真实发布接口。
+- 不把 drift guard 写成只能人工看的文档。
+
+## 涉及文件 / 函数 / 类
+
+测试对象:
+
+- `result_source_lookup.run()`
+- `walk_strategy.run()`
+- `run_record.run()`
+- `validation.validate_run()`
+- `DatabaseRuntimeStore`
+- `CompositeRuntimeStore`
+- `RunService.start_run()`
+- `RunService._policy_run_record_from_state()`
+- `LocalRuntimeFileStore`
+- P7 DB writer methods
+- P7 drift guard tests
+
+必验 DB 表:
+
+- `content_agent_final_outputs`
+- `content_agent_publish_jobs`
+- `content_agent_author_assets`
+- `content_agent_author_asset_roles`
+- `content_agent_source_path_records`
+- `content_agent_rule_decisions`
+- `content_agent_walk_actions`
+
+## 数据合同
+
+P7 完成态:
+
+- SQL / schema registry / DB validator 为 18 张 `content_agent_*` 表。
+- runtime file list 仍是 13 个文件;P7 不新增 publish job runtime 文件。
+- `final_output.json` 与 `content_agent_final_outputs.final_output` 同结构。
+- `content_agent_publish_jobs` 对入池内容有 DB-only 任务记录。
+- 作者资产主事实在两张 DB 表,final output 只放摘要。
+- `search_clues` 仍是运行线索,不出现 SearchClueAsset。
+- `run_path_complete / trace_complete` 可被 validation 证伪。
+
+## 开发顺序
+
+1. 跑 P7A-P7G 单测。
+2. 跑 P0-P6 回归。
+3. 跑 mock full run,验证 runtime 文件和 final output。
+4. 跑 fake DB runtime,验证 final outputs、publish jobs、author assets 写入 SQL。
+5. 跑 schema registry。
+6. 跑真实 DB validator;P7C 后应为 18/18。
+7. 跑 drift guard。
+8. 输出验证记录:命令、结果、失败归因、是否可进入 P8。
+
+## 验证命令
+
+```bash
+uv run pytest tests/test_p7_final_output.py tests/test_p7_publish_jobs.py tests/test_p7_author_assets.py tests/test_p7_lineage_validation.py tests/test_p7_policy_walk_versions.py tests/test_p7_drift_guards.py -q
+uv run pytest tests/test_runtime_files.py tests/test_database_runtime.py tests/test_v1_graph.py -q
+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 -q
+rg -n "HOLD_CONTENT_PENDING|weak_effective|search_query_effect_status\\s*[=:]\\s*[\"']blocked[\"']" content_agent tests sql product_documents tech_documents
+rg -n "douyin_available_walk_strategy(_v1)?|douyin_available_walk_strategy\\.v1\\.json" content_agent tests sql product_documents tech_documents
+rg -n "demand_find_author|similar_author|co_author|相似作者|共创作者" content_agent tests
+rg -n "SearchClueAsset|search_clue_asset|clue_asset|promote.*search_clue" content_agent tests product_documents tech_documents
+rg -n "run_path_complete\\s*[:=]\\s*True|trace_complete\\s*[:=]\\s*True" content_agent tests
+rg -n "decision_to_asset" content_agent tests product_documents/抖音游走策略/douyin_walk_strategy.v1.json tech_documents
+```
+
+## 失败归因
+
+- P7 专用测试缺失:P7H 未落。
+- DB validator 仍期望 16 表:P7C validator 未升级。
+- DB validator 18 表失败:DDL / registry / validator / 真实 DB 不一致。
+- publish job fake DB 无 insert:P7B 未落。
+- 默认测试访问真实发布接口:P7B / P7H drift guard 失败。
+- 作者资产两表无写入:P7C runtime writer 未落。
+- `decision_to_asset` 缺失仍 pass:P7D validation 未落。
+- final output 版本未分组:P7E 未落。
+- 旧 action / 旧策略名仍命中 current runtime:P7F 未落。
+- completeness 仍写死:P7G 未落。
+
+## sub-agent 交叉验证要点
+
+- 代码侧确认:P7A-P7G 覆盖所有执行路径。
+- 文档 / JSON 侧确认:旧 action / 旧策略名不再作为当前 runtime 合同。
+- DB 侧确认:18 表、唯一键、真实 DB validator 全部通过。
+- 测试侧确认:默认测试不访问真实外部服务。
+- 漂移侧确认:不写旧作者库、不做 SearchClueAsset、不真实发布。

+ 36 - 0
tech_documents/工程落地/implementation_briefs/P7/P7_completion_evidence.md

@@ -0,0 +1,36 @@
+# P7 Completion Evidence
+
+状态:P7 已完成实现与验收。本文件用于给 P8 开工前提供 P7 checkpoint 事实,避免继续引用 P7 implementation brief 中的开工前基线。
+
+## 完成范围
+
+- `final_output.json` 保留为本地最小交付视图,并写入 `content_agent_final_outputs`。
+- `KEEP_CONTENT_FOR_REVIEW` 进入 `review_records`,不进入正式 `content_assets`。
+- `content_agent_publish_jobs` 只生成 DB-only 任务记录,不调用真实发布接口。
+- 新增 `content_agent_author_assets` 和 `content_agent_author_asset_roles`,作者资产主事实进入 DB。
+- 正式内容资产必须能通过 `decision_to_asset` 反查 `RuleDecision -> ContentAsset`。
+- `final_output` 版本信息拆为 `policy` 和 `walk_strategy`。
+- `trace_complete`、`run_path_complete`、`validation_status` 由 validation 结果计算,不写死。
+- 当前 contract 范围内已清理 `HOLD_CONTENT_PENDING`、`weak_effective`、旧 blocked 状态和 `douyin_available_walk_strategy_v1`。
+
+## 当前事实
+
+- SQL / schema registry / 真实 DB validator 当前口径为 18 张 `content_agent_*` 表。
+- runtime 文件当前口径为 13 个文件;P7 不新增 `publish_jobs.jsonl`。
+- `SearchClueAsset` promotion 不属于 P7;P7 只保留运行线索 `search_clues`。
+- 旧 `demand_find_author` 不作为新版主事实表;只允许作为未来 `legacy_import` 迁移来源。
+
+## 验证记录
+
+- P7 专项测试:`15 passed`。
+- P7 相关扩展测试集:`64 passed, 1 warning`。
+- 全量测试:`159 passed, 1 warning`。
+- `uv run python scripts/validate_schema_registry.py`:pass。
+- `uv run --with pymysql python scripts/validate_content_agent_db.py --env-file .env`:18/18 ready,`schema_ready=true`。
+- `uv run python scripts/count_schema_registry.py`:`18 tables / 304 SQL columns / 56 JSON columns / 22 unique indexes / 32 secondary indexes / 10 business modules / 13 runtime files`。
+- drift guards 在当前 contract 根目录 `content_agent tests sql product_documents` 下零命中。
+
+## P8 前边界
+
+- P8 不需要重新解决 P7 的 final output、publish job DB-only、作者资产 DB、`decision_to_asset`、版本拆分和 completeness 计算。
+- P8 可以开始讨论策略学习和 SearchClueAsset promotion,但 promotion 需要单独拍板资产表、复用条件和跨 run 合并规则。

+ 8 - 7
tech_documents/数据库字段总览/00_数据库字段总览.md

@@ -25,13 +25,13 @@
 
 | 项 | 数量 |
 |---|---:|
-| 数据库表 | 15 |
-| SQL 字段 | 244 |
-| JSON 字段 | 50 |
-| 唯一键 | 18 |
-| 二级索引 | 29 |
+| 数据库表 | 18 |
+| SQL 字段 | 304 |
+| JSON 字段 | 56 |
+| 唯一键 | 22 |
+| 二级索引 | 32 |
 | 业务模块 | 10 |
-| runtime 文件 | 11 |
+| runtime 文件 | 13 |
 
 ## 使用方式
 
@@ -61,4 +61,5 @@
 - `platform_content_id` 是平台内容 ID,不能覆盖上游 `source_post_id`、`matched_post_ids`、`video_ids`。
 - `platform_raw_payload` 当前只是平台最小原始字段留痕,不是完整 Crawapi 原始响应。
 - `raw_payload` 是行级原始快照,用于复盘和未来字段晋升,不是普通业务字段。
-- `content_agent_publish_jobs` 和 `content_agent_pattern_recall_evidence` 当前是表结构预留,不代表 V1 主链路已经写入。
+- `content_agent_publish_jobs` 在 P7 由结果沉淀链路写 DB-only 任务记录,不自动调用真实发布接口。
+- `content_agent_pattern_recall_evidence` 已纳入 Pattern 回扣证据链路。

+ 7 - 5
tech_documents/数据库字段总览/02_按数据库表查看.md

@@ -16,17 +16,19 @@
 | `content_agent_run_events` | 运行记录模块 | 13 | 保存运行事件。 |
 | `content_agent_final_outputs` | 结果沉淀与来源反查模块 | 10 | 保存最终输出快照。 |
 | `content_agent_publish_jobs` | 结果沉淀与来源反查模块 | 17 | 保存发布或下游计划任务,V1 不自动调用发布接口。 |
+| `content_agent_author_assets` | 结果沉淀与来源反查模块 | 24 | 保存新版作者资产主事实。 |
+| `content_agent_author_asset_roles` | 结果沉淀与来源反查模块 | 11 | 保存作者资产多角色身份。 |
 | `content_agent_pattern_recall_evidence` | 发现内容与证据模块 | 18 | 保存 Pattern 回扣证据,当前为预留能力。 |
 | `content_agent_strategy_reviews` | 策略学习模块 | 14 | 保存策略复盘结果。 |
 | `content_agent_policy_runs` | 策略版本管理模块 | 22 | 保存策略执行版本和来源。 |
 
 ## 强校验要求
 
-- 表数量必须是 15
-- SQL 字段数量必须是 244。
-- JSON 字段数量必须是 50
-- 唯一键数量必须是 18
-- 二级索引数量必须是 29
+- 表数量必须是 18
+- SQL 字段数量必须是 304。
+- JSON 字段数量必须是 56
+- 唯一键数量必须是 22
+- 二级索引数量必须是 32。
 - Registry 里的字段名、字段类型、是否可空、默认值必须和 SQL 一致。
 
 校验命令:

Разница между файлами не показана из-за своего большого размера
+ 1019 - 6
tech_documents/数据库字段总览/content_agent_schema_registry.json


+ 27 - 6
tech_documents/数据库字段总览/content_agent_schema_registry_count_report.json

@@ -1,9 +1,9 @@
 {
   "summary": {
-    "table_count": 16,
-    "sql_column_count": 269,
-    "json_column_count": 51,
-    "unique_index_count": 19,
+    "table_count": 18,
+    "sql_column_count": 304,
+    "json_column_count": 56,
+    "unique_index_count": 22,
     "secondary_index_count": 32,
     "business_module_count": 10,
     "runtime_file_count": 13
@@ -150,6 +150,27 @@
         "response_payload"
       ]
     },
+    "content_agent_author_assets": {
+      "column_count": 24,
+      "json_column_count": 4,
+      "unique_index_count": 2,
+      "secondary_index_count": 0,
+      "json_columns": [
+        "content_tags",
+        "profile_snapshot",
+        "evidence_refs",
+        "raw_payload"
+      ]
+    },
+    "content_agent_author_asset_roles": {
+      "column_count": 11,
+      "json_column_count": 1,
+      "unique_index_count": 1,
+      "secondary_index_count": 0,
+      "json_columns": [
+        "raw_payload"
+      ]
+    },
     "content_agent_pattern_recall_evidence": {
       "column_count": 18,
       "json_column_count": 7,
@@ -222,9 +243,9 @@
     "learning_review"
   ],
   "registry_summary": {
-    "table_count": 16,
+    "table_count": 18,
     "business_module_count": 10,
     "runtime_file_count": 13,
-    "field_reference_count": 269
+    "field_reference_count": 304
   }
 }

+ 6 - 4
tech_documents/数据库字段总览/content_agent_schema_registry_count_report.md

@@ -1,9 +1,9 @@
 # Schema Registry 计数报告
 
-- 数据库表数量:`16`
-- SQL 字段数量:`269`
-- JSON 字段数量:`51`
-- 唯一键数量:`19`
+- 数据库表数量:`18`
+- SQL 字段数量:`304`
+- JSON 字段数量:`56`
+- 唯一键数量:`22`
 - 二级索引数量:`32`
 - 业务模块数量:`10`
 - Runtime 文件数量:`13`
@@ -25,6 +25,8 @@
 | `content_agent_run_events` | 13 | 1 | 1 | 2 |
 | `content_agent_final_outputs` | 10 | 2 | 1 | 1 |
 | `content_agent_publish_jobs` | 17 | 2 | 1 | 2 |
+| `content_agent_author_assets` | 24 | 4 | 2 | 0 |
+| `content_agent_author_asset_roles` | 11 | 1 | 1 | 0 |
 | `content_agent_pattern_recall_evidence` | 18 | 7 | 1 | 2 |
 | `content_agent_strategy_reviews` | 14 | 7 | 1 | 1 |
 | `content_agent_policy_runs` | 22 | 5 | 1 | 3 |

+ 126 - 0
tests/test_database_runtime.py

@@ -385,6 +385,132 @@ def test_database_runtime_preserves_p5_search_clue_aggregation_in_raw_payload():
     assert json.loads(values["raw_payload"])["query_aggregation_id"] == "agg_query_rule_blocked"
 
 
+def test_database_runtime_writes_publish_jobs_db_only_records():
+    connection = FakeConnection()
+    store = DatabaseRuntimeStore(_config(), connection_factory=lambda: connection)
+
+    store.write_publish_jobs(
+        "run_001",
+        "policy_run_001",
+        [
+            {
+                "publish_job_id": "publish_job_001",
+                "platform_content_id": "7390000000000000000",
+                "job_status": "created",
+                "trigger_mode": "manual_review",
+                "request_payload": {
+                    "decision_id": "decision_001",
+                    "source_path_record_ids": ["path_001"],
+                },
+                "response_payload": {},
+            }
+        ],
+    )
+
+    sql, params = connection.statements[-1]
+    values = _insert_values(sql, params)
+    assert "INSERT INTO `content_agent_publish_jobs`" in sql
+    assert "ON DUPLICATE KEY UPDATE" in sql
+    assert values["schema_version"] == "content_agent.v1"
+    assert values["run_id"] == "run_001"
+    assert values["policy_run_id"] == "policy_run_001"
+    assert values["publish_job_id"] == "publish_job_001"
+    assert values["job_status"] == "created"
+    assert values["trigger_mode"] == "manual_review"
+    assert json.loads(values["request_payload"])["decision_id"] == "decision_001"
+
+
+def test_database_runtime_writes_author_assets():
+    connection = FakeConnection()
+    store = DatabaseRuntimeStore(_config(), connection_factory=lambda: connection)
+
+    store.write_author_assets(
+        [
+            {
+                "author_asset_id": "author_asset_001",
+                "platform": "douyin",
+                "platform_author_id": "author_001",
+                "author_display_name": "作者一号",
+                "asset_status": "active",
+                "source_type": "runtime_author_work",
+                "validation_status": "validated",
+                "eligible_as_source": 1,
+                "content_tags": ["人物故事"],
+                "source_run_id": "run_001",
+                "source_policy_run_id": "policy_run_001",
+                "profile_snapshot": {"sample_count": 9},
+                "evidence_refs": {"decision_ids": ["d_001"]},
+                "raw_payload": {"author_asset_id": "author_asset_001"},
+            }
+        ]
+    )
+
+    sql, params = connection.statements[-1]
+    values = _insert_values(sql, params)
+    assert "INSERT INTO `content_agent_author_assets`" in sql
+    assert "ON DUPLICATE KEY UPDATE" in sql
+    assert values["schema_version"] == "content_agent.v1"
+    assert values["author_asset_id"] == "author_asset_001"
+    assert values["platform_author_id"] == "author_001"
+    assert values["eligible_as_source"] == 1
+    assert json.loads(values["content_tags"]) == ["人物故事"]
+    assert json.loads(values["profile_snapshot"])["sample_count"] == 9
+    assert json.loads(values["evidence_refs"])["decision_ids"] == ["d_001"]
+
+
+def test_database_runtime_writes_author_asset_roles():
+    connection = FakeConnection()
+    store = DatabaseRuntimeStore(_config(), connection_factory=lambda: connection)
+
+    store.write_author_asset_roles(
+        [
+            {
+                "author_asset_id": "author_asset_001",
+                "role": "source_seed",
+                "role_status": "active",
+                "role_reason_code": "p7_author_asset_eligible",
+                "assigned_by": "system",
+                "source_run_id": "run_001",
+                "raw_payload": {"role": "source_seed"},
+            }
+        ]
+    )
+
+    sql, params = connection.statements[-1]
+    values = _insert_values(sql, params)
+    assert "INSERT INTO `content_agent_author_asset_roles`" in sql
+    assert "ON DUPLICATE KEY UPDATE" in sql
+    assert values["schema_version"] == "content_agent.v1"
+    assert values["author_asset_id"] == "author_asset_001"
+    assert values["role"] == "source_seed"
+    assert values["assigned_by"] == "system"
+    assert json.loads(values["raw_payload"])["role"] == "source_seed"
+
+
+def test_database_runtime_update_final_output_upserts_validation_status():
+    connection = FakeConnection()
+    store = DatabaseRuntimeStore(_config(), connection_factory=lambda: connection)
+
+    store.update_json(
+        "run_001",
+        "final_output.json",
+        {
+            "schema_version": "runtime_record.v1",
+            "run_id": "run_001",
+            "policy_run_id": "policy_run_001",
+            "summary": {"run_path_complete": True},
+            "validation_status": "pass",
+        },
+    )
+
+    sql, params = connection.statements[-1]
+    values = _insert_values(sql, params)
+    assert "INSERT INTO `content_agent_final_outputs`" in sql
+    assert "ON DUPLICATE KEY UPDATE" in sql
+    assert values["validation_status"] == "pass"
+    assert json.loads(values["summary"])["run_path_complete"] is True
+
+
 def test_database_runtime_read_jsonl_reconstructs_runtime_payload():
     connection = FakeConnection()
     connection.select_all_result = [

+ 26 - 0
tests/test_p0d_p0g.py

@@ -337,6 +337,9 @@ class _SpyRuntimeStore:
         self.run_updates: list[dict[str, Any]] = []
         self.policy_runs: list[dict[str, Any]] = []
         self.lifecycle_events: list[dict[str, Any]] = []
+        self.publish_jobs: list[dict[str, Any]] = []
+        self.author_assets: list[dict[str, Any]] = []
+        self.author_asset_roles: list[dict[str, Any]] = []
 
     def prepare_run(self, run_id: str) -> Path:
         return self.local.prepare_run(run_id)
@@ -347,6 +350,9 @@ class _SpyRuntimeStore:
     def write_json(self, run_id: str, filename: str, data: dict[str, Any]) -> Path:
         return self.local.write_json(run_id, filename, data)
 
+    def update_json(self, run_id: str, filename: str, data: dict[str, Any]) -> Path:
+        return self.local.update_json(run_id, filename, data)
+
     def append_jsonl(self, run_id: str, filename: str, rows: list[dict[str, Any]]) -> Path:
         return self.local.append_jsonl(run_id, filename, rows)
 
@@ -376,6 +382,20 @@ class _SpyRuntimeStore:
     ) -> None:
         self.lifecycle_events.extend(dict(row) for row in rows)
 
+    def write_publish_jobs(
+        self,
+        run_id: str,
+        policy_run_id: str,
+        rows: list[dict[str, Any]],
+    ) -> None:
+        self.publish_jobs.extend(dict(row) for row in rows)
+
+    def write_author_assets(self, rows: list[dict[str, Any]]) -> None:
+        self.author_assets.extend(dict(row) for row in rows)
+
+    def write_author_asset_roles(self, rows: list[dict[str, Any]]) -> None:
+        self.author_asset_roles.extend(dict(row) for row in rows)
+
 
 class _FakeRuntimeStore(_SpyRuntimeStore):
     def __init__(self, run_dir: Path | None = None, fail_writes: bool = False) -> None:
@@ -389,6 +409,12 @@ class _FakeRuntimeStore(_SpyRuntimeStore):
             raise RuntimeError("primary failed")
         return self.run_dir(run_id) / filename
 
+    def update_json(self, run_id: str, filename: str, data: dict[str, Any]) -> Path:
+        self.calls.append(("update_json", filename))
+        if self.fail_writes:
+            raise RuntimeError("primary failed")
+        return self.run_dir(run_id) / filename
+
     def append_jsonl(self, run_id: str, filename: str, rows: list[dict[str, Any]]) -> Path:
         self.calls.append(("append_jsonl", filename))
         if self.fail_writes:

+ 172 - 0
tests/test_p7_author_assets.py

@@ -0,0 +1,172 @@
+from content_agent.business_modules import result_source_lookup
+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
+
+
+class CapturingRuntimeStore(LocalRuntimeFileStore):
+    def __init__(self, base_dir):
+        super().__init__(base_dir)
+        self.author_assets = []
+        self.author_asset_roles = []
+        self.publish_jobs = []
+
+    def write_author_assets(self, rows):
+        self.author_assets.extend(rows)
+
+    def write_author_asset_roles(self, rows):
+        self.author_asset_roles.extend(rows)
+
+    def write_publish_jobs(self, run_id, policy_run_id, rows):
+        self.publish_jobs.extend(rows)
+
+
+def test_author_assets_are_written_only_when_sampling_rules_pass(tmp_path):
+    runtime = CapturingRuntimeStore(tmp_path / "runtime")
+    runtime.prepare_run("run_001")
+
+    final_output = result_source_lookup.run(
+        "run_001",
+        "policy_run_001",
+        _policy_bundle(),
+        _items(),
+        _media_records(),
+        _decisions(),
+        _source_paths(),
+        [],
+        runtime,
+    )
+
+    assert len(final_output["author_assets"]) == 1
+    author_asset = final_output["author_assets"][0]
+    assert author_asset["platform_author_id"] == "author_001"
+    assert author_asset["asset_status"] == "active"
+    assert author_asset["eligible_as_source"] is True
+    assert author_asset["roles"] == ["author_asset", "source_seed", "high_50plus_profile"]
+    assert len(author_asset["decision_ids"]) == 9
+    assert runtime.author_assets[0]["source_type"] == "new_discovery"
+    assert runtime.author_assets[0]["validation_status"] == "rule_validated"
+    assert {row["role"] for row in runtime.author_asset_roles} == {
+        "author_asset",
+        "source_seed",
+        "high_50plus_profile",
+    }
+
+
+def test_mock_full_run_does_not_create_author_asset_without_enough_samples(tmp_path):
+    runtime = CapturingRuntimeStore(tmp_path / "runtime" / "v1")
+    service = RunService(
+        runtime=runtime,
+        query_variant_client=FakeQueryVariantClient(),
+    )
+
+    state = service.start_run(
+        RunStartRequest(platform_mode="mock", source=str(REAL_SOURCE_FIXTURE))
+    )
+
+    final_output = service.read_json(state["run_id"], "final_output.json")
+    assert final_output["author_assets"] == []
+    assert runtime.author_assets == []
+    assert runtime.author_asset_roles == []
+
+
+def _items():
+    return [
+        {
+            "platform": "douyin",
+            "platform_content_id": f"content_{index:03d}",
+            "content_discovery_id": f"discovery_{index:03d}",
+            "search_query_id": "q_001",
+            "platform_author_id": "author_001",
+            "author_display_name": "作者一号",
+            "tags": ["人物故事"],
+            "previous_discovery_step": "author_work",
+            "discovery_start_source": "pattern_itemset",
+        }
+        for index in range(1, 10)
+    ]
+
+
+def _media_records():
+    return [
+        {
+            "platform_content_id": f"content_{index:03d}",
+            "content_media_status": "metadata_only",
+        }
+        for index in range(1, 10)
+    ]
+
+
+def _decisions():
+    decisions = []
+    for index in range(1, 10):
+        action = "ADD_TO_CONTENT_POOL" if index <= 3 else "KEEP_CONTENT_FOR_REVIEW"
+        decisions.append(
+            {
+                "decision_id": f"d_{index:03d}",
+                "decision_target_id": f"content_{index:03d}",
+                "decision_action": action,
+                "decision_reason_code": "score_pass" if index <= 3 else "review_needed",
+                "rule_pack_id": "rule_pack_v1",
+                "rule_pack_version": "1.0.0",
+                "strategy_version": "V1",
+                "search_query_effect_status": "success" if index <= 3 else "pending",
+                "source_evidence": {"source_kind": "pattern_itemset"},
+                "age_50_plus_level": "medium",
+            }
+        )
+    return decisions
+
+
+def _source_paths():
+    paths = [
+        {
+            "source_path_record_id": "path_pattern_q_001",
+            "source_path_type": "pattern_to_search_query",
+            "from_node_type": "PatternSeed",
+            "from_node_id": 581,
+            "to_node_type": "SearchQuery",
+            "to_node_id": "q_001",
+        }
+    ]
+    for index in range(1, 10):
+        content_id = f"content_{index:03d}"
+        decision_id = f"d_{index:03d}"
+        paths.append(
+            {
+                "source_path_record_id": f"path_query_content_{index:03d}",
+                "source_path_type": "search_query_to_content",
+                "from_node_type": "SearchQuery",
+                "from_node_id": "q_001",
+                "to_node_type": "Content",
+                "to_node_id": content_id,
+                "decision_id": decision_id,
+            }
+        )
+        if index <= 3:
+            paths.append(
+                {
+                    "source_path_record_id": f"path_decision_asset_{index:03d}",
+                    "source_path_type": "decision_to_asset",
+                    "from_node_type": "RuleDecision",
+                    "from_node_id": decision_id,
+                    "to_node_type": "ContentAsset",
+                    "to_node_id": content_id,
+                    "decision_id": decision_id,
+                }
+            )
+    return paths
+
+
+def _policy_bundle():
+    return {
+        "policy_bundle_id": "douyin_policy_bundle_v1",
+        "strategy_id": "douyin_content_find_v1",
+        "strategy_version": "V1",
+        "rule_pack_id": "rule_pack_v1",
+        "rule_pack_version": "1.0.0",
+        "policy_bundle_hash": "hash_001",
+        "strategy_source_ref": {"file": "rule_pack.json"},
+        "rule_pack_source_ref": {"file": "rule_pack.json"},
+    }

+ 66 - 0
tests/test_p7_drift_guards.py

@@ -0,0 +1,66 @@
+from pathlib import Path
+
+from content_agent.run_service import RunService
+from content_agent.schemas import RunStartRequest
+from tests.p1_helpers import FakeQueryVariantClient, REAL_SOURCE_FIXTURE
+
+
+CURRENT_CONTRACT_ROOTS = [
+    Path("content_agent"),
+    Path("tests"),
+    Path("sql"),
+    Path("product_documents"),
+]
+
+
+def test_current_contract_has_no_deprecated_content_action_or_old_status():
+    text = _joined_text(CURRENT_CONTRACT_ROOTS)
+
+    assert "HOLD_CONTENT_" + "PENDING" not in text
+    assert "weak_" + "effective" not in text
+    assert "search_query_effect_status=" + '"blocked"' not in text
+    assert "search_query_effect_status = " + '"blocked"' not in text
+
+
+def test_current_contract_has_no_old_walk_strategy_source_or_id():
+    text = _joined_text(CURRENT_CONTRACT_ROOTS)
+
+    assert "douyin_available_" + "walk_strategy_v1" not in text
+    assert "douyin_available_" + "walk_strategy.v1.json" not in text
+    old_path = Path("product_documents/抖音游走策略") / (
+        "douyin_available_" + "walk_strategy.v1.json"
+    )
+    assert not old_path.exists()
+
+
+def test_current_code_does_not_hardcode_completeness_true():
+    text = _joined_text([Path("content_agent")])
+
+    assert "run_path_complete" + ": True" not in text
+    assert "run_path_complete" + " = True" not in text
+    assert "trace_complete" + ": True" not in text
+    assert "trace_complete" + " = True" not in text
+
+
+def test_new_run_does_not_emit_old_strategy_name(tmp_path):
+    service = RunService(
+        runtime_root=tmp_path / "runtime" / "v1",
+        query_variant_client=FakeQueryVariantClient(),
+    )
+    state = service.start_run(
+        RunStartRequest(platform_mode="mock", source=str(REAL_SOURCE_FIXTURE))
+    )
+
+    final_output = service.read_json(state["run_id"], "final_output.json")
+    text = str(final_output)
+    assert "douyin_available_" + "walk_strategy_v1" not in text
+    assert final_output["walk_strategy"]["walk_strategy_id"] == "douyin_walk_strategy_v1"
+
+
+def _joined_text(roots: list[Path]) -> str:
+    chunks = []
+    for root in roots:
+        for path in root.rglob("*"):
+            if path.is_file() and path.suffix in {".py", ".sql", ".md", ".json"}:
+                chunks.append(path.read_text(encoding="utf-8"))
+    return "\n".join(chunks)

+ 69 - 0
tests/test_p7_final_output.py

@@ -0,0 +1,69 @@
+import json
+
+from content_agent.run_service import RunService
+from content_agent.schemas import RunStartRequest
+from tests.p1_helpers import FakeQueryVariantClient, REAL_SOURCE_FIXTURE
+
+
+def _start_mock_run(tmp_path):
+    service = RunService(
+        runtime_root=tmp_path / "runtime" / "v1",
+        query_variant_client=FakeQueryVariantClient(),
+    )
+    state = service.start_run(
+        RunStartRequest(platform_mode="mock", source=str(REAL_SOURCE_FIXTURE))
+    )
+    assert state["status"] == "success"
+    return service, state["run_id"]
+
+
+def test_keep_content_for_review_is_visible_but_not_pooled(tmp_path):
+    service, run_id = _start_mock_run(tmp_path)
+
+    final_output = service.read_json(run_id, "final_output.json")
+
+    content_ids = {asset["platform_content_id"] for asset in final_output["content_assets"]}
+    review_ids = {record["platform_content_id"] for record in final_output["review_records"]}
+    assert final_output["summary"]["review_content_count"] == len(final_output["review_records"])
+    assert review_ids
+    assert not review_ids & content_ids
+    assert {record["review_status"] for record in final_output["review_records"]} == {
+        "pending_review"
+    }
+    assert {record["final_asset_status"] for record in final_output["review_records"]} == {
+        "review_only"
+    }
+    assert final_output["validation_status"] == "pass"
+    assert final_output["summary"]["run_path_complete"] is True
+    assert final_output["summary"]["trace_complete"] is True
+
+
+def test_review_record_path_refs_are_validated(tmp_path):
+    service, run_id = _start_mock_run(tmp_path)
+
+    final_output_path = service.runtime.run_dir(run_id) / "final_output.json"
+    final_output = json.loads(final_output_path.read_text(encoding="utf-8"))
+    final_output["review_records"][0]["source_path_record_ids"] = ["missing_path"]
+    final_output_path.write_text(
+        json.dumps(final_output, ensure_ascii=False, indent=2) + "\n",
+        encoding="utf-8",
+    )
+
+    validation = service.validate_run(run_id)
+    assert validation["status"] == "fail"
+    assert any(finding["check_id"] == "missing_path_ref" for finding in validation["findings"])
+    assert any(
+        finding["check_id"] == "completeness_mismatch"
+        for finding in validation["findings"]
+    )
+
+
+def test_run_service_rewrites_final_output_with_final_validation_status(tmp_path):
+    service, run_id = _start_mock_run(tmp_path)
+
+    final_output = service.read_json(run_id, "final_output.json")
+    validation = service.validate_run(run_id)
+    assert validation["status"] == "pass"
+    assert final_output["validation_status"] == validation["status"]
+    assert final_output["summary"]["run_path_complete"] is True
+    assert final_output["summary"]["trace_complete"] is True

+ 98 - 0
tests/test_p7_lineage_validation.py

@@ -0,0 +1,98 @@
+import json
+
+from content_agent.run_service import RunService
+from content_agent.schemas import RunStartRequest
+from tests.p1_helpers import FakeQueryVariantClient, REAL_SOURCE_FIXTURE
+
+
+def _start_mock_run(tmp_path):
+    service = RunService(
+        runtime_root=tmp_path / "runtime" / "v1",
+        query_variant_client=FakeQueryVariantClient(),
+    )
+    state = service.start_run(
+        RunStartRequest(platform_mode="mock", source=str(REAL_SOURCE_FIXTURE))
+    )
+    assert state["status"] == "success"
+    return service, state["run_id"]
+
+
+def test_content_asset_requires_decision_to_asset_path(tmp_path):
+    service, run_id = _start_mock_run(tmp_path)
+
+    paths_path = service.runtime.run_dir(run_id) / "source_path_records.jsonl"
+    paths = [
+        json.loads(line)
+        for line in paths_path.read_text(encoding="utf-8").splitlines()
+        if line.strip()
+    ]
+    paths = [path for path in paths if path["source_path_type"] != "decision_to_asset"]
+    paths_path.write_text(
+        "".join(json.dumps(path, ensure_ascii=False, separators=(",", ":")) + "\n" for path in paths),
+        encoding="utf-8",
+    )
+
+    validation = service.validate_run(run_id)
+    assert validation["status"] == "fail"
+    assert any(
+        finding["check_id"] == "decision_to_asset_missing"
+        for finding in validation["findings"]
+    )
+
+
+def test_content_asset_must_reference_decision_to_asset_path(tmp_path):
+    service, run_id = _start_mock_run(tmp_path)
+
+    paths = service.read_jsonl(run_id, "source_path_records.jsonl")
+    final_output_path = service.runtime.run_dir(run_id) / "final_output.json"
+    final_output = json.loads(final_output_path.read_text(encoding="utf-8"))
+    asset = final_output["content_assets"][0]
+    decision_asset_path_ids = {
+        path["source_path_record_id"]
+        for path in paths
+        if path["source_path_type"] == "decision_to_asset"
+        and path["decision_id"] == asset["decision_id"]
+        and path["to_node_id"] == asset["platform_content_id"]
+    }
+    final_output["content_assets"][0]["source_path_record_ids"] = [
+        path_id
+        for path_id in final_output["content_assets"][0]["source_path_record_ids"]
+        if path_id not in decision_asset_path_ids
+    ]
+    final_output_path.write_text(
+        json.dumps(final_output, ensure_ascii=False, indent=2) + "\n",
+        encoding="utf-8",
+    )
+
+    validation = service.validate_run(run_id)
+    assert validation["status"] == "fail"
+    assert any(
+        finding["check_id"] == "decision_to_asset_missing"
+        for finding in validation["findings"]
+    )
+
+
+def test_content_asset_rejects_broken_decision_to_asset_path(tmp_path):
+    service, run_id = _start_mock_run(tmp_path)
+
+    paths_path = service.runtime.run_dir(run_id) / "source_path_records.jsonl"
+    paths = [
+        json.loads(line)
+        for line in paths_path.read_text(encoding="utf-8").splitlines()
+        if line.strip()
+    ]
+    for path in paths:
+        if path["source_path_type"] == "decision_to_asset":
+            path["from_node_id"] = "wrong_decision"
+            break
+    paths_path.write_text(
+        "".join(json.dumps(path, ensure_ascii=False, separators=(",", ":")) + "\n" for path in paths),
+        encoding="utf-8",
+    )
+
+    validation = service.validate_run(run_id)
+    assert validation["status"] == "fail"
+    assert any(
+        finding["check_id"] == "decision_to_asset_broken"
+        for finding in validation["findings"]
+    )

+ 44 - 0
tests/test_p7_policy_walk_versions.py

@@ -0,0 +1,44 @@
+from content_agent.integrations.policy_json import JsonPolicyBundleStore
+from content_agent.run_service import RunService, _policy_run_record_from_state
+from content_agent.schemas import RunStartRequest
+from tests.p1_helpers import FakeQueryVariantClient, REAL_SOURCE_FIXTURE
+
+
+def test_final_output_separates_policy_and_walk_strategy_versions(tmp_path):
+    service = RunService(
+        runtime_root=tmp_path / "runtime" / "v1",
+        query_variant_client=FakeQueryVariantClient(),
+    )
+
+    state = service.start_run(
+        RunStartRequest(platform_mode="mock", source=str(REAL_SOURCE_FIXTURE))
+    )
+
+    final_output = service.read_json(state["run_id"], "final_output.json")
+    assert final_output["policy"]["policy_bundle_id"] == "douyin_policy_bundle_v1"
+    assert final_output["policy"]["strategy_version"] == "V1"
+    assert final_output["policy"]["rule_pack_id"] == "douyin_content_discovery_rule_pack_v1"
+    assert final_output["walk_strategy"]["walk_strategy_id"] == "douyin_walk_strategy_v1"
+    assert final_output["walk_strategy"]["walk_strategy_version"] == "V1.0"
+    assert final_output["walk_strategy"]["walk_strategy_source_ref"]["file"].endswith(
+        "douyin_walk_strategy.v1.json"
+    )
+
+
+def test_policy_run_record_uses_walk_strategy_version_from_walk_config():
+    policy_bundle = JsonPolicyBundleStore().load_policy_bundle("V1")
+    record = _policy_run_record_from_state(
+        {
+            "run_id": "run_001",
+            "policy_run_id": "policy_run_001",
+            "strategy_version": "V1",
+            "policy_bundle_id": policy_bundle["policy_bundle_id"],
+            "policy_bundle": policy_bundle,
+            "rule_decisions": [],
+            "final_output": {"summary": {}},
+            "status": "success",
+        }
+    )
+
+    assert record["strategy_version"] == "V1"
+    assert record["walk_strategy_version"] == "V1.0"

+ 40 - 0
tests/test_p7_publish_jobs.py

@@ -0,0 +1,40 @@
+from content_agent.integrations.runtime_files import LocalRuntimeFileStore, RUNTIME_FILENAMES
+from content_agent.run_service import RunService
+from content_agent.schemas import RunStartRequest
+from tests.p1_helpers import FakeQueryVariantClient, REAL_SOURCE_FIXTURE
+
+
+class CapturingRuntimeStore(LocalRuntimeFileStore):
+    def __init__(self, base_dir):
+        super().__init__(base_dir)
+        self.publish_jobs = []
+
+    def write_publish_jobs(self, run_id, policy_run_id, rows):
+        self.publish_jobs.append(
+            {"run_id": run_id, "policy_run_id": policy_run_id, "rows": rows}
+        )
+
+
+def test_pooled_content_generates_db_only_publish_jobs(tmp_path):
+    runtime = CapturingRuntimeStore(tmp_path / "runtime" / "v1")
+    service = RunService(
+        runtime=runtime,
+        query_variant_client=FakeQueryVariantClient(),
+    )
+
+    state = service.start_run(
+        RunStartRequest(platform_mode="mock", source=str(REAL_SOURCE_FIXTURE))
+    )
+
+    assert state["status"] == "success"
+    final_output = service.read_json(state["run_id"], "final_output.json")
+    assert len(runtime.publish_jobs) == 1
+    rows = runtime.publish_jobs[0]["rows"]
+    assert len(rows) == len(final_output["content_assets"])
+    assert {row["job_status"] for row in rows} == {"created"}
+    assert {row["trigger_mode"] for row in rows} == {"manual_review"}
+    assert {
+        row["platform_content_id"] for row in rows
+    } == {asset["platform_content_id"] for asset in final_output["content_assets"]}
+    assert all(row["request_payload"]["content_asset"] for row in rows)
+    assert "publish_jobs.jsonl" not in RUNTIME_FILENAMES

+ 5 - 2
tests/test_runtime_files.py

@@ -74,8 +74,11 @@ def test_runtime_files_are_parseable_and_consistent(tmp_path):
     for media_record in media_records:
         assert media_record["platform"] == "douyin"
     assert final_output["policy_run_id"] == policy_run_id
-    assert final_output["policy_bundle_id"] == "douyin_policy_bundle_v1"
-    assert final_output["rule_pack_source_ref"]["file"].endswith("douyin_rule_packs.v1.json")
+    assert final_output["policy"]["policy_bundle_id"] == "douyin_policy_bundle_v1"
+    assert final_output["policy"]["rule_pack_source_ref"]["file"].endswith(
+        "douyin_rule_packs.v1.json"
+    )
+    assert final_output["walk_strategy"]["walk_strategy_version"] == "V1.0"
     assert "policy_run_id" not in source_context
 
     assert {decision["decision_action"] for decision in decisions} <= {

+ 5 - 1
tests/test_v1_graph.py

@@ -32,4 +32,8 @@ def test_v1_graph_generates_all_runtime_files(tmp_path):
         "failed": 0,
         "rule_blocked": 1,
     }
-    assert final_output["policy_bundle_hash"] == state["policy_bundle"]["policy_bundle_hash"]
+    assert (
+        final_output["policy"]["policy_bundle_hash"]
+        == state["policy_bundle"]["policy_bundle_hash"]
+    )
+    assert final_output["walk_strategy"]["walk_strategy_version"] == "V1.0"

Некоторые файлы не были показаны из-за большого количества измененных файлов