registry.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. from __future__ import annotations
  2. import json
  3. from dataclasses import dataclass
  4. from datetime import datetime, timezone
  5. from decimal import Decimal
  6. from pathlib import Path
  7. from typing import Any
  8. from content_agent.integrations.database_runtime import ContentSupplyDbConfig
  9. SCHEMA_VERSION = "content_agent.v1"
  10. CREATE_TABLES_SQL = """
  11. CREATE TABLE IF NOT EXISTS content_agent_aigc_push_records (
  12. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  13. schema_version VARCHAR(80) NOT NULL DEFAULT 'content_agent.v1',
  14. push_record_id VARCHAR(160) NOT NULL,
  15. run_id VARCHAR(80) NOT NULL,
  16. policy_run_id VARCHAR(80) NULL,
  17. platform VARCHAR(32) NOT NULL,
  18. demand_content_id BIGINT UNSIGNED NULL,
  19. demand_name VARCHAR(512) NULL,
  20. demand_dt VARCHAR(32) NULL,
  21. hive_merge_leve2 VARCHAR(160) NULL,
  22. hive_gap_dt VARCHAR(32) NULL,
  23. evidence_platform VARCHAR(80) NULL,
  24. run_label VARCHAR(240) NULL,
  25. run_status VARCHAR(32) NULL,
  26. pushed_at DATETIME(3) NULL,
  27. push_status VARCHAR(32) NOT NULL DEFAULT 'pushed',
  28. source_kind VARCHAR(80) NOT NULL DEFAULT 'manual',
  29. source_ref VARCHAR(512) NULL,
  30. uploaded_by VARCHAR(120) NULL,
  31. notes TEXT NULL,
  32. raw_payload JSON NULL,
  33. created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  34. updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  35. PRIMARY KEY (id),
  36. UNIQUE KEY uk_ca_aigc_push_record (push_record_id),
  37. KEY idx_ca_aigc_push_run (run_id),
  38. KEY idx_ca_aigc_push_demand (demand_content_id),
  39. KEY idx_ca_aigc_push_platform_time (platform, pushed_at),
  40. KEY idx_ca_aigc_push_status (push_status)
  41. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
  42. CREATE TABLE IF NOT EXISTS content_agent_aigc_push_plans (
  43. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  44. schema_version VARCHAR(80) NOT NULL DEFAULT 'content_agent.v1',
  45. push_record_id VARCHAR(160) NOT NULL,
  46. plan_part VARCHAR(32) NOT NULL,
  47. produce_plan_id VARCHAR(120) NOT NULL,
  48. crawler_plan_id VARCHAR(120) NOT NULL,
  49. content_count INT UNSIGNED NOT NULL DEFAULT 0,
  50. pushed_at DATETIME(3) NULL,
  51. raw_payload JSON NULL,
  52. created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  53. updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  54. PRIMARY KEY (id),
  55. UNIQUE KEY uk_ca_aigc_push_plan (push_record_id, plan_part),
  56. KEY idx_ca_aigc_push_plan_crawler (crawler_plan_id),
  57. KEY idx_ca_aigc_push_plan_produce (produce_plan_id)
  58. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
  59. CREATE TABLE IF NOT EXISTS content_agent_aigc_push_items (
  60. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  61. schema_version VARCHAR(80) NOT NULL DEFAULT 'content_agent.v1',
  62. push_record_id VARCHAR(160) NOT NULL,
  63. plan_part VARCHAR(32) NOT NULL,
  64. item_index INT UNSIGNED NOT NULL,
  65. platform_content_id VARCHAR(160) NOT NULL,
  66. decision_id VARCHAR(80) NULL,
  67. content_title TEXT NULL,
  68. author_display_name VARCHAR(256) NULL,
  69. raw_payload JSON NULL,
  70. created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  71. updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  72. PRIMARY KEY (id),
  73. UNIQUE KEY uk_ca_aigc_push_item (push_record_id, platform_content_id),
  74. KEY idx_ca_aigc_push_item_part (push_record_id, plan_part),
  75. KEY idx_ca_aigc_push_item_content (platform_content_id)
  76. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
  77. """
  78. @dataclass(frozen=True)
  79. class AigcPushRegistry:
  80. config: ContentSupplyDbConfig
  81. @classmethod
  82. def from_env(cls, env_file: str | Path | None = ".env") -> "AigcPushRegistry":
  83. return cls(ContentSupplyDbConfig.from_env(env_file=env_file))
  84. def connect(self) -> Any:
  85. return self.config.connect()
  86. def init_db(self) -> None:
  87. with self.connect() as conn:
  88. with conn.cursor() as cur:
  89. for statement in _split_sql(CREATE_TABLES_SQL):
  90. cur.execute(statement)
  91. conn.commit()
  92. def upsert_record(self, record: dict[str, Any]) -> None:
  93. prepared = normalize_manual_record(record)
  94. with self.connect() as conn:
  95. with conn.cursor() as cur:
  96. _upsert(cur, "content_agent_aigc_push_records", _record_row(prepared), ("push_record_id",))
  97. cur.execute(
  98. "DELETE FROM content_agent_aigc_push_plans WHERE push_record_id = %s",
  99. (prepared["push_record_id"],),
  100. )
  101. cur.execute(
  102. "DELETE FROM content_agent_aigc_push_items WHERE push_record_id = %s",
  103. (prepared["push_record_id"],),
  104. )
  105. for plan in prepared["plans"]:
  106. _upsert(
  107. cur,
  108. "content_agent_aigc_push_plans",
  109. _plan_row(prepared, plan),
  110. ("push_record_id", "plan_part"),
  111. )
  112. for index, item in enumerate(plan.get("items") or [], start=1):
  113. _upsert(
  114. cur,
  115. "content_agent_aigc_push_items",
  116. _item_row(prepared, plan, item, index),
  117. ("push_record_id", "platform_content_id"),
  118. )
  119. conn.commit()
  120. def list_records(
  121. self,
  122. *,
  123. platform: str | None = None,
  124. demand_content_id: int | None = None,
  125. run_id: str | None = None,
  126. limit: int = 200,
  127. ) -> dict[str, Any]:
  128. where: list[str] = ["r.push_status = 'pushed'"]
  129. params: list[Any] = []
  130. if platform:
  131. where.append("r.platform = %s")
  132. params.append(platform)
  133. if demand_content_id is not None:
  134. where.append("r.demand_content_id = %s")
  135. params.append(demand_content_id)
  136. if run_id:
  137. where.append("r.run_id = %s")
  138. params.append(run_id)
  139. where_sql = "WHERE " + " AND ".join(where)
  140. params.append(max(1, min(int(limit), 500)))
  141. with self.connect() as conn:
  142. with conn.cursor() as cur:
  143. cur.execute(
  144. f"""
  145. SELECT r.*,
  146. COALESCE(SUM(p.content_count), 0) AS pushed_content_count,
  147. COUNT(DISTINCT p.crawler_plan_id) AS crawler_plan_count
  148. FROM content_agent_aigc_push_records r
  149. LEFT JOIN content_agent_aigc_push_plans p
  150. ON p.push_record_id = r.push_record_id
  151. {where_sql}
  152. GROUP BY r.id
  153. ORDER BY r.pushed_at DESC, r.id DESC
  154. LIMIT %s
  155. """,
  156. tuple(params),
  157. )
  158. records = [_decode_row(row) for row in cur.fetchall()]
  159. ids = [row["push_record_id"] for row in records]
  160. plans = _fetch_grouped(cur, "content_agent_aigc_push_plans", ids)
  161. items = _fetch_grouped(cur, "content_agent_aigc_push_items", ids)
  162. for record in records:
  163. push_id = record["push_record_id"]
  164. record["plans"] = plans.get(push_id, [])
  165. record["items"] = items.get(push_id, [])
  166. return {
  167. "items": records,
  168. "total": len(records),
  169. "data_origin": "production_db",
  170. }
  171. def build_record_from_run(
  172. self,
  173. *,
  174. run_id: str,
  175. platform: str,
  176. pushed_at: str,
  177. plans: list[dict[str, Any]],
  178. source_kind: str = "manual",
  179. source_ref: str | None = None,
  180. notes: str | None = None,
  181. uploaded_by: str | None = None,
  182. ) -> dict[str, Any]:
  183. with self.connect() as conn:
  184. with conn.cursor() as cur:
  185. cur.execute(
  186. """
  187. SELECT r.run_id,
  188. (SELECT p.policy_run_id
  189. FROM content_agent_policy_runs p
  190. WHERE p.run_id = r.run_id
  191. ORDER BY p.id DESC LIMIT 1) AS policy_run_id,
  192. r.platform, r.status AS run_status,
  193. r.demand_content_id, d.name AS demand_name, d.dt AS demand_dt,
  194. d.merge_leve2,
  195. JSON_UNQUOTE(JSON_EXTRACT(d.ext_data,'$.run_label')) AS run_label,
  196. JSON_UNQUOTE(JSON_EXTRACT(d.ext_data,'$.evidence_pack.demand_scope.merge_leve2')) AS hive_merge_leve2,
  197. JSON_UNQUOTE(JSON_EXTRACT(d.ext_data,'$.evidence_pack.demand_scope.gap_dt')) AS hive_gap_dt,
  198. JSON_UNQUOTE(JSON_EXTRACT(d.ext_data,'$.evidence_pack.demand_scope.platform')) AS evidence_platform
  199. FROM content_agent_runs r
  200. LEFT JOIN demand_content d ON d.id = r.demand_content_id
  201. WHERE r.run_id = %s
  202. LIMIT 1
  203. """,
  204. (run_id,),
  205. )
  206. run = cur.fetchone()
  207. if not run:
  208. raise ValueError(f"run not found: {run_id}")
  209. cur.execute(
  210. """
  211. SELECT di.platform_content_id, rd.decision_id, di.description AS content_title,
  212. di.author_display_name
  213. FROM content_agent_rule_decisions rd
  214. JOIN content_agent_discovered_content_items di
  215. ON rd.run_id = di.run_id AND rd.decision_target_id = di.platform_content_id
  216. WHERE rd.run_id = %s
  217. AND di.platform = %s
  218. AND rd.decision_action = 'ADD_TO_CONTENT_POOL'
  219. ORDER BY di.id
  220. """,
  221. (run_id, platform),
  222. )
  223. pooled = cur.fetchall()
  224. cursor = 0
  225. planned: list[dict[str, Any]] = []
  226. for plan in plans:
  227. count = int(plan.get("content_count") or 0)
  228. if not count:
  229. count = len(plan.get("video_ids") or plan.get("content_ids") or [])
  230. plan_items = pooled[cursor: cursor + count] if count else []
  231. cursor += count
  232. planned.append(
  233. {
  234. **plan,
  235. "items": [
  236. {
  237. "platform_content_id": row["platform_content_id"],
  238. "decision_id": row.get("decision_id"),
  239. "content_title": row.get("content_title"),
  240. "author_display_name": row.get("author_display_name"),
  241. }
  242. for row in plan_items
  243. ],
  244. }
  245. )
  246. pushed_at_dt = parse_datetime(pushed_at)
  247. return normalize_manual_record(
  248. {
  249. "push_record_id": f"aigc_push_{run_id}_{platform}_{pushed_at_dt.strftime('%Y%m%d%H%M%S')}",
  250. "run_id": run_id,
  251. "policy_run_id": run.get("policy_run_id"),
  252. "platform": platform,
  253. "demand_content_id": run.get("demand_content_id"),
  254. "demand_name": run.get("demand_name"),
  255. "demand_dt": run.get("demand_dt"),
  256. "hive_merge_leve2": run.get("hive_merge_leve2") or run.get("merge_leve2"),
  257. "hive_gap_dt": run.get("hive_gap_dt"),
  258. "evidence_platform": run.get("evidence_platform"),
  259. "run_label": run.get("run_label"),
  260. "run_status": run.get("run_status"),
  261. "pushed_at": pushed_at_dt.isoformat(),
  262. "push_status": "pushed",
  263. "source_kind": source_kind,
  264. "source_ref": source_ref,
  265. "uploaded_by": uploaded_by,
  266. "notes": notes,
  267. "plans": planned,
  268. }
  269. )
  270. def import_live_logs(registry: AigcPushRegistry, log_dir: Path) -> list[dict[str, Any]]:
  271. live_by_run: dict[str, list[dict[str, Any]]] = {}
  272. for path in sorted(log_dir.glob("aigc_push_*.jsonl")):
  273. for line in path.read_text(encoding="utf-8").splitlines():
  274. if not line.strip():
  275. continue
  276. record = json.loads(line)
  277. if record.get("mode") != "live" or record.get("status") != "ok":
  278. continue
  279. record["_source_file"] = str(path)
  280. live_by_run.setdefault(str(record["run_id"]), []).append(record)
  281. imported: list[dict[str, Any]] = []
  282. for run_id, rows in sorted(live_by_run.items()):
  283. rows.sort(key=lambda row: str(row.get("half") or ""))
  284. platform = str(rows[0]["platform"])
  285. plans = [
  286. {
  287. "plan_part": str(row.get("half") or index),
  288. "produce_plan_id": str(row["produce_plan_id"]),
  289. "crawler_plan_id": str(row["crawler_plan_id"]),
  290. "content_count": int(row.get("content_count") or row.get("aweme_count") or 0),
  291. "pushed_at": row.get("ts"),
  292. "raw_payload": {"source_file": row.get("_source_file")},
  293. }
  294. for index, row in enumerate(rows, start=1)
  295. ]
  296. pushed_at = rows[0].get("ts") or datetime.now(timezone.utc).isoformat()
  297. record = registry.build_record_from_run(
  298. run_id=run_id,
  299. platform=platform,
  300. pushed_at=pushed_at,
  301. plans=plans,
  302. source_kind="e2e_log_import",
  303. source_ref=";".join(sorted({str(row.get("_source_file")) for row in rows})),
  304. notes="Imported from live AIGC push logs; dry-run records ignored.",
  305. )
  306. registry.upsert_record(record)
  307. imported.append(record)
  308. return imported
  309. def normalize_manual_record(record: dict[str, Any]) -> dict[str, Any]:
  310. plans = record.get("plans") or []
  311. if not plans:
  312. raise ValueError("manual AIGC push record must include plans")
  313. push_record_id = str(record.get("push_record_id") or "").strip()
  314. if not push_record_id:
  315. raise ValueError("push_record_id is required")
  316. result = {
  317. **record,
  318. "schema_version": record.get("schema_version") or SCHEMA_VERSION,
  319. "push_record_id": push_record_id,
  320. "push_status": record.get("push_status") or "pushed",
  321. "source_kind": record.get("source_kind") or "manual",
  322. "pushed_at": parse_datetime(record["pushed_at"]).isoformat() if record.get("pushed_at") else None,
  323. "plans": [],
  324. }
  325. if result["push_status"] != "pushed":
  326. raise ValueError("only real pushed AIGC records are stored; dry-run records are ignored")
  327. for plan in plans:
  328. part = str(plan.get("plan_part") or plan.get("half") or "").strip()
  329. if not part:
  330. raise ValueError("each plan needs plan_part/half")
  331. crawler_plan_id = str(plan.get("crawler_plan_id") or "").strip()
  332. if not crawler_plan_id:
  333. raise ValueError("crawler_plan_id is required for real pushed records")
  334. produce_plan_id = str(plan.get("produce_plan_id") or "").strip()
  335. if not produce_plan_id:
  336. raise ValueError("produce_plan_id is required")
  337. raw_items = plan.get("items")
  338. if raw_items is None:
  339. ids = plan.get("video_ids") or plan.get("content_ids") or []
  340. raw_items = [{"platform_content_id": item_id} for item_id in ids]
  341. result["plans"].append(
  342. {
  343. **plan,
  344. "plan_part": part,
  345. "crawler_plan_id": crawler_plan_id,
  346. "produce_plan_id": produce_plan_id,
  347. "pushed_at": parse_datetime(plan["pushed_at"]).isoformat() if plan.get("pushed_at") else result["pushed_at"],
  348. "items": raw_items,
  349. "content_count": int(plan.get("content_count") or len(raw_items)),
  350. }
  351. )
  352. return result
  353. def parse_datetime(value: str | datetime) -> datetime:
  354. if isinstance(value, datetime):
  355. return value
  356. text = str(value).strip()
  357. if text.endswith("Z"):
  358. text = text[:-1] + "+00:00"
  359. if " " in text and "T" not in text:
  360. text = text.replace(" ", "T")
  361. parsed = datetime.fromisoformat(text)
  362. if parsed.tzinfo is not None:
  363. parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
  364. return parsed
  365. def _record_row(record: dict[str, Any]) -> dict[str, Any]:
  366. keys = [
  367. "schema_version",
  368. "push_record_id",
  369. "run_id",
  370. "policy_run_id",
  371. "platform",
  372. "demand_content_id",
  373. "demand_name",
  374. "demand_dt",
  375. "hive_merge_leve2",
  376. "hive_gap_dt",
  377. "evidence_platform",
  378. "run_label",
  379. "run_status",
  380. "pushed_at",
  381. "push_status",
  382. "source_kind",
  383. "source_ref",
  384. "uploaded_by",
  385. "notes",
  386. "raw_payload",
  387. ]
  388. row = {key: record.get(key) for key in keys}
  389. row["pushed_at"] = parse_datetime(record["pushed_at"]) if record.get("pushed_at") else None
  390. return row
  391. def _plan_row(record: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
  392. return {
  393. "schema_version": SCHEMA_VERSION,
  394. "push_record_id": record["push_record_id"],
  395. "plan_part": plan["plan_part"],
  396. "produce_plan_id": plan["produce_plan_id"],
  397. "crawler_plan_id": plan["crawler_plan_id"],
  398. "content_count": plan["content_count"],
  399. "pushed_at": parse_datetime(plan["pushed_at"]) if plan.get("pushed_at") else None,
  400. "raw_payload": plan.get("raw_payload"),
  401. }
  402. def _item_row(record: dict[str, Any], plan: dict[str, Any], item: dict[str, Any], index: int) -> dict[str, Any]:
  403. return {
  404. "schema_version": SCHEMA_VERSION,
  405. "push_record_id": record["push_record_id"],
  406. "plan_part": plan["plan_part"],
  407. "item_index": index,
  408. "platform_content_id": str(item.get("platform_content_id") or item.get("content_id") or item.get("aweme_id")),
  409. "decision_id": item.get("decision_id"),
  410. "content_title": item.get("content_title") or item.get("title"),
  411. "author_display_name": item.get("author_display_name"),
  412. "raw_payload": item.get("raw_payload"),
  413. }
  414. def _upsert(cur: Any, table: str, row: dict[str, Any], key_columns: tuple[str, ...]) -> None:
  415. clean = {key: value for key, value in row.items() if value is not None}
  416. columns = list(clean)
  417. placeholders = ", ".join(["%s"] * len(columns))
  418. column_sql = ", ".join(f"`{column}`" for column in columns)
  419. updates = ", ".join(
  420. f"`{column}` = VALUES(`{column}`)"
  421. for column in columns
  422. if column not in key_columns
  423. )
  424. values = [_db_value(value) for value in clean.values()]
  425. sql = f"INSERT INTO `{table}` ({column_sql}) VALUES ({placeholders})"
  426. if updates:
  427. sql += f" ON DUPLICATE KEY UPDATE {updates}"
  428. cur.execute(sql, values)
  429. def _fetch_grouped(cur: Any, table: str, push_ids: list[str]) -> dict[str, list[dict[str, Any]]]:
  430. if not push_ids:
  431. return {}
  432. cur.execute(
  433. f"SELECT * FROM `{table}` WHERE push_record_id IN %s ORDER BY push_record_id, id",
  434. (tuple(push_ids),),
  435. )
  436. result: dict[str, list[dict[str, Any]]] = {}
  437. for row in cur.fetchall():
  438. decoded = _decode_row(row)
  439. result.setdefault(str(decoded["push_record_id"]), []).append(decoded)
  440. return result
  441. def _decode_row(row: dict[str, Any]) -> dict[str, Any]:
  442. decoded = dict(row)
  443. for key, value in list(decoded.items()):
  444. if isinstance(value, (datetime,)):
  445. decoded[key] = value.isoformat(sep=" ")
  446. elif isinstance(value, bytes):
  447. decoded[key] = value.decode("utf-8")
  448. elif isinstance(value, Decimal):
  449. decoded[key] = int(value) if value == value.to_integral_value() else float(value)
  450. elif key in {"raw_payload"} and isinstance(value, str):
  451. try:
  452. decoded[key] = json.loads(value)
  453. except json.JSONDecodeError:
  454. pass
  455. return decoded
  456. def _db_value(value: Any) -> Any:
  457. if isinstance(value, (dict, list)):
  458. return json.dumps(value, ensure_ascii=False)
  459. if isinstance(value, datetime):
  460. return value.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
  461. return value
  462. def _split_sql(sql: str) -> list[str]:
  463. return [part.strip() for part in sql.split(";") if part.strip()]