| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551 |
- """PostgreSQL implementation of the formal acquisition repository."""
- from __future__ import annotations
- from typing import Any
- from uuid import UUID
- import psycopg2.extras
- from acquisition.domain import (
- AcquisitionJob,
- AcquisitionRun,
- CandidateItem,
- ItemClassification,
- MediaAsset,
- Query,
- QueryBatch,
- )
- Json = psycopg2.extras.Json
- psycopg2.extras.register_uuid()
- class PostgresAcquisitionRepository:
- """Repository backed by the formal cloud PostgreSQL schema.
- The repository does not commit by itself; callers own transaction scope via
- core.db_session.transaction or an equivalent connection boundary.
- """
- def __init__(self, conn: Any):
- self.conn = conn
- def _one(self, sql: str, params: tuple[Any, ...]) -> dict[str, Any]:
- with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
- cur.execute(sql, params)
- row = cur.fetchone()
- if row is None:
- raise RuntimeError("expected one row, got none")
- return dict(row)
- def _all(self, sql: str, params: tuple[Any, ...]) -> list[dict[str, Any]]:
- with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
- cur.execute(sql, params)
- return [dict(row) for row in cur.fetchall()]
- def create_query_batch(
- self,
- *,
- name: str,
- source_type: str = "manual",
- generation_method: str | None = None,
- target_platforms: list[str] | None = None,
- status: str = "draft",
- metadata: dict[str, Any] | None = None,
- ) -> QueryBatch:
- row = self._one(
- """
- INSERT INTO query_batches(
- name, source_type, generation_method, target_platforms,
- status, metadata
- )
- VALUES (%s, %s, %s, %s, %s, %s)
- RETURNING *
- """,
- (
- name,
- source_type,
- generation_method,
- target_platforms or [],
- status,
- Json(metadata or {}),
- ),
- )
- return QueryBatch.model_validate(row)
- def add_query(
- self,
- *,
- batch_id: UUID | None,
- query_text: str,
- axes: dict[str, Any] | None = None,
- keep: bool | None = None,
- filter_reason: str | None = None,
- status: str = "draft",
- sort_order: int = 0,
- metadata: dict[str, Any] | None = None,
- ) -> Query:
- row = self._one(
- """
- INSERT INTO queries(
- batch_id, query_text, axes, keep, filter_reason,
- status, sort_order, metadata
- )
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
- RETURNING *
- """,
- (
- batch_id,
- query_text,
- Json(axes or {}),
- keep,
- filter_reason,
- status,
- sort_order,
- Json(metadata or {}),
- ),
- )
- return Query.model_validate(row)
- def list_queries_for_batch(
- self,
- batch_id: UUID,
- *,
- keep: bool | None = None,
- ) -> list[Query]:
- if keep is None:
- rows = self._all(
- """
- SELECT * FROM queries
- WHERE batch_id = %s
- ORDER BY sort_order, created_at
- """,
- (batch_id,),
- )
- else:
- rows = self._all(
- """
- SELECT * FROM queries
- WHERE batch_id = %s AND keep IS NOT DISTINCT FROM %s
- ORDER BY sort_order, created_at
- """,
- (batch_id, keep),
- )
- return [Query.model_validate(row) for row in rows]
- def get_query_batch(self, batch_id: UUID) -> QueryBatch:
- row = self._one("SELECT * FROM query_batches WHERE id = %s", (batch_id,))
- return QueryBatch.model_validate(row)
- def create_acquisition_run(
- self,
- *,
- batch_id: UUID | None = None,
- run_key: str | None = None,
- status: str = "pending",
- note: str | None = None,
- metadata: dict[str, Any] | None = None,
- ) -> AcquisitionRun:
- row = self._one(
- """
- INSERT INTO acquisition_runs(batch_id, run_key, status, note, metadata)
- VALUES (%s, %s, %s, %s, %s)
- ON CONFLICT (run_key) DO UPDATE SET
- status = EXCLUDED.status,
- note = EXCLUDED.note,
- metadata = EXCLUDED.metadata
- RETURNING *
- """,
- (batch_id, run_key, status, note, Json(metadata or {})),
- )
- return AcquisitionRun.model_validate(row)
- def ensure_acquisition_job(
- self,
- *,
- run_id: UUID,
- query_id: UUID | None,
- platform: str,
- search_limit: int | None = None,
- display_limit: int | None = None,
- status: str = "pending",
- metadata: dict[str, Any] | None = None,
- ) -> AcquisitionJob:
- row = self._one(
- """
- INSERT INTO acquisition_jobs(
- run_id, query_id, platform, search_limit,
- display_limit, status, metadata
- )
- VALUES (%s, %s, %s, %s, %s, %s, %s)
- ON CONFLICT (run_id, query_id, platform) DO UPDATE SET
- search_limit = EXCLUDED.search_limit,
- display_limit = EXCLUDED.display_limit,
- metadata = EXCLUDED.metadata
- RETURNING *
- """,
- (
- run_id,
- query_id,
- platform,
- search_limit,
- display_limit,
- status,
- Json(metadata or {}),
- ),
- )
- return AcquisitionJob.model_validate(row)
- def update_acquisition_job(
- self,
- job_id: UUID,
- *,
- status: str,
- attempt_count: int | None = None,
- error_message: str | None = None,
- metadata: dict[str, Any] | None = None,
- ) -> AcquisitionJob:
- row = self._one(
- """
- UPDATE acquisition_jobs SET
- status = %s,
- attempt_count = COALESCE(%s, attempt_count),
- error_message = %s,
- metadata = CASE WHEN %s THEN %s ELSE metadata END
- WHERE id = %s
- RETURNING *
- """,
- (
- status,
- attempt_count,
- error_message,
- metadata is not None,
- Json(metadata or {}),
- job_id,
- ),
- )
- return AcquisitionJob.model_validate(row)
- def upsert_candidate_item(
- self,
- *,
- platform: str,
- job_id: UUID | None = None,
- query_id: UUID | None = None,
- platform_item_id: str | None = None,
- canonical_url: str | None = None,
- content_type: str | None = None,
- title: str | None = None,
- author_name: str | None = None,
- raw_summary: str | None = None,
- status: str = "candidate",
- source_payload: dict[str, Any] | None = None,
- metadata: dict[str, Any] | None = None,
- error_message: str | None = None,
- ) -> CandidateItem:
- existing_id = None
- if platform_item_id:
- row = self._one_or_none(
- """
- SELECT id FROM candidate_items
- WHERE platform = %s AND platform_item_id = %s
- ORDER BY created_at DESC
- LIMIT 1
- """,
- (platform, platform_item_id),
- )
- existing_id = row["id"] if row else None
- if existing_id:
- row = self._one(
- """
- UPDATE candidate_items SET
- job_id = %s,
- query_id = %s,
- canonical_url = %s,
- content_type = %s,
- title = %s,
- author_name = %s,
- raw_summary = %s,
- status = %s,
- source_payload = %s,
- metadata = %s,
- error_message = %s
- WHERE id = %s
- RETURNING *
- """,
- (
- job_id,
- query_id,
- canonical_url,
- content_type,
- title,
- author_name,
- raw_summary,
- status,
- Json(source_payload or {}),
- Json(metadata or {}),
- error_message,
- existing_id,
- ),
- )
- else:
- row = self._one(
- """
- INSERT INTO candidate_items(
- job_id, query_id, platform, platform_item_id, canonical_url,
- content_type, title, author_name, raw_summary, status,
- source_payload, metadata, error_message
- )
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
- RETURNING *
- """,
- (
- job_id,
- query_id,
- platform,
- platform_item_id,
- canonical_url,
- content_type,
- title,
- author_name,
- raw_summary,
- status,
- Json(source_payload or {}),
- Json(metadata or {}),
- error_message,
- ),
- )
- return CandidateItem.model_validate(row)
- def _one_or_none(self, sql: str, params: tuple[Any, ...]) -> dict[str, Any] | None:
- with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
- cur.execute(sql, params)
- row = cur.fetchone()
- return dict(row) if row else None
- def add_media_asset(
- self,
- *,
- item_id: UUID,
- media_type: str,
- source_url: str | None = None,
- oss_url: str | None = None,
- cdn_url: str | None = None,
- position: int = 0,
- status: str = "pending",
- source_payload: dict[str, Any] | None = None,
- metadata: dict[str, Any] | None = None,
- ) -> MediaAsset:
- row = self._one(
- """
- INSERT INTO media_assets(
- item_id, media_type, source_url, oss_url, cdn_url,
- position, status, source_payload, metadata
- )
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
- RETURNING *
- """,
- (
- item_id,
- media_type,
- source_url,
- oss_url,
- cdn_url,
- position,
- status,
- Json(source_payload or {}),
- Json(metadata or {}),
- ),
- )
- return MediaAsset.model_validate(row)
- def add_item_classification(
- self,
- *,
- item_id: UUID,
- is_creation_knowledge: bool | None = None,
- label: str | None = None,
- confidence: float | None = None,
- reason: str | None = None,
- model_name: str | None = None,
- prompt_version: str | None = None,
- result_payload: dict[str, Any] | None = None,
- status: str = "pending",
- error_message: str | None = None,
- ) -> ItemClassification:
- row = self._one(
- """
- INSERT INTO item_classifications(
- item_id, is_creation_knowledge, label, confidence, reason,
- model_name, prompt_version, result_payload, status, error_message
- )
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
- RETURNING *
- """,
- (
- item_id,
- is_creation_knowledge,
- label,
- confidence,
- reason,
- model_name,
- prompt_version,
- Json(result_payload or {}),
- status,
- error_message,
- ),
- )
- return ItemClassification.model_validate(row)
- def get_run_summary(self, run_id: UUID) -> dict[str, Any]:
- summary = self._one(
- """
- SELECT
- ar.id,
- ar.run_key,
- ar.batch_id,
- ar.status,
- ar.started_at,
- ar.finished_at,
- COUNT(DISTINCT q.id)::int AS query_count,
- COUNT(DISTINCT aj.id)::int AS job_count,
- COUNT(DISTINCT ci.id)::int AS candidate_count,
- COUNT(DISTINCT ic.id) FILTER (
- WHERE ic.is_creation_knowledge IS TRUE
- )::int AS creation_hit_count
- FROM acquisition_runs ar
- LEFT JOIN acquisition_jobs aj ON aj.run_id = ar.id
- LEFT JOIN queries q ON q.id = aj.query_id
- LEFT JOIN candidate_items ci ON ci.job_id = aj.id
- LEFT JOIN item_classifications ic ON ic.item_id = ci.id
- WHERE ar.id = %s
- GROUP BY ar.id
- """,
- (run_id,),
- )
- summary["queries"] = self._all(
- """
- SELECT
- q.id AS query_id,
- q.query_text,
- COUNT(DISTINCT aj.id)::int AS job_count,
- COUNT(DISTINCT ci.id)::int AS candidate_count,
- COUNT(DISTINCT ic.id) FILTER (
- WHERE ic.is_creation_knowledge IS TRUE
- )::int AS creation_hit_count,
- jsonb_object_agg(
- aj.platform,
- jsonb_build_object(
- 'status', aj.status,
- 'attempt_count', aj.attempt_count,
- 'display_limit', aj.display_limit,
- 'search_limit', aj.search_limit,
- 'error_message', aj.error_message
- )
- ) FILTER (WHERE aj.id IS NOT NULL) AS platforms
- FROM queries q
- JOIN acquisition_jobs aj ON aj.query_id = q.id
- LEFT JOIN candidate_items ci ON ci.job_id = aj.id
- LEFT JOIN item_classifications ic ON ic.item_id = ci.id
- WHERE aj.run_id = %s
- GROUP BY q.id, q.query_text, q.sort_order
- ORDER BY q.sort_order, q.query_text
- """,
- (run_id,),
- )
- return summary
- def get_query_detail(self, *, run_id: UUID, query_id: UUID) -> dict[str, Any]:
- query = self._one("SELECT * FROM queries WHERE id = %s", (query_id,))
- jobs = self._all(
- """
- SELECT * FROM acquisition_jobs
- WHERE run_id = %s AND query_id = %s
- ORDER BY platform
- """,
- (run_id, query_id),
- )
- items = self._all(
- """
- SELECT ci.* FROM candidate_items ci
- JOIN acquisition_jobs aj ON aj.id = ci.job_id
- WHERE aj.run_id = %s AND ci.query_id = %s
- ORDER BY ci.platform, ci.created_at
- """,
- (run_id, query_id),
- )
- item_ids = [row["id"] for row in items]
- media: list[dict[str, Any]] = []
- classifications: list[dict[str, Any]] = []
- if item_ids:
- media = self._all(
- """
- SELECT * FROM media_assets
- WHERE item_id = ANY(%s)
- ORDER BY item_id, position
- """,
- (item_ids,),
- )
- classifications = self._all(
- """
- SELECT * FROM item_classifications
- WHERE item_id = ANY(%s)
- ORDER BY created_at DESC
- """,
- (item_ids,),
- )
- return {
- "query": query,
- "jobs": jobs,
- "items": items,
- "media_assets": media,
- "classifications": classifications,
- }
- def list_creation_candidate_items(
- self,
- *,
- run_id: UUID | None = None,
- limit: int = 100,
- ) -> list[CandidateItem]:
- if run_id is None:
- rows = self._all(
- """
- SELECT ci.* FROM candidate_items ci
- JOIN item_classifications ic ON ic.item_id = ci.id
- WHERE ic.is_creation_knowledge IS TRUE
- ORDER BY ci.created_at
- LIMIT %s
- """,
- (limit,),
- )
- else:
- rows = self._all(
- """
- SELECT ci.* FROM candidate_items ci
- JOIN acquisition_jobs aj ON aj.id = ci.job_id
- JOIN item_classifications ic ON ic.item_id = ci.id
- WHERE aj.run_id = %s AND ic.is_creation_knowledge IS TRUE
- ORDER BY ci.created_at
- LIMIT %s
- """,
- (run_id, limit),
- )
- return [CandidateItem.model_validate(row) for row in rows]
- def get_candidate_item(self, item_id: UUID) -> CandidateItem:
- row = self._one("SELECT * FROM candidate_items WHERE id = %s", (item_id,))
- return CandidateItem.model_validate(row)
- def list_media_assets_for_item(self, item_id: UUID) -> list[MediaAsset]:
- rows = self._all(
- """
- SELECT * FROM media_assets
- WHERE item_id = %s
- ORDER BY position
- """,
- (item_id,),
- )
- return [MediaAsset.model_validate(row) for row in rows]
|