Spaces:
Sleeping
Sleeping
| """Supabase sentiment queries — overview, LLM verification, polarity. | |
| Module-level functions extracted from supabase_client.py. | |
| All functions use get_supabase_client() from the parent module. | |
| """ | |
| import logging | |
| import streamlit as st | |
| logger = logging.getLogger(__name__) | |
| def get_campaign_overview(campaign_id: int) -> dict: | |
| """Get comprehensive overview stats for a campaign via single RPC call. | |
| Uses get_campaign_overview_agg() RPC which performs COUNT(*) FILTER | |
| in a single table scan instead of 6 separate count queries. | |
| Returns: | |
| Dict with total_answers, in_house_negative_count, llm_verified_total, | |
| llm_verified_in_house, llm_pending, llm_confirmed_negative. | |
| Returns zeroed dict on DB error for graceful degradation. | |
| """ | |
| from core.supabase_client import get_supabase_client | |
| _empty = { | |
| "total_answers": 0, | |
| "in_house_negative_count": 0, | |
| "llm_verified_total": 0, | |
| "llm_verified_in_house": 0, | |
| "llm_pending": 0, | |
| "llm_confirmed_negative": 0, | |
| } | |
| try: | |
| client = get_supabase_client() | |
| result = client.rpc( | |
| "get_campaign_overview_agg", {"p_campaign_id": campaign_id} | |
| ).execute() | |
| data = result.data | |
| # RPC may return list-wrapped or JSON string depending on PostgREST | |
| if isinstance(data, list) and data: | |
| data = data[0] | |
| if isinstance(data, str): | |
| import json | |
| data = json.loads(data) | |
| return data if (isinstance(data, dict) and data) else _empty | |
| except Exception: | |
| return _empty | |
| def get_llm_verification_stats( | |
| campaign_id: int, | |
| in_house_only: bool = True, | |
| ) -> dict: | |
| """Get LLM verification statistics via single RPC (avoids VIEW timeout). | |
| Uses get_campaign_sentiment_stats RPC — one table scan with COUNT FILTER. | |
| Args: | |
| campaign_id: Campaign ID | |
| in_house_only: If True, only count in-house brand negatives (default) | |
| Returns: | |
| Dict with total_verified, false_positives, true_negatives counts | |
| """ | |
| from core.supabase_client import get_supabase_client | |
| _empty = {"total_verified": 0, "false_positives": 0, "true_negatives": 0} | |
| try: | |
| client = get_supabase_client() | |
| result = client.rpc( | |
| "get_campaign_sentiment_stats", {"p_campaign_id": campaign_id} | |
| ).execute() | |
| stats = result.data or {} | |
| if isinstance(stats, list) and stats: | |
| stats = stats[0] | |
| if isinstance(stats, str): | |
| import json | |
| stats = json.loads(stats) | |
| if not isinstance(stats, dict): | |
| return _empty | |
| if in_house_only: | |
| return { | |
| "total_verified": stats.get("in_house_llm_verified", 0), | |
| "false_positives": stats.get("in_house_llm_false_pos", 0), | |
| "true_negatives": stats.get("in_house_llm_true_neg", 0), | |
| } | |
| return { | |
| "total_verified": stats.get("llm_verified", 0), | |
| "false_positives": stats.get("llm_false_positive", 0), | |
| "true_negatives": stats.get("llm_true_negative", 0), | |
| } | |
| except Exception: | |
| return _empty | |
| def get_false_positives( | |
| campaign_id: int, | |
| page: int = 1, | |
| page_size: int = 50, | |
| in_house_only: bool = True, | |
| ) -> tuple[list[dict], int]: | |
| """Get false positive items via RPC (avoids VIEW timeout). | |
| Returns: | |
| Tuple of (items list, total count) | |
| """ | |
| from core.supabase_client import get_supabase_client | |
| try: | |
| client = get_supabase_client() | |
| result = client.rpc("get_nudge_export_data", { | |
| "p_campaign_id": campaign_id, | |
| "p_in_house_only": in_house_only, | |
| "p_llm_verified_only": True, | |
| "p_llm_is_negative": False, | |
| "p_limit": 50000, | |
| }).execute() | |
| data = result.data or {} | |
| rows = data.get("rows", []) if isinstance(data, dict) else [] | |
| total = data.get("total", len(rows)) if isinstance(data, dict) else len(rows) | |
| # Sort by llm_verified_at DESC (RPC sorts by analyzed_at) | |
| rows.sort(key=lambda r: r.get("llm_verified_at") or "", reverse=True) | |
| # Client-side pagination | |
| offset = (page - 1) * page_size | |
| return rows[offset:offset + page_size], total | |
| except Exception as e: | |
| logger.warning("get_false_positives failed for campaign %s: %s", campaign_id, e) | |
| return [], 0 | |
| def get_true_negatives( | |
| campaign_id: int, | |
| page: int = 1, | |
| page_size: int = 50, | |
| in_house_only: bool = True, | |
| ) -> tuple[list[dict], int]: | |
| """Get true negative items via RPC (avoids VIEW timeout). | |
| Returns: | |
| Tuple of (items list, total count) | |
| """ | |
| from core.supabase_client import get_supabase_client | |
| try: | |
| client = get_supabase_client() | |
| result = client.rpc("get_nudge_export_data", { | |
| "p_campaign_id": campaign_id, | |
| "p_in_house_only": in_house_only, | |
| "p_llm_verified_only": True, | |
| "p_llm_is_negative": True, | |
| "p_limit": 50000, | |
| }).execute() | |
| data = result.data or {} | |
| rows = data.get("rows", []) if isinstance(data, dict) else [] | |
| total = data.get("total", len(rows)) if isinstance(data, dict) else len(rows) | |
| # Sort by llm_verified_at DESC (RPC sorts by analyzed_at) | |
| rows.sort(key=lambda r: r.get("llm_verified_at") or "", reverse=True) | |
| # Client-side pagination | |
| offset = (page - 1) * page_size | |
| return rows[offset:offset + page_size], total | |
| except Exception as e: | |
| logger.warning("get_true_negatives failed for campaign %s: %s", campaign_id, e) | |
| return [], 0 | |
| def get_llm_verified_for_export( | |
| campaign_id: int, | |
| is_negative: bool, | |
| in_house_only: bool = True, | |
| include_full_answers: bool = False, | |
| ) -> list[dict]: | |
| """Get all LLM verified items for export via single RPC (avoids VIEW timeout). | |
| Returns: | |
| List of items with optional full answer text | |
| """ | |
| from core.supabase_client import get_supabase_client | |
| try: | |
| client = get_supabase_client() | |
| result = client.rpc("get_nudge_export_data", { | |
| "p_campaign_id": campaign_id, | |
| "p_in_house_only": in_house_only, | |
| "p_llm_verified_only": True, | |
| "p_llm_is_negative": is_negative, | |
| "p_limit": 50000, | |
| }).execute() | |
| data = result.data or {} | |
| items = data.get("rows", []) if isinstance(data, dict) else [] | |
| # Fetch full answers from Athena if requested | |
| if include_full_answers and items: | |
| from .athena_client import fetch_full_answers_batch | |
| answer_ids = [item["answer_id"] for item in items if item.get("answer_id")] | |
| if answer_ids: | |
| full_answers = fetch_full_answers_batch(answer_ids) | |
| for item in items: | |
| aid = item.get("answer_id") | |
| if aid and aid in full_answers: | |
| item["answer_full"] = full_answers[aid] | |
| else: | |
| item["answer_full"] = item.get("answer_preview", "") | |
| return items | |
| except Exception as e: | |
| logger.warning("get_llm_verified_for_export failed for campaign %s: %s", campaign_id, e) | |
| return [] | |
| def get_sentiment_data_for_export( | |
| campaign_id: int, | |
| polarity: str | None = None, | |
| llm_status: str | None = None, | |
| in_house_only: bool = True, | |
| include_full_answers: bool = False, | |
| include_evidence: bool = False, | |
| ) -> list[dict]: | |
| """Export sentiment data via RPC (avoids VIEW timeout on large campaigns). | |
| Uses get_nudge_export_data RPC with inline CTE — WHERE campaign_id | |
| is applied before DISTINCT ON, enabling index scan instead of full | |
| VIEW materialization. | |
| Args: | |
| campaign_id: Campaign ID | |
| polarity: Polarity filter ('negative', 'positive', 'neutral', None=all) | |
| llm_status: LLM status filter ('verified', 'false_positive', 'true_negative', 'unverified', None=all) | |
| in_house_only: In-house brand filter | |
| include_full_answers: Include full answer text from Athena | |
| include_evidence: Include LLM evidence fields | |
| Returns: | |
| Filtered data list | |
| """ | |
| from core.supabase_client import get_supabase_client | |
| try: | |
| client = get_supabase_client() | |
| # Map llm_status to RPC parameters | |
| llm_verified_only = llm_status in ("verified", "false_positive", "true_negative") | |
| llm_is_negative = None | |
| if llm_status == "true_negative": | |
| llm_is_negative = True | |
| elif llm_status == "false_positive": | |
| llm_is_negative = False | |
| params = { | |
| "p_campaign_id": campaign_id, | |
| "p_in_house_only": in_house_only, | |
| "p_llm_verified_only": llm_verified_only, | |
| "p_llm_is_negative": llm_is_negative, | |
| "p_limit": 50000, | |
| } | |
| result = client.rpc("get_nudge_export_data", params).execute() | |
| data = result.data or {} | |
| if isinstance(data, dict): | |
| items = data.get("rows", []) or [] | |
| else: | |
| items = data if isinstance(data, list) else [] | |
| # Apply polarity filter (kept client-side for simplicity) | |
| if polarity: | |
| items = [item for item in items if item.get("overall_polarity") == polarity] | |
| # Apply unverified filter (RPC only supports verified=True) | |
| if llm_status == "unverified": | |
| items = [item for item in items if not item.get("llm_verified")] | |
| if include_full_answers and items: | |
| from .athena_client import fetch_full_answers_batch | |
| answer_ids = [item["answer_id"] for item in items if item.get("answer_id")] | |
| if answer_ids: | |
| full_answers = fetch_full_answers_batch(answer_ids) | |
| for item in items: | |
| aid = item.get("answer_id") | |
| if aid and aid in full_answers: | |
| item["answer_full"] = full_answers[aid] | |
| else: | |
| item["answer_full"] = item.get("answer_preview", "") | |
| return items | |
| except Exception as e: | |
| logger.warning("get_sentiment_data_for_export failed for campaign %s: %s", campaign_id, e) | |
| return [] | |
| def get_answers_by_polarity( | |
| campaign_id: int, | |
| polarity: str, | |
| page: int = 1, | |
| page_size: int = 50, | |
| in_house_only: bool = False, | |
| ) -> tuple[list[dict], int]: | |
| """Get answers filtered by polarity via RPC (avoids VIEW timeout). | |
| Returns: | |
| Tuple of (items list, total count) | |
| """ | |
| from core.supabase_client import get_supabase_client | |
| try: | |
| client = get_supabase_client() | |
| offset = (page - 1) * page_size | |
| params: dict = { | |
| "p_campaign_id": campaign_id, | |
| "p_in_house_only": False, | |
| "p_polarity": polarity, | |
| "p_offset": offset, | |
| "p_limit": page_size, | |
| } | |
| if in_house_only: | |
| params["p_has_in_house_brands"] = True | |
| result = client.rpc("get_nudge_export_data", params).execute() | |
| data = result.data or {} | |
| rows = data.get("rows", []) if isinstance(data, dict) else [] | |
| total = data.get("total", len(rows)) if isinstance(data, dict) else len(rows) | |
| return rows, total | |
| except Exception as e: | |
| logger.warning("get_answers_by_polarity failed for campaign %s: %s", campaign_id, e) | |
| return [], 0 | |
| def get_polarity_stats(campaign_id: int, in_house_only: bool = False) -> dict: | |
| """Get polarity distribution via single RPC (avoids VIEW timeout). | |
| Uses get_campaign_sentiment_stats RPC — one table scan with COUNT FILTER. | |
| Returns: | |
| Dict with positive, neutral, negative counts | |
| """ | |
| from core.supabase_client import get_supabase_client | |
| _empty = {"positive": 0, "neutral": 0, "negative": 0} | |
| try: | |
| client = get_supabase_client() | |
| result = client.rpc( | |
| "get_campaign_sentiment_stats", {"p_campaign_id": campaign_id} | |
| ).execute() | |
| stats = result.data or {} | |
| if isinstance(stats, list) and stats: | |
| stats = stats[0] | |
| if isinstance(stats, str): | |
| import json | |
| stats = json.loads(stats) | |
| if not isinstance(stats, dict): | |
| return _empty | |
| if in_house_only: | |
| return { | |
| "positive": stats.get("in_house_positive", 0), | |
| "neutral": stats.get("in_house_neutral", 0), | |
| "negative": stats.get("in_house_negative_polarity", 0), | |
| } | |
| return { | |
| "positive": stats.get("positive", 0), | |
| "neutral": stats.get("neutral", 0), | |
| "negative": stats.get("negative", 0), | |
| } | |
| except Exception: | |
| return _empty | |