database_runtime.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173
  1. from __future__ import annotations
  2. import json
  3. import os
  4. from dataclasses import dataclass
  5. from datetime import datetime, timezone
  6. from decimal import Decimal
  7. from pathlib import Path
  8. from typing import Any, Callable
  9. import pymysql
  10. from content_agent.constants import DB_SCHEMA_VERSION, RUNTIME_RECORD_SCHEMA_VERSION
  11. ConnectionFactory = Callable[[], Any]
  12. FORBIDDEN_RAW_PAYLOAD_KEYS = {
  13. "password",
  14. "token",
  15. "access_token",
  16. "refresh_token",
  17. "api_key",
  18. "apikey",
  19. "secret",
  20. "dsn",
  21. "authorization",
  22. "cookie",
  23. "session",
  24. "credential",
  25. }
  26. RUNTIME_FILE_TABLES = {
  27. "source_context.json": "content_agent_source_contexts",
  28. "pattern_seed_pack.json": "content_agent_pattern_seed_packs",
  29. "search_queries.jsonl": "content_agent_queries",
  30. "discovered_content_items.jsonl": "content_agent_discovered_content_items",
  31. "content_media_records.jsonl": "content_agent_content_media_records",
  32. "pattern_recall_evidence.jsonl": "content_agent_pattern_recall_evidence",
  33. "rule_decisions.jsonl": "content_agent_rule_decisions",
  34. "walk_actions.jsonl": "content_agent_walk_actions",
  35. "run_events.jsonl": "content_agent_run_events",
  36. "source_path_records.jsonl": "content_agent_source_path_records",
  37. "search_clues.jsonl": "content_agent_search_clues",
  38. "final_output.json": "content_agent_final_outputs",
  39. "strategy_review.json": "content_agent_strategy_reviews",
  40. }
  41. JSON_COLUMNS_BY_TABLE = {
  42. "content_agent_source_contexts": {"evidence_pack", "source_context", "raw_demand_content"},
  43. "content_agent_pattern_seed_packs": {"itemset_ids", "seed_terms", "pattern_seed_pack"},
  44. "content_agent_queries": {"pattern_seed_ref", "raw_payload"},
  45. "content_agent_discovered_content_items": {
  46. "statistics",
  47. "tags",
  48. "text_extra",
  49. "source_evidence",
  50. "pattern_match_result",
  51. "platform_raw_payload",
  52. "raw_payload",
  53. },
  54. "content_agent_content_media_records": {"raw_payload"},
  55. "content_agent_pattern_recall_evidence": {
  56. "evidence_summary",
  57. "raw_payload",
  58. },
  59. "content_agent_rule_decisions": {
  60. "triggered_blocking_rules",
  61. "scorecard",
  62. "source_evidence",
  63. "decision_replay_data",
  64. "raw_payload",
  65. },
  66. "content_agent_walk_actions": {"raw_payload"},
  67. "content_agent_source_path_records": {"raw_payload"},
  68. "content_agent_search_clues": {"raw_payload"},
  69. "content_agent_run_events": {"raw_payload"},
  70. "content_agent_media_pipeline_tasks": {"input_payload", "result_payload", "error_summary"},
  71. "content_agent_final_outputs": {"summary", "final_output"},
  72. "content_agent_publish_jobs": {"request_payload", "response_payload"},
  73. "content_agent_author_assets": {
  74. "content_tags",
  75. "profile_snapshot",
  76. "evidence_refs",
  77. "raw_payload",
  78. },
  79. "content_agent_author_asset_roles": {"raw_payload"},
  80. "content_agent_search_clue_assets": {"summary_metrics", "raw_payload"},
  81. "content_agent_search_clue_asset_evidence": {
  82. "source_path_record_ids",
  83. "decision_ids",
  84. "performance_feedback_refs",
  85. "raw_payload",
  86. },
  87. "content_agent_strategy_reviews": {
  88. "summary",
  89. "effective_search_queries",
  90. "weak_search_queries",
  91. "top_reject_reasons",
  92. "productive_paths",
  93. "suggestions",
  94. "raw_payload",
  95. },
  96. "content_agent_performance_feedback": {"raw_payload"},
  97. "content_agent_runs": {"source_ref", "error_detail"},
  98. "content_agent_policy_runs": {
  99. "strategy_source_ref",
  100. "rule_pack_source_ref",
  101. "metrics",
  102. "decision_summary",
  103. "raw_payload",
  104. },
  105. }
  106. DATETIME_COLUMNS = {"started_at", "completed_at", "created_at", "updated_at"}
  107. JSON_FILE_PAYLOAD_COLUMNS = {
  108. "source_context.json": "source_context",
  109. "pattern_seed_pack.json": "pattern_seed_pack",
  110. "final_output.json": "final_output",
  111. "strategy_review.json": "raw_payload",
  112. }
  113. JSONL_UPSERT_KEYS = {
  114. "content_media_records.jsonl": ("run_id", "policy_run_id", "platform", "platform_content_id"),
  115. "search_queries.jsonl": ("run_id", "policy_run_id", "search_query_id"),
  116. "pattern_recall_evidence.jsonl": ("run_id", "policy_run_id", "recall_evidence_id"),
  117. "search_clues.jsonl": ("run_id", "policy_run_id", "clue_id"),
  118. "run_events.jsonl": ("run_id", "policy_run_id", "event_id"),
  119. }
  120. RULE_DECISION_RUNTIME_COLUMNS = (
  121. "run_id",
  122. "policy_run_id",
  123. "decision_id",
  124. "policy_bundle_id",
  125. "rule_pack_id",
  126. "rule_pack_version",
  127. "strategy_version",
  128. "decision_target_type",
  129. "decision_target_id",
  130. "decision_action",
  131. "decision_reason_code",
  132. "search_query_effect_status",
  133. "score",
  134. "triggered_blocking_rules",
  135. "scorecard",
  136. "source_evidence",
  137. "decision_replay_data",
  138. "raw_payload",
  139. )
  140. @dataclass(frozen=True)
  141. class ContentSupplyDbConfig:
  142. host: str
  143. port: int
  144. user: str
  145. password: str
  146. database: str
  147. timeout: int = 8
  148. @classmethod
  149. def from_env(
  150. cls,
  151. env_file: str | Path | None = ".env",
  152. ) -> "ContentSupplyDbConfig":
  153. env: dict[str, str] = _load_env_file(env_file)
  154. env.update({
  155. key: os.environ[key]
  156. for key in [
  157. "CONTENT_SUPPLY_DB_HOST",
  158. "CONTENT_SUPPLY_DB_PORT",
  159. "CONTENT_SUPPLY_DB_NAME",
  160. "CONTENT_SUPPLY_DB_USER",
  161. "CONTENT_SUPPLY_DB_PASSWORD",
  162. ]
  163. if os.environ.get(key)
  164. })
  165. required_keys = [
  166. "CONTENT_SUPPLY_DB_HOST",
  167. "CONTENT_SUPPLY_DB_PORT",
  168. "CONTENT_SUPPLY_DB_NAME",
  169. "CONTENT_SUPPLY_DB_USER",
  170. "CONTENT_SUPPLY_DB_PASSWORD",
  171. ]
  172. missing_keys = [key for key in required_keys if not env.get(key)]
  173. if missing_keys:
  174. raise ValueError(f"Missing required CFA db env keys: {', '.join(missing_keys)}")
  175. return cls(
  176. host=env["CONTENT_SUPPLY_DB_HOST"],
  177. port=int(env["CONTENT_SUPPLY_DB_PORT"]),
  178. user=env["CONTENT_SUPPLY_DB_USER"],
  179. password=env["CONTENT_SUPPLY_DB_PASSWORD"],
  180. database=env["CONTENT_SUPPLY_DB_NAME"],
  181. )
  182. def connect(self) -> Any:
  183. return pymysql.connect(
  184. host=self.host,
  185. port=self.port,
  186. user=self.user,
  187. password=self.password,
  188. database=self.database,
  189. charset="utf8mb4",
  190. cursorclass=pymysql.cursors.DictCursor,
  191. connect_timeout=self.timeout,
  192. read_timeout=self.timeout,
  193. write_timeout=self.timeout,
  194. )
  195. class DatabaseRuntimeStore:
  196. def __init__(
  197. self,
  198. config: ContentSupplyDbConfig,
  199. connection_factory: ConnectionFactory | None = None,
  200. ) -> None:
  201. self.config = config
  202. self._connection_factory = connection_factory or config.connect
  203. def prepare_run(self, run_id: str) -> Path:
  204. if self._run_has_runtime_records(run_id):
  205. raise FileExistsError(f"run already exists in database runtime tables: {run_id}")
  206. return self.run_dir(run_id)
  207. def run_dir(self, run_id: str) -> Path:
  208. return Path("database_runtime") / run_id
  209. def write_json(self, run_id: str, filename: str, data: dict[str, Any]) -> Path:
  210. table, record = _record_for_json(filename, data)
  211. if record["run_id"] != run_id:
  212. raise ValueError(f"{filename} run_id does not match runtime run_id")
  213. self._insert(table, record)
  214. return self.run_dir(run_id) / filename
  215. def update_json(self, run_id: str, filename: str, data: dict[str, Any]) -> Path:
  216. table, record = _record_for_json(filename, data)
  217. if record["run_id"] != run_id:
  218. raise ValueError(f"{filename} run_id does not match runtime run_id")
  219. if filename == "final_output.json":
  220. self._upsert(
  221. table,
  222. record,
  223. key_columns=("run_id", "policy_run_id", "output_version"),
  224. )
  225. return self.run_dir(run_id) / filename
  226. self._insert(table, record)
  227. return self.run_dir(run_id) / filename
  228. def append_jsonl(self, run_id: str, filename: str, rows: list[dict[str, Any]]) -> Path:
  229. table = _table_for_runtime_file(filename)
  230. # 整批共用一个连接、一次 commit:避免每行新建连接+commit 的 N 次网络往返。
  231. statements: list[tuple[str, list[Any]]] = []
  232. for row in rows:
  233. if row.get("run_id") != run_id:
  234. raise ValueError(f"{filename} row run_id does not match runtime run_id")
  235. record = _record_for_jsonl(filename, row)
  236. statements.append(
  237. self._row_sql(table, record, key_columns=JSONL_UPSERT_KEYS.get(filename))
  238. )
  239. if statements:
  240. with self._connection_factory() as conn:
  241. with conn.cursor() as cur:
  242. for sql, values in statements:
  243. cur.execute(sql, values)
  244. conn.commit()
  245. return self.run_dir(run_id) / filename
  246. def read_json(self, run_id: str, filename: str) -> dict[str, Any]:
  247. table = _table_for_runtime_file(filename)
  248. payload_column = JSON_FILE_PAYLOAD_COLUMNS.get(filename)
  249. if not payload_column:
  250. raise ValueError(f"{filename} is not a JSON runtime file")
  251. row = self._fetch_one(
  252. f"SELECT `{payload_column}` FROM `{table}` WHERE `run_id` = %s ORDER BY `id` DESC LIMIT 1",
  253. (run_id,),
  254. )
  255. if not row:
  256. raise FileNotFoundError(f"{filename} not found for run: {run_id}")
  257. payload = _decode_json_payload(row[payload_column]) or {}
  258. if payload_column == "raw_payload":
  259. return _runtime_payload(payload)
  260. return payload
  261. def read_jsonl(self, run_id: str, filename: str) -> list[dict[str, Any]]:
  262. table = _table_for_runtime_file(filename)
  263. if filename == "rule_decisions.jsonl":
  264. column_sql = ", ".join(f"`{column}`" for column in RULE_DECISION_RUNTIME_COLUMNS)
  265. rows = self._fetch_all(
  266. f"SELECT {column_sql} FROM `{table}` WHERE `run_id` = %s ORDER BY `id`",
  267. (run_id,),
  268. )
  269. return [_runtime_rule_decision(row) for row in rows]
  270. rows = self._fetch_all(
  271. f"SELECT `raw_payload` FROM `{table}` WHERE `run_id` = %s ORDER BY `id`",
  272. (run_id,),
  273. )
  274. return [_runtime_payload(_decode_json_payload(row["raw_payload"]) or {}) for row in rows]
  275. def read_performance_feedback(
  276. self,
  277. run_id: str,
  278. policy_run_id: str,
  279. ) -> list[dict[str, Any]]:
  280. rows = self._fetch_all(
  281. "SELECT `raw_payload` FROM `content_agent_performance_feedback` "
  282. "WHERE `run_id` = %s AND `policy_run_id` = %s ORDER BY `id`",
  283. (run_id, policy_run_id),
  284. )
  285. return [_runtime_payload(_decode_json_payload(row["raw_payload"]) or {}) for row in rows]
  286. def enqueue_media_pipeline_task(self, task: dict[str, Any]) -> dict[str, Any]:
  287. record = _with_db_schema(task)
  288. self._upsert(
  289. "content_agent_media_pipeline_tasks",
  290. record,
  291. key_columns=("idempotency_key",),
  292. )
  293. return dict(task)
  294. def update_media_pipeline_task(self, task_id: str, updates: dict[str, Any]) -> None:
  295. if not updates:
  296. return
  297. record = _sanitize_record("content_agent_media_pipeline_tasks", updates)
  298. if not record:
  299. return
  300. assignments = ", ".join(f"`{column}` = %s" for column in record)
  301. params = [
  302. _db_value("content_agent_media_pipeline_tasks", column, value)
  303. for column, value in record.items()
  304. ]
  305. params.append(task_id)
  306. with self._connection_factory() as conn:
  307. with conn.cursor() as cur:
  308. cur.execute(
  309. f"UPDATE `content_agent_media_pipeline_tasks` SET {assignments} WHERE `task_id` = %s",
  310. params,
  311. )
  312. conn.commit()
  313. def read_media_pipeline_tasks(
  314. self,
  315. run_id: str,
  316. *,
  317. batch_id: str | None = None,
  318. ) -> list[dict[str, Any]]:
  319. if batch_id is None:
  320. rows = self._fetch_all(
  321. "SELECT * FROM `content_agent_media_pipeline_tasks` WHERE `run_id` = %s ORDER BY `id`",
  322. (run_id,),
  323. )
  324. else:
  325. rows = self._fetch_all(
  326. "SELECT * FROM `content_agent_media_pipeline_tasks` WHERE `run_id` = %s AND `batch_id` = %s ORDER BY `id`",
  327. (run_id, batch_id),
  328. )
  329. return [_runtime_media_pipeline_task(row) for row in rows]
  330. def file_status(self, run_id: str) -> dict[str, bool]:
  331. return {
  332. filename: self._runtime_file_exists(run_id, filename)
  333. for filename in RUNTIME_FILE_TABLES
  334. }
  335. def create_run_record(self, record: dict[str, Any]) -> None:
  336. self._insert("content_agent_runs", _run_record(record))
  337. def update_run_record(self, run_id: str, updates: dict[str, Any]) -> None:
  338. if not updates:
  339. return
  340. record = _sanitize_record("content_agent_runs", updates)
  341. if not record:
  342. return
  343. assignments = ", ".join(f"`{column}` = %s" for column in record)
  344. params = [
  345. _db_value("content_agent_runs", column, value)
  346. for column, value in record.items()
  347. ]
  348. params.append(run_id)
  349. with self._connection_factory() as conn:
  350. with conn.cursor() as cur:
  351. cur.execute(
  352. f"UPDATE `content_agent_runs` SET {assignments} WHERE `run_id` = %s",
  353. params,
  354. )
  355. conn.commit()
  356. def record_policy_run(self, record: dict[str, Any]) -> None:
  357. self._insert("content_agent_policy_runs", _policy_run_record(record))
  358. def append_run_event_records(
  359. self,
  360. run_id: str,
  361. policy_run_id: str,
  362. rows: list[dict[str, Any]],
  363. ) -> None:
  364. prepared_rows = [
  365. {**row, "run_id": run_id, "policy_run_id": row.get("policy_run_id", policy_run_id)}
  366. for row in rows
  367. ]
  368. self.append_jsonl(run_id, "run_events.jsonl", prepared_rows)
  369. def write_publish_jobs(
  370. self,
  371. run_id: str,
  372. policy_run_id: str,
  373. rows: list[dict[str, Any]],
  374. ) -> None:
  375. for row in rows:
  376. record = {
  377. **row,
  378. "run_id": run_id,
  379. "policy_run_id": row.get("policy_run_id", policy_run_id),
  380. }
  381. self._upsert(
  382. "content_agent_publish_jobs",
  383. _with_db_schema(record),
  384. key_columns=("run_id", "policy_run_id", "publish_job_id"),
  385. )
  386. def write_author_assets(self, rows: list[dict[str, Any]]) -> None:
  387. for row in rows:
  388. self._upsert(
  389. "content_agent_author_assets",
  390. _with_db_schema(row),
  391. key_columns=("author_asset_id",),
  392. )
  393. def write_author_asset_roles(self, rows: list[dict[str, Any]]) -> None:
  394. for row in rows:
  395. self._upsert(
  396. "content_agent_author_asset_roles",
  397. _with_db_schema(row),
  398. key_columns=("author_asset_id", "role"),
  399. )
  400. def write_search_clue_assets(self, rows: list[dict[str, Any]]) -> None:
  401. for row in rows:
  402. self._upsert(
  403. "content_agent_search_clue_assets",
  404. _with_db_schema(row),
  405. key_columns=("search_clue_asset_id",),
  406. )
  407. def write_search_clue_asset_evidence(self, rows: list[dict[str, Any]]) -> None:
  408. for row in rows:
  409. self._upsert(
  410. "content_agent_search_clue_asset_evidence",
  411. _with_db_schema(row),
  412. key_columns=("run_id", "policy_run_id", "clue_id"),
  413. )
  414. def _row_sql(
  415. self,
  416. table: str,
  417. record: dict[str, Any],
  418. key_columns: tuple[str, ...] | None = None,
  419. ) -> tuple[str, list[Any]]:
  420. sanitized = _sanitize_record(table, record)
  421. columns = list(sanitized)
  422. placeholders = ", ".join(["%s"] * len(columns))
  423. column_sql = ", ".join(f"`{column}`" for column in columns)
  424. values = [
  425. _db_value(table, column, sanitized[column])
  426. for column in columns
  427. ]
  428. sql = f"INSERT INTO `{table}` ({column_sql}) VALUES ({placeholders})"
  429. if key_columns:
  430. update_columns = [column for column in columns if column not in key_columns]
  431. assignments = ", ".join(f"`{column}` = VALUES(`{column}`)" for column in update_columns)
  432. if assignments:
  433. sql += f" ON DUPLICATE KEY UPDATE {assignments}"
  434. return sql, values
  435. def _insert(self, table: str, record: dict[str, Any]) -> None:
  436. sql, values = self._row_sql(table, record)
  437. with self._connection_factory() as conn:
  438. with conn.cursor() as cur:
  439. cur.execute(sql, values)
  440. conn.commit()
  441. def _upsert(
  442. self,
  443. table: str,
  444. record: dict[str, Any],
  445. key_columns: tuple[str, ...],
  446. ) -> None:
  447. sql, values = self._row_sql(table, record, key_columns=key_columns)
  448. with self._connection_factory() as conn:
  449. with conn.cursor() as cur:
  450. cur.execute(sql, values)
  451. conn.commit()
  452. def _fetch_one(self, sql: str, params: tuple[Any, ...]) -> dict[str, Any] | None:
  453. with self._connection_factory() as conn:
  454. with conn.cursor() as cur:
  455. cur.execute(sql, params)
  456. return cur.fetchone()
  457. def _fetch_all(self, sql: str, params: tuple[Any, ...]) -> list[dict[str, Any]]:
  458. with self._connection_factory() as conn:
  459. with conn.cursor() as cur:
  460. cur.execute(sql, params)
  461. return list(cur.fetchall())
  462. def _run_has_runtime_records(self, run_id: str) -> bool:
  463. tables = [
  464. "content_agent_runs",
  465. "content_agent_policy_runs",
  466. *RUNTIME_FILE_TABLES.values(),
  467. ]
  468. for table in tables:
  469. row = self._fetch_one(
  470. f"SELECT COUNT(*) AS cnt FROM `{table}` WHERE `run_id` = %s",
  471. (run_id,),
  472. )
  473. if row and row.get("cnt", 0):
  474. return True
  475. return False
  476. def _runtime_file_exists(self, run_id: str, filename: str) -> bool:
  477. table = _table_for_runtime_file(filename)
  478. row = self._fetch_one(
  479. f"SELECT COUNT(*) AS cnt FROM `{table}` WHERE `run_id` = %s",
  480. (run_id,),
  481. )
  482. return bool(row and row.get("cnt", 0))
  483. def _record_for_json(filename: str, data: dict[str, Any]) -> tuple[str, dict[str, Any]]:
  484. table = _table_for_runtime_file(filename)
  485. if filename == "source_context.json":
  486. evidence_pack = data.get("ext_data", {}).get("evidence_pack") or {}
  487. return table, {
  488. "schema_version": DB_SCHEMA_VERSION,
  489. "run_id": data["run_id"],
  490. "demand_content_id": _int_or_none(data.get("demand_content_id")),
  491. "pattern_source_system": evidence_pack.get("pattern_source_system"),
  492. "source_kind": evidence_pack.get("source_kind"),
  493. "source_post_id": evidence_pack.get("source_post_id"),
  494. "pattern_execution_id": evidence_pack.get("pattern_execution_id"),
  495. "mining_config_id": evidence_pack.get("mining_config_id"),
  496. "evidence_pack": evidence_pack,
  497. "source_context": data,
  498. "raw_demand_content": data.get("raw_demand_content"),
  499. }
  500. if filename == "pattern_seed_pack.json":
  501. return table, {
  502. "schema_version": DB_SCHEMA_VERSION,
  503. "run_id": data["run_id"],
  504. "policy_run_id": data["policy_run_id"],
  505. "source_post_id": data.get("source_post_id"),
  506. "pattern_execution_id": data.get("pattern_execution_id"),
  507. "itemset_ids": _itemset_ids_from_seed_pack(data),
  508. "seed_terms": data.get("seed_terms"),
  509. "pattern_seed_pack": data,
  510. }
  511. if filename == "final_output.json":
  512. return table, {
  513. "schema_version": DB_SCHEMA_VERSION,
  514. "run_id": data["run_id"],
  515. "policy_run_id": data["policy_run_id"],
  516. "output_version": data.get("output_version", "v1"),
  517. "summary": data.get("summary"),
  518. "final_output": data,
  519. "validation_status": data.get("validation_status"),
  520. }
  521. if filename == "strategy_review.json":
  522. return table, {
  523. "schema_version": DB_SCHEMA_VERSION,
  524. "run_id": data["run_id"],
  525. "policy_run_id": data["policy_run_id"],
  526. "review_id": data["review_id"],
  527. "review_status": data.get("review_status", "generated"),
  528. "summary": data.get("summary"),
  529. "effective_search_queries": data.get("effective_search_queries"),
  530. "weak_search_queries": data.get("weak_search_queries"),
  531. "top_reject_reasons": data.get("top_reject_reasons"),
  532. "productive_paths": data.get("productive_paths"),
  533. "suggestions": data.get("suggestions"),
  534. "raw_payload": data.get("raw_payload", data),
  535. }
  536. raise ValueError(f"unsupported JSON runtime file: {filename}")
  537. def _record_for_jsonl(filename: str, row: dict[str, Any]) -> dict[str, Any]:
  538. if filename == "search_queries.jsonl":
  539. return _with_db_schema(row)
  540. if filename == "discovered_content_items.jsonl":
  541. return _with_db_schema(row)
  542. if filename == "content_media_records.jsonl":
  543. return _with_db_schema(row)
  544. if filename == "pattern_recall_evidence.jsonl":
  545. return _with_db_schema(row)
  546. if filename == "rule_decisions.jsonl":
  547. return _with_db_schema(row)
  548. if filename == "walk_actions.jsonl":
  549. return _with_db_schema(row)
  550. if filename == "run_events.jsonl":
  551. return _with_db_schema(row)
  552. if filename == "source_path_records.jsonl":
  553. return _with_db_schema(row)
  554. if filename == "search_clues.jsonl":
  555. return _with_db_schema(row)
  556. raise ValueError(f"unsupported JSONL runtime file: {filename}")
  557. def _run_record(record: dict[str, Any]) -> dict[str, Any]:
  558. return _with_db_schema(record)
  559. def _policy_run_record(record: dict[str, Any]) -> dict[str, Any]:
  560. return _with_db_schema(record)
  561. def _with_db_schema(record: dict[str, Any]) -> dict[str, Any]:
  562. return {**record, "schema_version": DB_SCHEMA_VERSION}
  563. def _sanitize_record(table: str, record: dict[str, Any]) -> dict[str, Any]:
  564. result = {
  565. column: value
  566. for column, value in record.items()
  567. if column in _allowed_columns(table) and value is not None
  568. }
  569. raw_payload = result.get("raw_payload")
  570. if isinstance(raw_payload, dict):
  571. _assert_no_forbidden_raw_payload_keys(raw_payload)
  572. return result
  573. def _allowed_columns(table: str) -> set[str]:
  574. return TABLE_COLUMNS[table]
  575. TABLE_COLUMNS = {
  576. "content_agent_source_contexts": {
  577. "schema_version",
  578. "run_id",
  579. "demand_content_id",
  580. "pattern_source_system",
  581. "source_kind",
  582. "source_post_id",
  583. "pattern_execution_id",
  584. "mining_config_id",
  585. "evidence_pack",
  586. "source_context",
  587. "raw_demand_content",
  588. "created_at",
  589. },
  590. "content_agent_pattern_seed_packs": {
  591. "schema_version",
  592. "run_id",
  593. "policy_run_id",
  594. "source_post_id",
  595. "pattern_execution_id",
  596. "itemset_ids",
  597. "seed_terms",
  598. "pattern_seed_pack",
  599. "created_at",
  600. },
  601. "content_agent_queries": {
  602. "schema_version",
  603. "run_id",
  604. "policy_run_id",
  605. "search_query_id",
  606. "search_query",
  607. "search_query_generation_method",
  608. "discovery_start_source",
  609. "previous_discovery_step",
  610. "search_query_effect_status",
  611. "pattern_seed_ref",
  612. "raw_payload",
  613. "created_at",
  614. },
  615. "content_agent_discovered_content_items": {
  616. "schema_version",
  617. "run_id",
  618. "policy_run_id",
  619. "content_discovery_id",
  620. "search_query_id",
  621. "platform",
  622. "platform_content_id",
  623. "platform_content_format",
  624. "platform_content_url",
  625. "description",
  626. "platform_author_id",
  627. "author_display_name",
  628. "discovery_start_source",
  629. "previous_discovery_step",
  630. "statistics",
  631. "tags",
  632. "text_extra",
  633. "source_evidence",
  634. "pattern_match_result",
  635. "platform_raw_payload",
  636. "raw_payload",
  637. "created_at",
  638. },
  639. "content_agent_content_media_records": {
  640. "schema_version",
  641. "run_id",
  642. "policy_run_id",
  643. "platform",
  644. "platform_content_id",
  645. "content_media_status",
  646. "content_metadata_source",
  647. "play_url",
  648. "local_path",
  649. "oss_url",
  650. "raw_payload",
  651. "created_at",
  652. },
  653. "content_agent_pattern_recall_evidence": {
  654. "schema_version",
  655. "run_id",
  656. "policy_run_id",
  657. "recall_evidence_id",
  658. "content_discovery_id",
  659. "platform_content_id",
  660. "recall_status",
  661. "evidence_summary",
  662. "raw_payload",
  663. "created_at",
  664. },
  665. "content_agent_rule_decisions": {
  666. "schema_version",
  667. "run_id",
  668. "policy_run_id",
  669. "decision_id",
  670. "policy_bundle_id",
  671. "rule_pack_id",
  672. "rule_pack_version",
  673. "strategy_version",
  674. "decision_target_type",
  675. "decision_target_id",
  676. "decision_action",
  677. "decision_reason_code",
  678. "search_query_effect_status",
  679. "score",
  680. "triggered_blocking_rules",
  681. "scorecard",
  682. "source_evidence",
  683. "decision_replay_data",
  684. "raw_payload",
  685. "created_at",
  686. },
  687. "content_agent_walk_actions": {
  688. "schema_version",
  689. "run_id",
  690. "policy_run_id",
  691. "walk_action_id",
  692. "edge_id",
  693. "edge_type",
  694. "from_node_type",
  695. "from_node_id",
  696. "to_node_type",
  697. "to_node_id",
  698. "walk_action",
  699. "walk_status",
  700. "budget_tier",
  701. "depth",
  702. "page_cursor",
  703. "next_cursor",
  704. "decision_id",
  705. "rule_pack_id",
  706. "rule_pack_version",
  707. "reason_code",
  708. "source_path_record_id",
  709. "raw_payload",
  710. "created_at",
  711. "updated_at",
  712. },
  713. "content_agent_source_path_records": {
  714. "schema_version",
  715. "run_id",
  716. "policy_run_id",
  717. "source_path_record_id",
  718. "source_path_type",
  719. "from_node_type",
  720. "from_node_id",
  721. "to_node_type",
  722. "to_node_id",
  723. "decision_id",
  724. "rule_pack_id",
  725. "discovery_start_source",
  726. "previous_discovery_step",
  727. "origin_path_id",
  728. "source_evidence_ref",
  729. "raw_payload",
  730. "created_at",
  731. },
  732. "content_agent_search_clues": {
  733. "schema_version",
  734. "run_id",
  735. "policy_run_id",
  736. "clue_id",
  737. "search_query_id",
  738. "search_query",
  739. "discovery_start_source",
  740. "previous_discovery_step",
  741. "result_count",
  742. "pooled_content_count",
  743. "review_content_count",
  744. "pending_content_count",
  745. "rejected_content_count",
  746. "search_query_effect_status",
  747. "walk_next_step",
  748. "raw_payload",
  749. "created_at",
  750. },
  751. "content_agent_run_events": {
  752. "schema_version",
  753. "run_id",
  754. "policy_run_id",
  755. "event_id",
  756. "event_type",
  757. "status",
  758. "input_ref",
  759. "output_ref",
  760. "error_code",
  761. "message",
  762. "raw_payload",
  763. "created_at",
  764. },
  765. "content_agent_media_pipeline_tasks": {
  766. "schema_version",
  767. "task_id",
  768. "task_type",
  769. "idempotency_key",
  770. "run_id",
  771. "policy_run_id",
  772. "batch_id",
  773. "platform",
  774. "platform_content_id",
  775. "status",
  776. "attempt_count",
  777. "lease_until",
  778. "next_retry_at",
  779. "input_payload",
  780. "result_payload",
  781. "error_summary",
  782. "created_at",
  783. "updated_at",
  784. },
  785. "content_agent_final_outputs": {
  786. "schema_version",
  787. "run_id",
  788. "policy_run_id",
  789. "output_version",
  790. "summary",
  791. "final_output",
  792. "validation_status",
  793. "created_at",
  794. "updated_at",
  795. },
  796. "content_agent_publish_jobs": {
  797. "schema_version",
  798. "run_id",
  799. "policy_run_id",
  800. "publish_job_id",
  801. "platform_content_id",
  802. "job_status",
  803. "trigger_mode",
  804. "crawler_plan_id",
  805. "produce_plan_id",
  806. "publish_plan_id",
  807. "request_payload",
  808. "response_payload",
  809. "error_code",
  810. "error_message",
  811. "created_at",
  812. "updated_at",
  813. },
  814. "content_agent_author_assets": {
  815. "schema_version",
  816. "author_asset_id",
  817. "platform",
  818. "platform_author_id",
  819. "author_display_name",
  820. "author_profile_url",
  821. "asset_status",
  822. "source_type",
  823. "validation_status",
  824. "eligible_as_source",
  825. "elderly_ratio",
  826. "elderly_tgi",
  827. "content_tags",
  828. "source_run_id",
  829. "source_policy_run_id",
  830. "last_profile_fetch_at",
  831. "last_works_fetch_at",
  832. "last_validated_at",
  833. "profile_snapshot",
  834. "evidence_refs",
  835. "raw_payload",
  836. "created_at",
  837. "updated_at",
  838. },
  839. "content_agent_author_asset_roles": {
  840. "schema_version",
  841. "author_asset_id",
  842. "role",
  843. "role_status",
  844. "role_reason_code",
  845. "assigned_by",
  846. "source_run_id",
  847. "raw_payload",
  848. "created_at",
  849. "updated_at",
  850. },
  851. "content_agent_strategy_reviews": {
  852. "schema_version",
  853. "run_id",
  854. "policy_run_id",
  855. "review_id",
  856. "review_status",
  857. "summary",
  858. "effective_search_queries",
  859. "weak_search_queries",
  860. "top_reject_reasons",
  861. "productive_paths",
  862. "suggestions",
  863. "raw_payload",
  864. "created_at",
  865. },
  866. "content_agent_performance_feedback": {
  867. "schema_version",
  868. "run_id",
  869. "policy_run_id",
  870. "feedback_id",
  871. "platform",
  872. "platform_content_id",
  873. "content_asset_id",
  874. "feedback_source",
  875. "feedback_status",
  876. "metric_window_start",
  877. "metric_window_end",
  878. "completion_rate",
  879. "share_rate",
  880. "average_watch_seconds",
  881. "total_watch_seconds",
  882. "impression_count",
  883. "play_count",
  884. "like_count",
  885. "comment_count",
  886. "share_count",
  887. "collect_count",
  888. "raw_payload",
  889. "created_at",
  890. "updated_at",
  891. },
  892. "content_agent_search_clue_assets": {
  893. "schema_version",
  894. "search_clue_asset_id",
  895. "platform",
  896. "clue_type",
  897. "normalized_clue_text",
  898. "display_clue_text",
  899. "promotion_status",
  900. "reusable_priority",
  901. "can_seed_next_run",
  902. "first_seen_run_id",
  903. "first_seen_policy_run_id",
  904. "last_validated_at",
  905. "summary_metrics",
  906. "raw_payload",
  907. "created_at",
  908. "updated_at",
  909. },
  910. "content_agent_search_clue_asset_evidence": {
  911. "schema_version",
  912. "evidence_id",
  913. "search_clue_asset_id",
  914. "run_id",
  915. "policy_run_id",
  916. "clue_id",
  917. "search_query_id",
  918. "pooled_content_count",
  919. "review_content_count",
  920. "failed_content_count",
  921. "source_path_record_ids",
  922. "decision_ids",
  923. "performance_feedback_refs",
  924. "raw_payload",
  925. "created_at",
  926. },
  927. "content_agent_runs": {
  928. "schema_version",
  929. "run_id",
  930. "demand_content_id",
  931. "run_label",
  932. "platform",
  933. "platform_mode",
  934. "strategy_version",
  935. "status",
  936. "current_step",
  937. "validation_status",
  938. "source_ref",
  939. "error_code",
  940. "error_message",
  941. "error_detail",
  942. "started_at",
  943. "completed_at",
  944. "created_at",
  945. "updated_at",
  946. },
  947. "content_agent_policy_runs": {
  948. "schema_version",
  949. "run_id",
  950. "policy_run_id",
  951. "experiment_name",
  952. "run_role",
  953. "policy_bundle_id",
  954. "rule_pack_id",
  955. "strategy_id",
  956. "strategy_version",
  957. "rule_pack_version",
  958. "walk_strategy_version",
  959. "policy_bundle_hash",
  960. "strategy_source_ref",
  961. "rule_pack_source_ref",
  962. "evidence_bundle_schema_version",
  963. "runtime_record_schema_version",
  964. "status",
  965. "metrics",
  966. "decision_summary",
  967. "raw_payload",
  968. "created_at",
  969. },
  970. }
  971. def _table_for_runtime_file(filename: str) -> str:
  972. try:
  973. return RUNTIME_FILE_TABLES[filename]
  974. except KeyError as exc:
  975. raise ValueError(f"unsupported runtime file: {filename}") from exc
  976. def _db_value(table: str, column: str, value: Any) -> Any:
  977. if column in JSON_COLUMNS_BY_TABLE.get(table, set()):
  978. return _json_dump(value)
  979. if column in DATETIME_COLUMNS:
  980. return _datetime_value(value)
  981. return value
  982. def _json_dump(value: Any) -> str | None:
  983. if value is None:
  984. return None
  985. return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
  986. def _decode_json_payload(value: Any) -> Any:
  987. if value is None:
  988. return None
  989. if isinstance(value, (dict, list)):
  990. return value
  991. return json.loads(value)
  992. def _runtime_payload(payload: dict[str, Any]) -> dict[str, Any]:
  993. if "raw_payload" in payload:
  994. return payload
  995. return {**payload, "raw_payload": dict(payload)}
  996. def _runtime_rule_decision(row: dict[str, Any]) -> dict[str, Any]:
  997. raw_payload = _decode_json_payload(row.get("raw_payload")) or {}
  998. if not isinstance(raw_payload, dict):
  999. raw_payload = {}
  1000. formal: dict[str, Any] = {}
  1001. for column in RULE_DECISION_RUNTIME_COLUMNS:
  1002. if column == "raw_payload":
  1003. continue
  1004. value = row.get(column)
  1005. if column in JSON_COLUMNS_BY_TABLE["content_agent_rule_decisions"]:
  1006. value = _decode_json_payload(value)
  1007. formal[column] = _runtime_json_safe_value(value)
  1008. return {
  1009. "record_schema_version": raw_payload.get(
  1010. "record_schema_version",
  1011. RUNTIME_RECORD_SCHEMA_VERSION,
  1012. ),
  1013. **raw_payload,
  1014. **formal,
  1015. "raw_payload": raw_payload,
  1016. }
  1017. def _runtime_media_pipeline_task(row: dict[str, Any]) -> dict[str, Any]:
  1018. result: dict[str, Any] = {}
  1019. for key, value in row.items():
  1020. if key in JSON_COLUMNS_BY_TABLE["content_agent_media_pipeline_tasks"]:
  1021. value = _decode_json_payload(value)
  1022. result[key] = _runtime_json_safe_value(value)
  1023. return result
  1024. def _runtime_json_safe_value(value: Any) -> Any:
  1025. if isinstance(value, Decimal):
  1026. return int(value) if value == value.to_integral_value() else float(value)
  1027. if isinstance(value, datetime):
  1028. return value.isoformat()
  1029. if isinstance(value, list):
  1030. return [_runtime_json_safe_value(item) for item in value]
  1031. if isinstance(value, dict):
  1032. return {key: _runtime_json_safe_value(child) for key, child in value.items()}
  1033. return value
  1034. def _datetime_value(value: Any) -> Any:
  1035. if value is None or isinstance(value, datetime):
  1036. return value
  1037. if isinstance(value, str):
  1038. try:
  1039. parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
  1040. except ValueError:
  1041. return value
  1042. if parsed.tzinfo:
  1043. parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
  1044. return parsed
  1045. return value
  1046. def _itemset_ids_from_seed_pack(data: dict[str, Any]) -> list[Any]:
  1047. if data.get("itemset_ids"):
  1048. return list(data["itemset_ids"])
  1049. itemsets = data.get("itemsets") or []
  1050. ids = [
  1051. itemset.get("itemset_id")
  1052. for itemset in itemsets
  1053. if isinstance(itemset, dict) and itemset.get("itemset_id") is not None
  1054. ]
  1055. return list(dict.fromkeys(ids))
  1056. def _int_or_none(value: Any) -> int | None:
  1057. if value in (None, ""):
  1058. return None
  1059. return int(value)
  1060. def _assert_no_forbidden_raw_payload_keys(payload: Any) -> None:
  1061. if isinstance(payload, list):
  1062. for item in payload:
  1063. _assert_no_forbidden_raw_payload_keys(item)
  1064. return
  1065. if not isinstance(payload, dict):
  1066. return
  1067. for key, value in payload.items():
  1068. lowered = str(key).lower()
  1069. if lowered in FORBIDDEN_RAW_PAYLOAD_KEYS:
  1070. raise ValueError(f"raw_payload contains forbidden key: {key}")
  1071. _assert_no_forbidden_raw_payload_keys(value)
  1072. def _load_env_file(path_value: str | Path | None) -> dict[str, str]:
  1073. if not path_value:
  1074. return {}
  1075. path = Path(path_value)
  1076. if not path.exists():
  1077. return {}
  1078. result: dict[str, str] = {}
  1079. for line in path.read_text(encoding="utf-8").splitlines():
  1080. stripped = line.strip()
  1081. if not stripped or stripped.startswith("#") or "=" not in stripped:
  1082. continue
  1083. key, value = stripped.split("=", 1)
  1084. result[key.strip()] = value.strip().strip('"').strip("'")
  1085. return result