"""Storage backend ABC — defines how the app persists data. Implementations: - LocalBackend (MVP: writes JSON files to output/) - Future: DatastreamBackend (Kafka producer → Snowflake Datastream → Snowflake tables) """ from __future__ import annotations from abc import ABC, abstractmethod from typing import Any from marketing_intelligence.core.models import ( ArtistSnapshotRecord, CampaignConfigRecord, CampaignSentiment, RunRecord, SoundRecord, ) class StorageBackend(ABC): @abstractmethod async def write_run_record(self, record: RunRecord) -> str | None: """Persist dim_run. Returns path/URI or None.""" @abstractmethod async def write_campaign_post( self, config: CampaignConfigRecord, sound: SoundRecord | None, campaign_id: str, run_id: str, ) -> str | None: """Persist fact_campaign_config + dim_sound. Returns path/URI or None.""" @abstractmethod async def write_post_metrics( self, items: list[dict[str, Any]], campaign_config_key: str, run_id: str, reasoning: str | None = None, ) -> str | None: """Persist fact_campaign_snapshot (metrics + optional discovery reasoning). Returns path/URI or None.""" @abstractmethod async def write_artist_snapshot( self, record: ArtistSnapshotRecord, campaign_id: str ) -> str | None: """Persist fact_artist_snapshot. Returns path/URI or None.""" @abstractmethod async def write_sentiment(self, record: CampaignSentiment) -> str | None: """Persist fact_campaign_sentiment. Returns path/URI or None.""" @abstractmethod async def read_sentiment( self, campaign_config_key: str, run_id: str ) -> dict[str, Any] | None: """Read back a written campaign sentiment record (for PDF generation). None if missing.""" @abstractmethod async def write_pdf_report( self, campaign_config_key: str, run_id: str, local_pdf_path: str ) -> str | None: """Persist a locally-generated PDF report. Returns path/URI or None. The PDF itself is always built on local disk first (reportlab needs a real filesystem path) — this just hands the finished file to the backend to store wherever campaign artifacts live (same directory for LocalBackend, uploaded to S3 for S3Backend). """ @abstractmethod async def read_latest_metrics( self, campaign_config_key: str ) -> dict[str, dict[str, Any]]: """Read latest metrics snapshot for delta computation. Returns {post_id: {views, likes, ...}}.""" @abstractmethod async def write_latest_metrics( self, campaign_config_key: str, metrics: dict[str, Any] ) -> None: """Write latest metrics snapshot for delta computation.""" @abstractmethod async def write_video_comments( self, campaign_id: str, run_id: str, video_id: str, data: dict[str, Any], ) -> str | None: """Persist video comments + sentiment. Returns path/URI or None.""" @abstractmethod async def update_video_comments_sentiment( self, campaign_id: str, run_id: str, video_id: str, sentiment: str | None, confidence: float | None, summary: str | None, key_themes: list[str] | None, ) -> bool: """Update sentiment fields on an existing video comment record. Returns False (no-op) if no record exists for video_id. """ @abstractmethod async def read_all_video_comments( self, campaign_id: str, run_id: str, limit: int | None = None, ) -> list[dict[str, Any]]: """Read unscored video comment records for a run. limit caps the batch size.""" @abstractmethod async def read_all_video_sentiments( self, campaign_id: str, run_id: str, ) -> list[dict[str, Any]]: """Read per-video sentiment summaries (no raw comment_texts) for a run."""