chainshift-dashboard / core /supabase_sentiment.py
GitHub Action
Sync from GitHub
ef78361
Raw
History Blame Contribute Delete
13 kB
"""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
@st.cache_data(ttl=60)
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
@st.cache_data(ttl=60)
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 []
@st.cache_data(ttl=60)
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