"""Storage tools — persist discovery and watch data.""" from __future__ import annotations import functools import json from datetime import UTC, datetime from typing import Any, Literal import structlog from marketing_intelligence.core.models import ( ArtistSnapshotRecord, CampaignConfigRecord, CampaignPost, CampaignSentiment, RunRecord, SoundRecord, TrendSignal, ) from marketing_intelligence.mcp.app import mcp from marketing_intelligence.mcp.tools.guard import tool_guard from marketing_intelligence.mcp.tools.responses import ( AllVideoCommentsResponse, AllVideoSentimentsResponse, PersistResponse, RunRecordResponse, StoreResponse, VideoForSentiment, VideoSentimentSummary, ) from marketing_intelligence.storage.campaign_config import add_posts from marketing_intelligence.storage.factory import get_backend logger = structlog.get_logger("mcp.tools") class StorageService: """Encapsulates all storage write operations.""" def __init__(self) -> None: self._backend = get_backend() async def persist_metrics( self, items: list[dict[str, Any]], campaign_config_key: str, run_id: str, reasoning: str | None = None, ) -> PersistResponse: """Write post metrics and latest-metrics snapshot to storage.""" try: await self._backend.write_post_metrics( items, campaign_config_key, run_id, reasoning ) except (OSError, ValueError, TypeError) as e: return PersistResponse( success=False, error=str(e)[:300], mission=campaign_config_key, count=len(items), ) now = datetime.now(UTC).isoformat() latest = { str(item.get("video_id") or item.get("post_id") or ""): { "views": item.get("views"), "likes": item.get("likes"), "comments": item.get("comments"), "shares": item.get("shares"), "favorites": item.get("favorites"), "observed_at": item.get("observed_at", now), } for item in items if item.get("video_id") or item.get("post_id") } if latest: await self._backend.write_latest_metrics(campaign_config_key, latest) return PersistResponse( success=True, mission=campaign_config_key, count=len(items) ) async def run_record( self, run_id: str, run_type: str, campaign_key: str | None = None, started_at: str | None = None, finished_at: str | None = None, posts_ingested: int | None = None, is_sample: bool = False, sample_size_target: int | None = None, ) -> RunRecordResponse: """Write a run record (started/finished timestamps, ingested count) to storage.""" now = datetime.now(UTC).isoformat() record = RunRecord( run_id=run_id, run_type=run_type, # type: ignore[arg-type] campaign_key=campaign_key, started_at=started_at or now, finished_at=finished_at, posts_ingested=posts_ingested, is_sample=is_sample, sample_size_target=sample_size_target, ) try: await self._backend.write_run_record(record) except (OSError, ValueError, TypeError) as e: return RunRecordResponse(success=False, error=str(e)[:300], run_id=run_id) return RunRecordResponse(success=True, run_id=record.run_id) async def campaign_post( self, post_id: str, campaign_key: str, artist_key: str, campaign_id: str, run_id: str, content_tier: int, url: str | None = None, sound_id: str | None = None, sound_title: str | None = None, sound_url: str | None = None, hashtags: str | None = None, ) -> StoreResponse: """Write campaign post config plus optional sound record to storage.""" now = datetime.now(UTC).isoformat() config = CampaignConfigRecord( campaign_key=campaign_key, artist_key=artist_key, post_id=post_id, content_tier=content_tier, hashtags=hashtags, is_active=True, ) sound = ( SoundRecord( sound_id=sound_id or "", title=sound_title, url=sound_url, first_seen_at=now, ) if sound_id else None ) try: await self._backend.write_campaign_post(config, sound, campaign_id, run_id) except (OSError, ValueError, TypeError) as e: return StoreResponse( success=False, error=str(e)[:300], mission=campaign_id, count=1 ) add_posts( campaign_id, [ CampaignPost( post_id=post_id, url=url or f"https://www.tiktok.com/video/{post_id}", sound_id=sound_id, campaign_config_key=f"{campaign_key}_{artist_key}", ) ], ) return StoreResponse(success=True, mission=campaign_id, count=1) async def artist_snapshot( self, artist_key: str, campaign_id: str, run_id: str, tiktok_followers: int | None = None, total_creates: int | None = None, total_views: int | None = None, total_likes: int | None = None, total_comments: int | None = None, total_shares: int | None = None, engagement_rate: float | None = None, ) -> StoreResponse: """Write an artist follower/engagement snapshot to storage.""" record = ArtistSnapshotRecord( artist_key=artist_key, run_id=run_id, observed_at=datetime.now(UTC).isoformat(), tiktok_followers=tiktok_followers, total_creates=total_creates, total_views=total_views, total_likes=total_likes, total_comments=total_comments, total_shares=total_shares, engagement_rate=engagement_rate, ) try: await self._backend.write_artist_snapshot(record, campaign_id) except (OSError, ValueError, TypeError) as e: return StoreResponse( success=False, error=str(e)[:300], mission=campaign_id, count=1 ) return StoreResponse(success=True, mission=campaign_id, count=1) async def campaign_sentiment( self, campaign_config_key: str, run_id: str, sentiment: str, confidence: float, summary: str, reasoning: str, how_sound_is_used: str, confidence_rationale: str, detected_topics: list[str] | None = None, primary_hashtags: list[str] | None = None, trend_signals: list[dict[str, Any]] | None = None, virality_factors: list[str] | None = None, video_ids: list[str] | None = None, ) -> StoreResponse: """Write campaign-level sentiment analysis results to storage.""" record = CampaignSentiment( campaign_config_key=campaign_config_key, run_id=run_id, observed_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), sentiment=sentiment, # type: ignore[arg-type] confidence=confidence, summary=summary, reasoning=reasoning, how_sound_is_used=how_sound_is_used, confidence_rationale=confidence_rationale, detected_topics=detected_topics or [], primary_hashtags=primary_hashtags or [], trend_signals=[TrendSignal(**s) for s in trend_signals] if trend_signals else [], virality_factors=virality_factors or [], video_ids=video_ids or [], ) try: await self._backend.write_sentiment(record) except (OSError, ValueError, TypeError) as e: return StoreResponse( success=False, error=str(e)[:300], mission=campaign_config_key, count=1 ) return StoreResponse(success=True, mission=campaign_config_key, count=1) async def video_comments( self, video_id: str, campaign_id: str, run_id: str, url: str, comment_texts: list[str], sentiment: str | None = None, confidence: float | None = None, summary: str | None = None, key_themes: list[str] | None = None, ) -> StoreResponse: """Write per-video comment data, optionally including sentiment analysis.""" data: dict[str, Any] = { "video_id": video_id, "url": url, "comment_count": len(comment_texts), "comment_texts": comment_texts, "sentiment": sentiment, "confidence": confidence, "summary": summary, "key_themes": key_themes or [], "observed_at": datetime.now(UTC).isoformat(), } try: await self._backend.write_video_comments( campaign_id, run_id, video_id, data ) except (OSError, ValueError, TypeError) as e: return StoreResponse( success=False, error=str(e)[:300], mission=campaign_id, count=1 ) return StoreResponse(success=True, mission=campaign_id, count=1) @functools.lru_cache(maxsize=None) def _get_svc() -> StorageService: """Return the singleton StorageService instance.""" return StorageService() # ── MCP tool wrappers ──────────────────────────────────────────────────────── @mcp.tool() @tool_guard(PersistResponse, echo=("campaign_config_key", "run_id")) async def persist_post_metrics( items: list[dict[str, Any]] | str, campaign_config_key: str, run_id: str, reasoning: str | None = None, ) -> PersistResponse: """Persist scraped post metrics. Pass reasoning for discovery runs (writes result.json).""" parsed: list[dict[str, Any]] = ( json.loads(items) if isinstance(items, str) else items ) return await _get_svc().persist_metrics( parsed, campaign_config_key, run_id, reasoning ) @mcp.tool() @tool_guard(RunRecordResponse, echo=("run_id",)) async def write_run_record( run_id: str, run_type: str, campaign_key: str | None = None, started_at: str | None = None, finished_at: str | None = None, posts_ingested: int | None = None, is_sample: bool = False, sample_size_target: int | None = None, ) -> RunRecordResponse: """Write a run record (dim_run). Call at start and end of run.""" return await _get_svc().run_record( run_id, run_type, campaign_key, started_at, finished_at, posts_ingested, is_sample, sample_size_target, ) @mcp.tool() @tool_guard(StoreResponse, echo=("campaign_id", "post_id")) async def write_campaign_post( post_id: str, campaign_key: str, artist_key: str, campaign_id: str, run_id: str, content_tier: int, url: str | None = None, sound_id: str | None = None, sound_title: str | None = None, sound_url: str | None = None, hashtags: str | None = None, ) -> StoreResponse: """Write one post's campaign config + sound. Call once per scraped post.""" return await _get_svc().campaign_post( post_id, campaign_key, artist_key, campaign_id, run_id, content_tier, url, sound_id, sound_title, sound_url, hashtags, ) @mcp.tool() @tool_guard(StoreResponse, echo=("campaign_id",)) async def write_artist_snapshot( artist_key: str, campaign_id: str, run_id: str, tiktok_followers: int | None = None, total_creates: int | None = None, total_views: int | None = None, total_likes: int | None = None, total_comments: int | None = None, total_shares: int | None = None, engagement_rate: float | None = None, ) -> StoreResponse: """Write artist snapshot (fact_artist_snapshot).""" return await _get_svc().artist_snapshot( artist_key, campaign_id, run_id, tiktok_followers, total_creates, total_views, total_likes, total_comments, total_shares, engagement_rate, ) @mcp.tool() @tool_guard(StoreResponse, echo=("campaign_config_key",)) async def write_campaign_sentiment( campaign_config_key: str, run_id: str, sentiment: str, confidence: float, summary: str, reasoning: str, how_sound_is_used: str, confidence_rationale: str, detected_topics: list[str] | None = None, primary_hashtags: list[str] | None = None, trend_signals: list[dict[str, Any]] | None = None, virality_factors: list[str] | None = None, video_ids: list[str] | None = None, ) -> StoreResponse: """Write campaign sentiment analysis (fact_campaign_sentiment).""" return await _get_svc().campaign_sentiment( campaign_config_key, run_id, sentiment, confidence, summary, reasoning, how_sound_is_used, confidence_rationale, detected_topics, primary_hashtags, trend_signals, virality_factors, video_ids, ) @mcp.tool() @tool_guard(StoreResponse, echo=("video_id", "campaign_id")) async def write_video_comments( video_id: str, campaign_id: str, run_id: str, url: str, comment_texts: list[str], sentiment: str | None = None, confidence: float | None = None, summary: str | None = None, key_themes: list[str] | None = None, ) -> StoreResponse: """Write video comments + sentiment. Call once per video after scraping comments.""" return await _get_svc().video_comments( video_id, campaign_id, run_id, url, comment_texts, sentiment, confidence, summary, key_themes, ) @mcp.tool() @tool_guard(StoreResponse, echo=("video_id", "campaign_id")) async def update_video_sentiment( video_id: str, campaign_id: str, run_id: str, sentiment: Literal["positive", "neutral", "negative"], confidence: float, summary: str, key_themes: list[str] | None = None, ) -> StoreResponse: """Update per-video sentiment on a stored comment record.""" backend = get_backend() found = await backend.update_video_comments_sentiment( campaign_id, run_id, video_id, sentiment, confidence, summary, key_themes or [] ) if not found: return StoreResponse( success=False, mission=campaign_id, count=0, error=( f"no comment record found for video_id={video_id!r} — check it against " "read_all_video_comments output, it may be mistyped" ), ) return StoreResponse(success=True, mission=campaign_id, count=1) @mcp.tool() @tool_guard(AllVideoCommentsResponse, echo=("campaign_id", "run_id")) async def read_all_video_comments( campaign_id: str, run_id: str, limit: int = 6, ) -> AllVideoCommentsResponse: """Read up to `limit` video comment records for this run that don't have sentiment yet. Paginated by design: already-scored videos never come back, so call this repeatedly (score everything returned, then call again) until it returns an empty list. """ backend = get_backend() records = await backend.read_all_video_comments(campaign_id, run_id, limit=limit) vfs_fields = list(VideoForSentiment.model_fields.keys()) videos = [ VideoForSentiment(**{k: r[k] for k in vfs_fields if r.get(k) is not None}) for r in records ] return AllVideoCommentsResponse(videos=videos, total_videos=len(videos)) @mcp.tool() @tool_guard(AllVideoSentimentsResponse, echo=("campaign_id", "run_id")) async def read_all_video_sentiments( campaign_id: str, run_id: str, ) -> AllVideoSentimentsResponse: """Read per-video sentiment summaries (no raw comments) for this run. Use at the start of the campaign sentiment stage. """ backend = get_backend() records = await backend.read_all_video_sentiments(campaign_id, run_id) vss_fields = list(VideoSentimentSummary.model_fields.keys()) videos = [ VideoSentimentSummary(**{k: r[k] for k in vss_fields if r.get(k) is not None}) for r in records ] return AllVideoSentimentsResponse(videos=videos, total_videos=len(videos))