source_context.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. from __future__ import annotations
  2. import copy
  3. import json
  4. from pathlib import Path
  5. from typing import Any
  6. from content_agent.constants import RUNTIME_SCHEMA_VERSION
  7. from content_agent.errors import ContentAgentError, ErrorCode
  8. LEGACY_RUN_ID_KEY = "tr" + "ace_id"
  9. # 上游 DemandAgent V2 的证据来源类型(5 类 + multi_source)。只有 pattern_itemset
  10. # 才带 itemset 字段;其余来源 itemset_ids/itemset_items/mining_config_id 允许为空。
  11. ALLOWED_SOURCE_KINDS = {
  12. "high_weight_element",
  13. "high_weight_category",
  14. "element_co_occurrence",
  15. "category_co_occurrence",
  16. "pattern_itemset",
  17. "multi_source",
  18. }
  19. def load_source_context(run_id: str, source: str | dict[str, Any] | None) -> dict[str, Any]:
  20. if isinstance(source, dict):
  21. data = _source_context_from_demand_content(_normalize_source_row(source))
  22. elif source:
  23. path = Path(source)
  24. if not path.exists():
  25. raise ContentAgentError(
  26. ErrorCode.INVALID_SOURCE,
  27. "source file not found",
  28. {"source": source},
  29. status_code=400,
  30. )
  31. data = _load_source_payload(path)
  32. else:
  33. raise ContentAgentError(
  34. ErrorCode.INVALID_SOURCE,
  35. "source payload is required",
  36. {"selector": "source"},
  37. status_code=400,
  38. )
  39. data["run_id"] = run_id
  40. data["schema_version"] = RUNTIME_SCHEMA_VERSION
  41. data.pop(LEGACY_RUN_ID_KEY, None)
  42. data.setdefault("ext_data", {})
  43. evidence_pack = data["ext_data"].get("evidence_pack") or {}
  44. data["ext_data"]["evidence_pack"] = normalize_evidence_pack(
  45. run_id,
  46. evidence_pack,
  47. )
  48. return data
  49. def normalize_evidence_pack(
  50. run_id: str,
  51. evidence_pack: dict[str, Any],
  52. ) -> dict[str, Any]:
  53. normalized: dict[str, Any] = {}
  54. incoming = copy.deepcopy(evidence_pack)
  55. upstream_run_id = incoming.pop(LEGACY_RUN_ID_KEY, None) or incoming.get("run_id")
  56. normalized.update(incoming)
  57. if upstream_run_id and upstream_run_id != run_id:
  58. normalized["upstream_run_id"] = upstream_run_id
  59. normalized["run_id"] = run_id
  60. if not normalized.get("source_kind") and normalized.get("pattern_scope"):
  61. normalized["source_kind"] = normalized["pattern_scope"]
  62. if not normalized.get("case_id_type") and normalized.get("pattern_id_type"):
  63. normalized["case_id_type"] = normalized["pattern_id_type"]
  64. if not normalized.get("source_certainty") and normalized.get("validation_method"):
  65. normalized["source_certainty"] = normalized["validation_method"]
  66. normalized["itemset_ids"] = list(normalized.get("itemset_ids") or [])
  67. normalized["itemset_items"] = list(normalized.get("itemset_items") or [])
  68. normalized["category_bindings"] = list(normalized.get("category_bindings") or [])
  69. normalized["element_bindings"] = list(normalized.get("element_bindings") or [])
  70. normalized["matched_post_ids"] = list(normalized.get("matched_post_ids") or [])
  71. normalized["video_ids"] = list(normalized.get("video_ids") or [])
  72. normalized["case_ids"] = list(normalized.get("case_ids") or [])
  73. normalized["decode_case_ids"] = list(normalized.get("decode_case_ids") or [])
  74. normalized["seed_terms"] = list(normalized.get("seed_terms") or [])
  75. _validate_pg_evidence_pack(normalized)
  76. return normalized
  77. def build_pattern_seed_pack(
  78. run_id: str,
  79. policy_run_id: str,
  80. source_context: dict[str, Any],
  81. ) -> dict[str, Any]:
  82. evidence_pack = source_context["ext_data"]["evidence_pack"]
  83. seed_terms = _unique_terms(evidence_pack.get("seed_terms", []))
  84. return {
  85. "schema_version": RUNTIME_SCHEMA_VERSION,
  86. "run_id": run_id,
  87. "policy_run_id": policy_run_id,
  88. "pattern_source_system": evidence_pack.get("pattern_source_system"),
  89. "pattern_execution_id": evidence_pack["pattern_execution_id"],
  90. "mining_config_id": evidence_pack.get("mining_config_id"),
  91. "source_post_id": evidence_pack.get("source_post_id"),
  92. "case_id_type": evidence_pack.get("case_id_type"),
  93. "itemsets": evidence_pack.get("itemset_items", []),
  94. "support": evidence_pack.get("support"),
  95. "absolute_support": evidence_pack.get("absolute_support"),
  96. "seed_terms": seed_terms,
  97. "query_seed_points": evidence_pack.get("query_seed_points", []),
  98. "category_bindings": evidence_pack.get("category_bindings", []),
  99. "element_bindings": evidence_pack.get("element_bindings", []),
  100. "matched_post_ids": evidence_pack.get("matched_post_ids", []),
  101. "video_ids": evidence_pack.get("video_ids", []),
  102. "case_ids": evidence_pack.get("case_ids", []),
  103. "decode_case_ids": evidence_pack.get("decode_case_ids", []),
  104. "source_certainty": evidence_pack.get("source_certainty"),
  105. "validation_status": evidence_pack.get("validation_status"),
  106. }
  107. def _load_source_payload(path: Path) -> dict[str, Any]:
  108. payload = json.loads(path.read_text(encoding="utf-8"))
  109. if isinstance(payload, list):
  110. for row in payload:
  111. if not isinstance(row, dict):
  112. continue
  113. normalized = _normalize_source_row(row)
  114. if normalized.get("ext_data", {}).get("evidence_pack"):
  115. return _source_context_from_demand_content(normalized)
  116. raise ValueError(f"{path} does not contain a row with ext_data.evidence_pack")
  117. if isinstance(payload, dict):
  118. normalized = _normalize_source_row(payload)
  119. if normalized.get("ext_data", {}).get("evidence_pack"):
  120. return normalized
  121. raise ValueError(f"{path} does not contain ext_data.evidence_pack")
  122. def _source_context_from_demand_content(row: dict[str, Any]) -> dict[str, Any]:
  123. return {
  124. "run_id": row.get("run_id"),
  125. "demand_content_id": str(row.get("id") or row.get("demand_content_id") or ""),
  126. "merge_leve2": row.get("merge_leve2"),
  127. "name": row.get("name"),
  128. "suggestion": row.get("suggestion"),
  129. "score": row.get("score"),
  130. "dt": row.get("dt"),
  131. "ext_data": copy.deepcopy(row["ext_data"]),
  132. "raw_demand_content": copy.deepcopy(row.get("raw_demand_content")),
  133. }
  134. def _normalize_source_row(row: dict[str, Any]) -> dict[str, Any]:
  135. normalized = dict(row)
  136. ext_data = normalized.get("ext_data")
  137. if isinstance(ext_data, str) and ext_data.strip():
  138. normalized["ext_data"] = json.loads(ext_data)
  139. return normalized
  140. def _validate_pg_evidence_pack(evidence_pack: dict[str, Any]) -> None:
  141. expected_values = {
  142. "pattern_source_system": "pg_pattern_v2",
  143. "validation_status": "passed",
  144. }
  145. for field, expected in expected_values.items():
  146. if evidence_pack.get(field) != expected:
  147. raise ValueError(f"invalid evidence_pack.{field}: expected {expected}")
  148. alias_expected_values = {
  149. ("case_id_type", "pattern_id_type"): "post_id",
  150. ("source_certainty", "validation_method"): "db_validated",
  151. }
  152. for fields, expected in alias_expected_values.items():
  153. values = [
  154. evidence_pack.get(field)
  155. for field in fields
  156. if evidence_pack.get(field) is not None
  157. ]
  158. if not values or any(value != expected for value in values):
  159. raise ValueError(f"invalid evidence_pack.{fields[0]}: expected {expected}")
  160. # source_kind 由"写死 pattern_itemset"改为 6 值白名单(V2 多来源契约)。
  161. source_kind = evidence_pack.get("source_kind") or evidence_pack.get("pattern_scope")
  162. if source_kind not in ALLOWED_SOURCE_KINDS:
  163. raise ValueError(
  164. f"invalid evidence_pack.source_kind: expected one of {sorted(ALLOWED_SOURCE_KINDS)}"
  165. )
  166. # 地基字段:实测全部 142 条 V2 行均非空(含老批次),所有来源都给。
  167. # 注:evidence_sources 仅最新批次有(12/142),故不列必填,只在下面 has_itemset 检测里用。
  168. required_fields = [
  169. "source_post_id",
  170. "pattern_execution_id",
  171. "category_bindings",
  172. "element_bindings",
  173. "support",
  174. "absolute_support",
  175. "matched_post_ids",
  176. "video_ids",
  177. "case_ids",
  178. "seed_terms",
  179. ]
  180. for field in required_fields:
  181. value = evidence_pack.get(field)
  182. if value is None or value == "" or value == []:
  183. raise ValueError(f"missing evidence_pack.{field}")
  184. # itemset 三件套有条件必填:仅当来源是 pattern_itemset、或多来源里含 itemset 项。
  185. has_itemset = source_kind == "pattern_itemset" or any(
  186. isinstance(source, dict) and source.get("source_kind") == "pattern_itemset"
  187. for source in (evidence_pack.get("evidence_sources") or [])
  188. )
  189. if has_itemset:
  190. for field in ("itemset_ids", "itemset_items", "mining_config_id"):
  191. value = evidence_pack.get(field)
  192. if value is None or value == "" or value == []:
  193. raise ValueError(f"missing evidence_pack.{field}")
  194. if evidence_pack["source_post_id"] not in evidence_pack["matched_post_ids"]:
  195. raise ValueError("evidence_pack.source_post_id must be in matched_post_ids")
  196. def _unique_terms(terms: list[str]) -> list[str]:
  197. return list(dict.fromkeys(term for term in terms if term))