Bladeren bron

feat(acquisition): add domain and postgres repository

SamLee 3 weken geleden
bovenliggende
commit
f5fcee6b21
4 gewijzigde bestanden met toevoegingen van 859 en 0 verwijderingen
  1. 141 0
      acquisition/domain.py
  2. 6 0
      acquisition/repositories/__init__.py
  3. 161 0
      acquisition/repositories/base.py
  4. 551 0
      acquisition/repositories/postgres.py

+ 141 - 0
acquisition/domain.py

@@ -0,0 +1,141 @@
+"""Formal acquisition domain objects.
+
+These models describe the cloud pipeline state used by the formal PostgreSQL
+repository layer.
+"""
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Literal
+from uuid import UUID
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+JsonDict = dict[str, Any]
+Platform = Literal["xiaohongshu", "weixin", "douyin"]
+AcquisitionStatus = Literal[
+    "draft",
+    "pending",
+    "running",
+    "candidate",
+    "classified",
+    "done",
+    "failed",
+    "testing",
+]
+
+
+class DomainModel(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+
+class QueryBatch(DomainModel):
+    id: UUID | None = None
+    name: str
+    source_type: str = "manual"
+    generation_method: str | None = None
+    target_platforms: list[str] = Field(default_factory=list)
+    status: str = "draft"
+    metadata: JsonDict = Field(default_factory=dict)
+    error_message: str | None = None
+    created_at: datetime | None = None
+    updated_at: datetime | None = None
+
+
+class Query(DomainModel):
+    id: UUID | None = None
+    batch_id: UUID | None = None
+    query_text: str
+    axes: JsonDict = Field(default_factory=dict)
+    keep: bool | None = None
+    filter_reason: str | None = None
+    status: str = "draft"
+    sort_order: int = 0
+    metadata: JsonDict = Field(default_factory=dict)
+    error_message: str | None = None
+    created_at: datetime | None = None
+    updated_at: datetime | None = None
+
+
+class AcquisitionRun(DomainModel):
+    id: UUID | None = None
+    batch_id: UUID | None = None
+    run_key: str | None = None
+    status: str = "pending"
+    note: str | None = None
+    metadata: JsonDict = Field(default_factory=dict)
+    error_message: str | None = None
+    started_at: datetime | None = None
+    finished_at: datetime | None = None
+    created_at: datetime | None = None
+    updated_at: datetime | None = None
+
+
+class AcquisitionJob(DomainModel):
+    id: UUID | None = None
+    run_id: UUID
+    query_id: UUID | None = None
+    platform: str
+    status: str = "pending"
+    search_limit: int | None = None
+    display_limit: int | None = None
+    attempt_count: int = 0
+    metadata: JsonDict = Field(default_factory=dict)
+    error_message: str | None = None
+    started_at: datetime | None = None
+    finished_at: datetime | None = None
+    created_at: datetime | None = None
+    updated_at: datetime | None = None
+
+
+class CandidateItem(DomainModel):
+    id: UUID | None = None
+    job_id: UUID | None = None
+    query_id: UUID | None = None
+    platform: str
+    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
+    published_at: datetime | None = None
+    raw_summary: str | None = None
+    status: str = "candidate"
+    source_payload: JsonDict = Field(default_factory=dict)
+    metadata: JsonDict = Field(default_factory=dict)
+    error_message: str | None = None
+    created_at: datetime | None = None
+    updated_at: datetime | None = None
+
+
+class MediaAsset(DomainModel):
+    id: UUID | None = None
+    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: JsonDict = Field(default_factory=dict)
+    metadata: JsonDict = Field(default_factory=dict)
+    error_message: str | None = None
+    created_at: datetime | None = None
+    updated_at: datetime | None = None
+
+
+class ItemClassification(DomainModel):
+    id: UUID | None = None
+    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: JsonDict = Field(default_factory=dict)
+    status: str = "pending"
+    error_message: str | None = None
+    created_at: datetime | None = None
+    updated_at: datetime | None = None

+ 6 - 0
acquisition/repositories/__init__.py

@@ -0,0 +1,6 @@
+"""Acquisition repository adapters."""
+
+from acquisition.repositories.base import AcquisitionRepository
+from acquisition.repositories.postgres import PostgresAcquisitionRepository
+
+__all__ = ["AcquisitionRepository", "PostgresAcquisitionRepository"]

+ 161 - 0
acquisition/repositories/base.py

@@ -0,0 +1,161 @@
+"""Repository contracts for the formal acquisition state store."""
+from __future__ import annotations
+
+from typing import Any, Protocol
+from uuid import UUID
+
+from acquisition.domain import (
+    AcquisitionJob,
+    AcquisitionRun,
+    CandidateItem,
+    ItemClassification,
+    MediaAsset,
+    Query,
+    QueryBatch,
+)
+
+
+class AcquisitionRepository(Protocol):
+    """Persistence boundary used by acquisition runners and API routes."""
+
+    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:
+        ...
+
+    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:
+        ...
+
+    def list_queries_for_batch(
+        self,
+        batch_id: UUID,
+        *,
+        keep: bool | None = None,
+    ) -> list[Query]:
+        ...
+
+    def get_query_batch(self, batch_id: UUID) -> QueryBatch:
+        ...
+
+    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:
+        ...
+
+    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:
+        ...
+
+    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:
+        ...
+
+    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:
+        ...
+
+    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:
+        ...
+
+    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:
+        ...
+
+    def get_run_summary(self, run_id: UUID) -> dict[str, Any]:
+        ...
+
+    def get_query_detail(self, *, run_id: UUID, query_id: UUID) -> dict[str, Any]:
+        ...
+
+    def list_creation_candidate_items(
+        self,
+        *,
+        run_id: UUID | None = None,
+        limit: int = 100,
+    ) -> list[CandidateItem]:
+        ...
+
+    def get_candidate_item(self, item_id: UUID) -> CandidateItem:
+        ...
+
+    def list_media_assets_for_item(self, item_id: UUID) -> list[MediaAsset]:
+        ...

+ 551 - 0
acquisition/repositories/postgres.py

@@ -0,0 +1,551 @@
+"""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]