"""Local file storage backend — MVP implementation. Writes JSON files to output/ directory. No external dependencies. """ from __future__ import annotations import json from datetime import UTC, datetime from pathlib import Path from typing import Any, cast import structlog from marketing_intelligence.core.config import settings from marketing_intelligence.core.models import ( ArtistSnapshotRecord, CampaignConfigRecord, CampaignDiscoveryRunReasoning, CampaignSentiment, PostMetrics, RunRecord, SoundRecord, ) from marketing_intelligence.storage.backend import StorageBackend logger = structlog.get_logger("storage.local") # manifest.json/request.json live in the same comments/{campaign_id}/{run_id}/ folder as video # records — must be excluded from any glob that expects only video records. _NON_VIDEO_FILES = {"manifest.json", "request.json"} def _out() -> Path: p = Path(settings.output_dir) p.mkdir(parents=True, exist_ok=True) return p def _write(path: Path, data: dict[str, Any]) -> str: path.parent.mkdir(parents=True, exist_ok=True) path.write_text( json.dumps(data, indent=2, ensure_ascii=False, default=str), encoding="utf-8" ) return str(path) class LocalBackend(StorageBackend): async def write_run_record(self, record: RunRecord) -> str | None: path = _out() / "runs" / f"{record.run_id}.json" return _write(path, record.model_dump(mode="json")) async def write_campaign_post( self, config: CampaignConfigRecord, sound: SoundRecord | None, campaign_id: str, run_id: str, ) -> str | None: data: dict[str, Any] = {"campaign_config": config.model_dump(mode="json")} if sound: data["sound"] = sound.model_dump(mode="json") path = _out() / "posts" / campaign_id / run_id / f"{config.post_id}.json" return _write(path, data) async def write_post_metrics( self, items: list[dict[str, Any]], campaign_config_key: str, run_id: str, reasoning: str | None = None, ) -> str | None: now = datetime.now(UTC).isoformat() messages = [] for item in items: post_id = str(item.get("video_id") or item.get("post_id") or "") if not post_id: continue messages.append( PostMetrics.from_observation( campaign_config_key=campaign_config_key, post_id=post_id, run_id=run_id, observed_at=item.get("observed_at", now), sound_id=item.get("sound_id"), views=item.get("views"), likes=item.get("likes"), comments=item.get("comments"), shares=item.get("shares"), favorites=item.get("favorites"), prev=item.get("_prev"), ).model_dump() ) if not messages: return None if reasoning: result = CampaignDiscoveryRunReasoning( campaign_config_key=campaign_config_key, run_id=run_id, count=len(messages), reasoning=reasoning, items=items, ) path = _out() / campaign_config_key / run_id / "result.json" _write(path, result.model_dump(exclude_none=True)) path = _out() / "metrics" / campaign_config_key / f"{run_id}.json" return _write( path, { "campaign_config_key": campaign_config_key, "run_id": run_id, "items": messages, }, ) async def write_artist_snapshot( self, record: ArtistSnapshotRecord, campaign_id: str ) -> str | None: path = _out() / "artist_snapshots" / campaign_id / f"{record.run_id}.json" return _write(path, record.model_dump(mode="json")) async def write_sentiment(self, record: CampaignSentiment) -> str | None: path = ( _out() / "sentiment" / record.campaign_config_key / f"{record.run_id}.json" ) return _write(path, record.model_dump(mode="json")) async def read_sentiment( self, campaign_config_key: str, run_id: str ) -> dict[str, Any] | None: path = _out() / "sentiment" / campaign_config_key / f"{run_id}.json" if not path.exists(): return None return cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8"))) async def write_pdf_report( self, campaign_config_key: str, run_id: str, local_pdf_path: str ) -> str | None: import shutil dest = _out() / "reports" / campaign_config_key / f"{run_id}.pdf" dest.parent.mkdir(parents=True, exist_ok=True) shutil.move(local_pdf_path, dest) return str(dest) async def read_latest_metrics( self, campaign_config_key: str ) -> dict[str, dict[str, Any]]: path = _out() / "snapshots" / campaign_config_key / "latest.json" if not path.exists(): return {} try: return cast( dict[str, dict[str, Any]], json.loads(path.read_text(encoding="utf-8")) ) except Exception: return {} async def write_latest_metrics( self, campaign_config_key: str, metrics: dict[str, Any] ) -> None: path = _out() / "snapshots" / campaign_config_key / "latest.json" _write(path, metrics) async def write_video_comments( self, campaign_id: str, run_id: str, video_id: str, data: dict[str, Any], ) -> str | None: path = _out() / "comments" / campaign_id / run_id / f"{video_id}.json" return _write(path, data) 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: path = _out() / "comments" / campaign_id / run_id / f"{video_id}.json" if not path.exists(): return False data = json.loads(path.read_text(encoding="utf-8")) data.update( { "sentiment": sentiment, "confidence": confidence, "summary": summary, "key_themes": key_themes or [], } ) _write(path, data) return True async def read_all_video_comments( self, campaign_id: str, run_id: str, limit: int | None = None, ) -> list[dict[str, Any]]: folder = _out() / "comments" / campaign_id / run_id if not folder.exists(): return [] results = [] for p in sorted(folder.glob("*.json")): if p.name in _NON_VIDEO_FILES: continue d = json.loads(p.read_text(encoding="utf-8")) if d.get("sentiment") is not None: continue results.append( { "video_id": d.get("video_id"), "url": d.get("url"), "track_name": d.get("track_name"), "caption": d.get("caption"), "hashtags": d.get("hashtags", []), "sound_id": d.get("sound_id"), "is_original_sound": d.get("is_original_sound", False), "location": d.get("location"), "video_description": d.get("video_description"), "comment_count": d.get("comment_count", 0), "comment_texts": d.get("comment_texts", []), } ) if limit is not None and len(results) >= limit: break return results async def read_all_video_sentiments( self, campaign_id: str, run_id: str, ) -> list[dict[str, Any]]: folder = _out() / "comments" / campaign_id / run_id if not folder.exists(): return [] results = [] for p in sorted(folder.glob("*.json")): if p.name in _NON_VIDEO_FILES: continue d = json.loads(p.read_text(encoding="utf-8")) results.append( { "video_id": d.get("video_id"), "url": d.get("url"), "track_name": d.get("track_name"), "comment_count": d.get("comment_count", 0), "sentiment": d.get("sentiment"), "confidence": d.get("confidence"), "summary": d.get("summary"), "key_themes": d.get("key_themes", []), "video_description": d.get("video_description"), } ) return results