"""Supabase research queries — topic clusters, cross-model analysis. Module-level functions extracted from supabase_client.py. All functions use get_supabase_client() from the parent module. """ def get_topic_clusters(campaign_id: int, source: str | None = None) -> list[dict]: """Fetch topic clusters with scores for a campaign, optionally filtered by source. Returns: List of cluster dicts sorted by opportunity_score DESC. """ from core.supabase_client import get_supabase_client client = get_supabase_client() query = ( client.table("topic_clusters") .select( "id, cluster_label, fanout_count, unique_questions, " "attention_score, citation_density, opportunity_score, " "sample_fanouts, top_sources, source" ) .eq("campaign_id", campaign_id) ) if source: query = query.eq("source", source) result = query.order("opportunity_score", desc=True).execute() return result.data or [] def get_topic_map_snapshot(campaign_id: int, source: str | None = None) -> dict | None: """Fetch latest UMAP 2D coordinates for visualization. Returns: Dict with coordinates and algorithm_params, or None. """ from core.supabase_client import get_supabase_client client = get_supabase_client() result = ( client.table("topic_map_snapshots") .select("coordinates, algorithm_params") .eq("campaign_id", campaign_id) .order("created_at", desc=True) .execute() ) if not result.data: return None if source: for snap in result.data: params = snap.get("algorithm_params") or {} if params.get("source") == source: return snap return None return result.data[0] def get_cross_model_analysis( campaign_chatgpt: int, campaign_gemini: int, ) -> dict | None: """Fetch cross-model analysis summary (NMI, match count). Returns: Dict with nmi_score, total_matched_topics, algorithm_params, or None. """ from core.supabase_client import get_supabase_client client = get_supabase_client() result = ( client.table("cross_model_analysis") .select("nmi_score, total_matched_topics, algorithm_params, created_at") .eq("campaign_chatgpt", campaign_chatgpt) .eq("campaign_gemini", campaign_gemini) .limit(1) .execute() ) return result.data[0] if result.data else None def get_gap_scores( campaign_chatgpt: int, campaign_gemini: int, ) -> list[dict]: """Fetch cross-model topic matches with GapScore and quadrant. Returns: List of match dicts with cluster labels, sorted by gap_score DESC. """ from core.supabase_client import get_supabase_client client = get_supabase_client() result = ( client.table("cross_model_topic_matches") .select( "id, chatgpt_cluster_id, gemini_cluster_id, " "match_score, label_similarity, centroid_similarity, " "demand_percentile, supply_percentile, gap_score, quadrant" ) .eq("campaign_chatgpt", campaign_chatgpt) .eq("campaign_gemini", campaign_gemini) .not_.is_("gap_score", "null") .order("gap_score", desc=True) .execute() ) matches = result.data or [] # Enrich with cluster labels (batch query instead of N+1) if matches: chatgpt_ids = [m["chatgpt_cluster_id"] for m in matches] gemini_ids = [m["gemini_cluster_id"] for m in matches] all_ids = list(set(chatgpt_ids + gemini_ids)) label_map = {} label_result = ( client.table("topic_clusters") .select("id, cluster_label") .in_("id", all_ids) .execute() ) for row in (label_result.data or []): label_map[row["id"]] = row.get("cluster_label", "") for m in matches: m["chatgpt_label"] = label_map.get(m["chatgpt_cluster_id"], "") m["gemini_label"] = label_map.get(m["gemini_cluster_id"], "") return matches def find_cross_model_pair(campaign_id: int) -> dict | None: """Find cross-model analysis pair containing this campaign_id. Checks both chatgpt and gemini sides so the sidebar only needs one ID. Returns: Dict with campaign_chatgpt, campaign_gemini, nmi_score, total_matched_topics, or None if no pair exists. """ from core.supabase_client import get_supabase_client client = get_supabase_client() resp = ( client.table("cross_model_analysis") .select("campaign_chatgpt, campaign_gemini, nmi_score, total_matched_topics") .or_(f"campaign_chatgpt.eq.{campaign_id},campaign_gemini.eq.{campaign_id}") .limit(1) .execute() ) if resp.data: return resp.data[0] return None