postgres.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  1. """PostgreSQL implementation of the formal acquisition repository."""
  2. from __future__ import annotations
  3. from typing import Any
  4. from uuid import UUID
  5. import psycopg2.extras
  6. from acquisition.domain import (
  7. AcquisitionJob,
  8. AcquisitionRun,
  9. CandidateItem,
  10. ItemClassification,
  11. MediaAsset,
  12. Query,
  13. QueryBatch,
  14. )
  15. Json = psycopg2.extras.Json
  16. psycopg2.extras.register_uuid()
  17. class PostgresAcquisitionRepository:
  18. """Repository backed by the formal cloud PostgreSQL schema.
  19. The repository does not commit by itself; callers own transaction scope via
  20. core.db_session.transaction or an equivalent connection boundary.
  21. """
  22. def __init__(self, conn: Any):
  23. self.conn = conn
  24. def _one(self, sql: str, params: tuple[Any, ...]) -> dict[str, Any]:
  25. with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
  26. cur.execute(sql, params)
  27. row = cur.fetchone()
  28. if row is None:
  29. raise RuntimeError("expected one row, got none")
  30. return dict(row)
  31. def _all(self, sql: str, params: tuple[Any, ...]) -> list[dict[str, Any]]:
  32. with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
  33. cur.execute(sql, params)
  34. return [dict(row) for row in cur.fetchall()]
  35. def create_query_batch(
  36. self,
  37. *,
  38. name: str,
  39. source_type: str = "manual",
  40. generation_method: str | None = None,
  41. target_platforms: list[str] | None = None,
  42. status: str = "draft",
  43. metadata: dict[str, Any] | None = None,
  44. ) -> QueryBatch:
  45. row = self._one(
  46. """
  47. INSERT INTO query_batches(
  48. name, source_type, generation_method, target_platforms,
  49. status, metadata
  50. )
  51. VALUES (%s, %s, %s, %s, %s, %s)
  52. RETURNING *
  53. """,
  54. (
  55. name,
  56. source_type,
  57. generation_method,
  58. target_platforms or [],
  59. status,
  60. Json(metadata or {}),
  61. ),
  62. )
  63. return QueryBatch.model_validate(row)
  64. def add_query(
  65. self,
  66. *,
  67. batch_id: UUID | None,
  68. query_text: str,
  69. axes: dict[str, Any] | None = None,
  70. keep: bool | None = None,
  71. filter_reason: str | None = None,
  72. status: str = "draft",
  73. sort_order: int = 0,
  74. metadata: dict[str, Any] | None = None,
  75. ) -> Query:
  76. row = self._one(
  77. """
  78. INSERT INTO queries(
  79. batch_id, query_text, axes, keep, filter_reason,
  80. status, sort_order, metadata
  81. )
  82. VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
  83. RETURNING *
  84. """,
  85. (
  86. batch_id,
  87. query_text,
  88. Json(axes or {}),
  89. keep,
  90. filter_reason,
  91. status,
  92. sort_order,
  93. Json(metadata or {}),
  94. ),
  95. )
  96. return Query.model_validate(row)
  97. def list_queries_for_batch(
  98. self,
  99. batch_id: UUID,
  100. *,
  101. keep: bool | None = None,
  102. ) -> list[Query]:
  103. if keep is None:
  104. rows = self._all(
  105. """
  106. SELECT * FROM queries
  107. WHERE batch_id = %s
  108. ORDER BY sort_order, created_at
  109. """,
  110. (batch_id,),
  111. )
  112. else:
  113. rows = self._all(
  114. """
  115. SELECT * FROM queries
  116. WHERE batch_id = %s AND keep IS NOT DISTINCT FROM %s
  117. ORDER BY sort_order, created_at
  118. """,
  119. (batch_id, keep),
  120. )
  121. return [Query.model_validate(row) for row in rows]
  122. def get_query_batch(self, batch_id: UUID) -> QueryBatch:
  123. row = self._one("SELECT * FROM query_batches WHERE id = %s", (batch_id,))
  124. return QueryBatch.model_validate(row)
  125. def create_acquisition_run(
  126. self,
  127. *,
  128. batch_id: UUID | None = None,
  129. run_key: str | None = None,
  130. status: str = "pending",
  131. note: str | None = None,
  132. metadata: dict[str, Any] | None = None,
  133. ) -> AcquisitionRun:
  134. row = self._one(
  135. """
  136. INSERT INTO acquisition_runs(batch_id, run_key, status, note, metadata, started_at)
  137. VALUES (%s, %s, %s, %s, %s, CASE WHEN %s = 'running' THEN now() ELSE NULL END)
  138. ON CONFLICT (run_key) DO UPDATE SET
  139. status = EXCLUDED.status,
  140. note = EXCLUDED.note,
  141. metadata = EXCLUDED.metadata,
  142. started_at = COALESCE(acquisition_runs.started_at, EXCLUDED.started_at)
  143. RETURNING *
  144. """,
  145. (batch_id, run_key, status, note, Json(metadata or {}), status),
  146. )
  147. return AcquisitionRun.model_validate(row)
  148. def ensure_acquisition_job(
  149. self,
  150. *,
  151. run_id: UUID,
  152. query_id: UUID | None,
  153. platform: str,
  154. search_limit: int | None = None,
  155. display_limit: int | None = None,
  156. status: str = "pending",
  157. metadata: dict[str, Any] | None = None,
  158. ) -> AcquisitionJob:
  159. row = self._one(
  160. """
  161. INSERT INTO acquisition_jobs(
  162. run_id, query_id, platform, search_limit,
  163. display_limit, status, metadata
  164. )
  165. VALUES (%s, %s, %s, %s, %s, %s, %s)
  166. ON CONFLICT (run_id, query_id, platform) DO UPDATE SET
  167. search_limit = EXCLUDED.search_limit,
  168. display_limit = EXCLUDED.display_limit,
  169. metadata = EXCLUDED.metadata
  170. RETURNING *
  171. """,
  172. (
  173. run_id,
  174. query_id,
  175. platform,
  176. search_limit,
  177. display_limit,
  178. status,
  179. Json(metadata or {}),
  180. ),
  181. )
  182. return AcquisitionJob.model_validate(row)
  183. def update_acquisition_job(
  184. self,
  185. job_id: UUID,
  186. *,
  187. status: str,
  188. attempt_count: int | None = None,
  189. error_message: str | None = None,
  190. metadata: dict[str, Any] | None = None,
  191. ) -> AcquisitionJob:
  192. row = self._one(
  193. """
  194. UPDATE acquisition_jobs SET
  195. status = %s,
  196. attempt_count = COALESCE(%s, attempt_count),
  197. error_message = %s,
  198. metadata = CASE WHEN %s THEN %s ELSE metadata END
  199. WHERE id = %s
  200. RETURNING *
  201. """,
  202. (
  203. status,
  204. attempt_count,
  205. error_message,
  206. metadata is not None,
  207. Json(metadata or {}),
  208. job_id,
  209. ),
  210. )
  211. return AcquisitionJob.model_validate(row)
  212. def update_acquisition_run(
  213. self,
  214. run_id: UUID,
  215. *,
  216. status: str,
  217. error_message: str | None = None,
  218. metadata: dict[str, Any] | None = None,
  219. ) -> AcquisitionRun:
  220. row = self._one(
  221. """
  222. UPDATE acquisition_runs SET
  223. status = %s,
  224. error_message = %s,
  225. metadata = CASE WHEN %s THEN metadata || %s ELSE metadata END,
  226. finished_at = CASE WHEN %s IN ('done', 'partial', 'failed') THEN now() ELSE finished_at END
  227. WHERE id = %s
  228. RETURNING *
  229. """,
  230. (
  231. status,
  232. error_message,
  233. metadata is not None,
  234. Json(metadata or {}),
  235. status,
  236. run_id,
  237. ),
  238. )
  239. return AcquisitionRun.model_validate(row)
  240. def upsert_candidate_item(
  241. self,
  242. *,
  243. platform: str,
  244. job_id: UUID | None = None,
  245. query_id: UUID | None = None,
  246. platform_item_id: str | None = None,
  247. canonical_url: str | None = None,
  248. content_type: str | None = None,
  249. title: str | None = None,
  250. author_name: str | None = None,
  251. raw_summary: str | None = None,
  252. status: str = "candidate",
  253. source_payload: dict[str, Any] | None = None,
  254. metadata: dict[str, Any] | None = None,
  255. error_message: str | None = None,
  256. ) -> CandidateItem:
  257. existing_id = None
  258. if platform_item_id:
  259. row = self._one_or_none(
  260. """
  261. SELECT id FROM candidate_items
  262. WHERE platform = %s AND platform_item_id = %s
  263. ORDER BY created_at DESC
  264. LIMIT 1
  265. """,
  266. (platform, platform_item_id),
  267. )
  268. existing_id = row["id"] if row else None
  269. if existing_id:
  270. row = self._one(
  271. """
  272. UPDATE candidate_items SET
  273. job_id = %s,
  274. query_id = %s,
  275. canonical_url = %s,
  276. content_type = %s,
  277. title = %s,
  278. author_name = %s,
  279. raw_summary = %s,
  280. status = %s,
  281. source_payload = %s,
  282. metadata = %s,
  283. error_message = %s
  284. WHERE id = %s
  285. RETURNING *
  286. """,
  287. (
  288. job_id,
  289. query_id,
  290. canonical_url,
  291. content_type,
  292. title,
  293. author_name,
  294. raw_summary,
  295. status,
  296. Json(source_payload or {}),
  297. Json(metadata or {}),
  298. error_message,
  299. existing_id,
  300. ),
  301. )
  302. else:
  303. row = self._one(
  304. """
  305. INSERT INTO candidate_items(
  306. job_id, query_id, platform, platform_item_id, canonical_url,
  307. content_type, title, author_name, raw_summary, status,
  308. source_payload, metadata, error_message
  309. )
  310. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  311. RETURNING *
  312. """,
  313. (
  314. job_id,
  315. query_id,
  316. platform,
  317. platform_item_id,
  318. canonical_url,
  319. content_type,
  320. title,
  321. author_name,
  322. raw_summary,
  323. status,
  324. Json(source_payload or {}),
  325. Json(metadata or {}),
  326. error_message,
  327. ),
  328. )
  329. return CandidateItem.model_validate(row)
  330. def _one_or_none(self, sql: str, params: tuple[Any, ...]) -> dict[str, Any] | None:
  331. with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
  332. cur.execute(sql, params)
  333. row = cur.fetchone()
  334. return dict(row) if row else None
  335. def add_media_asset(
  336. self,
  337. *,
  338. item_id: UUID,
  339. media_type: str,
  340. source_url: str | None = None,
  341. oss_url: str | None = None,
  342. cdn_url: str | None = None,
  343. position: int = 0,
  344. status: str = "pending",
  345. source_payload: dict[str, Any] | None = None,
  346. metadata: dict[str, Any] | None = None,
  347. ) -> MediaAsset:
  348. row = self._one(
  349. """
  350. INSERT INTO media_assets(
  351. item_id, media_type, source_url, oss_url, cdn_url,
  352. position, status, source_payload, metadata
  353. )
  354. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
  355. RETURNING *
  356. """,
  357. (
  358. item_id,
  359. media_type,
  360. source_url,
  361. oss_url,
  362. cdn_url,
  363. position,
  364. status,
  365. Json(source_payload or {}),
  366. Json(metadata or {}),
  367. ),
  368. )
  369. return MediaAsset.model_validate(row)
  370. def add_item_classification(
  371. self,
  372. *,
  373. item_id: UUID,
  374. is_creation_knowledge: bool | None = None,
  375. label: str | None = None,
  376. confidence: float | None = None,
  377. reason: str | None = None,
  378. model_name: str | None = None,
  379. prompt_version: str | None = None,
  380. result_payload: dict[str, Any] | None = None,
  381. status: str = "pending",
  382. error_message: str | None = None,
  383. ) -> ItemClassification:
  384. row = self._one(
  385. """
  386. INSERT INTO item_classifications(
  387. item_id, is_creation_knowledge, label, confidence, reason,
  388. model_name, prompt_version, result_payload, status, error_message
  389. )
  390. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  391. RETURNING *
  392. """,
  393. (
  394. item_id,
  395. is_creation_knowledge,
  396. label,
  397. confidence,
  398. reason,
  399. model_name,
  400. prompt_version,
  401. Json(result_payload or {}),
  402. status,
  403. error_message,
  404. ),
  405. )
  406. return ItemClassification.model_validate(row)
  407. def get_run_summary(self, run_id: UUID) -> dict[str, Any]:
  408. summary = self._one(
  409. """
  410. SELECT
  411. ar.id,
  412. ar.run_key,
  413. ar.batch_id,
  414. ar.status,
  415. ar.started_at,
  416. ar.finished_at,
  417. COUNT(DISTINCT q.id)::int AS query_count,
  418. COUNT(DISTINCT aj.id)::int AS job_count,
  419. COUNT(DISTINCT ci.id)::int AS candidate_count,
  420. COUNT(DISTINCT ic.id) FILTER (
  421. WHERE ic.is_creation_knowledge IS TRUE
  422. )::int AS creation_hit_count
  423. FROM acquisition_runs ar
  424. LEFT JOIN acquisition_jobs aj ON aj.run_id = ar.id
  425. LEFT JOIN queries q ON q.id = aj.query_id
  426. LEFT JOIN candidate_items ci ON ci.job_id = aj.id
  427. LEFT JOIN item_classifications ic ON ic.item_id = ci.id
  428. WHERE ar.id = %s
  429. GROUP BY ar.id
  430. """,
  431. (run_id,),
  432. )
  433. summary["queries"] = self._all(
  434. """
  435. SELECT
  436. q.id AS query_id,
  437. q.query_text,
  438. COUNT(DISTINCT aj.id)::int AS job_count,
  439. COUNT(DISTINCT ci.id)::int AS candidate_count,
  440. COUNT(DISTINCT ic.id) FILTER (
  441. WHERE ic.is_creation_knowledge IS TRUE
  442. )::int AS creation_hit_count,
  443. jsonb_object_agg(
  444. aj.platform,
  445. jsonb_build_object(
  446. 'status', aj.status,
  447. 'attempt_count', aj.attempt_count,
  448. 'display_limit', aj.display_limit,
  449. 'search_limit', aj.search_limit,
  450. 'error_message', aj.error_message
  451. )
  452. ) FILTER (WHERE aj.id IS NOT NULL) AS platforms
  453. FROM queries q
  454. JOIN acquisition_jobs aj ON aj.query_id = q.id
  455. LEFT JOIN candidate_items ci ON ci.job_id = aj.id
  456. LEFT JOIN item_classifications ic ON ic.item_id = ci.id
  457. WHERE aj.run_id = %s
  458. GROUP BY q.id, q.query_text, q.sort_order
  459. ORDER BY q.sort_order, q.query_text
  460. """,
  461. (run_id,),
  462. )
  463. return summary
  464. def get_query_detail(self, *, run_id: UUID, query_id: UUID) -> dict[str, Any]:
  465. query = self._one("SELECT * FROM queries WHERE id = %s", (query_id,))
  466. jobs = self._all(
  467. """
  468. SELECT * FROM acquisition_jobs
  469. WHERE run_id = %s AND query_id = %s
  470. ORDER BY platform
  471. """,
  472. (run_id, query_id),
  473. )
  474. items = self._all(
  475. """
  476. SELECT ci.* FROM candidate_items ci
  477. JOIN acquisition_jobs aj ON aj.id = ci.job_id
  478. WHERE aj.run_id = %s AND ci.query_id = %s
  479. ORDER BY ci.platform, ci.created_at
  480. """,
  481. (run_id, query_id),
  482. )
  483. item_ids = [row["id"] for row in items]
  484. media: list[dict[str, Any]] = []
  485. classifications: list[dict[str, Any]] = []
  486. if item_ids:
  487. media = self._all(
  488. """
  489. SELECT * FROM media_assets
  490. WHERE item_id = ANY(%s)
  491. ORDER BY item_id, position
  492. """,
  493. (item_ids,),
  494. )
  495. classifications = self._all(
  496. """
  497. SELECT * FROM item_classifications
  498. WHERE item_id = ANY(%s)
  499. ORDER BY created_at DESC
  500. """,
  501. (item_ids,),
  502. )
  503. return {
  504. "query": query,
  505. "jobs": jobs,
  506. "items": items,
  507. "media_assets": media,
  508. "classifications": classifications,
  509. }
  510. def get_latest_singleton_overview(self) -> dict[str, Any]:
  511. batch = self._one_or_none(
  512. """
  513. SELECT * FROM query_batches
  514. WHERE generation_method = %s
  515. ORDER BY created_at DESC
  516. LIMIT 1
  517. """,
  518. ("creation_singleton_v1",),
  519. )
  520. if batch is None:
  521. return {"batch": None, "run": None, "queries": [], "decoded_items": []}
  522. run = self._one_or_none(
  523. """
  524. SELECT * FROM acquisition_runs
  525. WHERE batch_id = %s AND run_key LIKE %s
  526. ORDER BY created_at DESC
  527. LIMIT 1
  528. """,
  529. (batch["id"], "singleton-acquisition:%"),
  530. )
  531. if run is None:
  532. queries = self._all(
  533. """
  534. SELECT
  535. q.id AS query_id,
  536. q.query_text,
  537. q.metadata->>'family_key' AS family_key,
  538. q.sort_order,
  539. 0::int AS candidate_count,
  540. 0::int AS creation_hit_count
  541. FROM queries q
  542. WHERE q.batch_id = %s
  543. ORDER BY q.sort_order, q.created_at
  544. """,
  545. (batch["id"],),
  546. )
  547. return {"batch": batch, "run": None, "queries": queries, "decoded_items": []}
  548. queries = self._all(
  549. """
  550. SELECT
  551. q.id AS query_id,
  552. q.query_text,
  553. q.metadata->>'family_key' AS family_key,
  554. q.sort_order,
  555. COUNT(DISTINCT ci.id)::int AS candidate_count,
  556. COUNT(DISTINCT ic.id) FILTER (
  557. WHERE ic.is_creation_knowledge IS TRUE
  558. )::int AS creation_hit_count
  559. FROM queries q
  560. LEFT JOIN acquisition_jobs aj ON aj.query_id = q.id AND aj.run_id = %s
  561. LEFT JOIN candidate_items ci ON ci.job_id = aj.id
  562. LEFT JOIN item_classifications ic ON ic.item_id = ci.id
  563. WHERE q.batch_id = %s
  564. GROUP BY q.id, q.query_text, q.metadata, q.sort_order
  565. ORDER BY q.sort_order, q.created_at
  566. """,
  567. (run["id"], batch["id"]),
  568. )
  569. decoded_items = self._all(
  570. """
  571. SELECT
  572. ci.id AS item_id,
  573. ci.query_id,
  574. ci.title,
  575. ci.platform,
  576. dr.status AS decode_status,
  577. COUNT(DISTINCT pd.id)::int AS payload_count
  578. FROM candidate_items ci
  579. JOIN acquisition_jobs aj ON aj.id = ci.job_id
  580. JOIN decode_results dr ON dr.item_id = ci.id
  581. LEFT JOIN payload_drafts pd ON pd.item_id = ci.id
  582. WHERE aj.run_id = %s
  583. GROUP BY ci.id, ci.query_id, ci.title, ci.platform, dr.status, dr.created_at
  584. ORDER BY dr.created_at DESC
  585. """,
  586. (run["id"],),
  587. )
  588. return {
  589. "batch": batch,
  590. "run": run,
  591. "queries": queries,
  592. "decoded_items": decoded_items,
  593. }
  594. def list_creation_candidate_items(
  595. self,
  596. *,
  597. run_id: UUID | None = None,
  598. limit: int = 100,
  599. ) -> list[CandidateItem]:
  600. if run_id is None:
  601. rows = self._all(
  602. """
  603. SELECT ci.* FROM candidate_items ci
  604. JOIN item_classifications ic ON ic.item_id = ci.id
  605. WHERE ic.is_creation_knowledge IS TRUE
  606. ORDER BY ci.created_at
  607. LIMIT %s
  608. """,
  609. (limit,),
  610. )
  611. else:
  612. rows = self._all(
  613. """
  614. SELECT ci.* FROM candidate_items ci
  615. JOIN acquisition_jobs aj ON aj.id = ci.job_id
  616. JOIN item_classifications ic ON ic.item_id = ci.id
  617. WHERE aj.run_id = %s AND ic.is_creation_knowledge IS TRUE
  618. ORDER BY ci.created_at
  619. LIMIT %s
  620. """,
  621. (run_id, limit),
  622. )
  623. return [CandidateItem.model_validate(row) for row in rows]
  624. def get_candidate_item(self, item_id: UUID) -> CandidateItem:
  625. row = self._one("SELECT * FROM candidate_items WHERE id = %s", (item_id,))
  626. return CandidateItem.model_validate(row)
  627. def list_media_assets_for_item(self, item_id: UUID) -> list[MediaAsset]:
  628. rows = self._all(
  629. """
  630. SELECT * FROM media_assets
  631. WHERE item_id = %s
  632. ORDER BY position
  633. """,
  634. (item_id,),
  635. )
  636. return [MediaAsset.model_validate(row) for row in rows]