"""Cross-Model Quadrant Matrix + Convergence Analysis (ADR-014 Phase 3). Demand (ChatGPT fanout) vs Supply (Gemini citation) gap analysis. Scatter plot + GapScore Top 10 + Convergence (Venn, matched/unmatched topics). """ import statistics import streamlit as st import pandas as pd import plotly.graph_objects as go from core.supabase_client import ( get_cross_model_analysis, get_gap_scores, get_topic_clusters, ) # Quadrant colors matching ADR-014 spec QUADRANT_COLORS = { "OPPORTUNITY": "#10B981", # Green "SATURATED": "#3B82F6", # Blue "LATENT_AUTHORITY": "#F59E0B", # Amber "NICHE": "#9CA3AF", # Gray } QUADRANT_LABELS = { "OPPORTUNITY": "Opportunity", "SATURATED": "Saturated", "LATENT_AUTHORITY": "Latent Authority", "NICHE": "Niche", } QUADRANT_ACTIONS = { "OPPORTUNITY": "사용자 관심이 높지만 인용 콘텐츠가 부족합니다. " "이 주제에 전문 콘텐츠를 제작하면 AI 답변에 인용될 가능성이 높습니다.", "SATURATED": "수요와 공급 모두 높은 경쟁 영역입니다. " "차별화된 전문성이나 고유 데이터로 기존 콘텐츠와 차별화하세요.", "LATENT_AUTHORITY": "이미 인용되고 있지만 검색 수요는 낮습니다. " "기존 콘텐츠를 활용하여 브랜드 권위를 강화하세요.", "NICHE": "수요와 공급 모두 낮은 틈새 영역입니다. " "시장 변화를 모니터링하며 기회가 커지면 진입을 검토하세요.", } def render_cross_model(base_ctx: dict, pair: dict): """Render cross-model quadrant matrix UI. Args: base_ctx: Dashboard base context with campaign_id etc. pair: Cross-model pair dict from find_cross_model_pair(). """ campaign_chatgpt = pair["campaign_chatgpt"] campaign_gemini = pair["campaign_gemini"] st.caption( "ChatGPT와 Gemini 두 AI 모델의 토픽을 비교하여 " "**콘텐츠 수요-공급 Gap**을 분석합니다." ) with st.expander("Cross-Model 분석이란?", expanded=False): st.markdown(""" **왜 두 모델을 비교하나요?** ChatGPT와 Gemini는 같은 주제에 대해 서로 다른 방식으로 정보를 탐색합니다: - **ChatGPT (Demand)**: 사용자 질문을 여러 하위 질문으로 분해하여 검색합니다. AI가 자주 검색하는 토픽 = **사용자 관심이 높은 토픽** - **Gemini (Supply)**: 답변에 실제 웹 콘텐츠를 인용합니다. AI가 자주 인용하는 토픽 = **콘텐츠 공급이 충분한 토픽** **두 신호를 교차 분석**하면, "사람들이 많이 물어보지만 아직 좋은 콘텐츠가 없는 영역"을 데이터 기반으로 발견할 수 있습니다. **분석 프로세스:** ``` ChatGPT 하위 질문 클러스터링 (Demand 토픽) ↓ Gemini 인용 문구 클러스터링 (Supply 토픽) ↓ 두 모델의 유사 토픽 매칭 (Label + Centroid 유사도) ↓ Demand-Supply Gap 계산 → Quadrant 분류 ``` """) # Fetch data analysis = get_cross_model_analysis(campaign_chatgpt, campaign_gemini) matches = get_gap_scores(campaign_chatgpt, campaign_gemini) if not analysis or not matches: st.warning("Cross-Model 분석 데이터를 불러올 수 없습니다.") return # Fetch clusters for convergence analysis chatgpt_clusters = get_topic_clusters(campaign_chatgpt, source="chatgpt") gemini_clusters = get_topic_clusters(campaign_gemini, source="gemini") # --- A) Alignment Overview --- _render_overview(analysis, matches) st.markdown("---") # --- B) Quadrant Scatter Plot --- _render_scatter(matches) st.markdown("---") # --- C) GapScore Top 10 --- _render_top_gaps(matches) st.markdown("---") # --- D) Convergence Analysis (Phase 3.4) --- _render_convergence( analysis, matches, chatgpt_clusters, gemini_clusters, ) def _render_overview(analysis: dict, matches: list[dict]): """Render alignment overview metrics.""" alignment = float(analysis.get("nmi_score", 0)) total_matched = int(analysis.get("total_matched_topics", 0)) # Count by quadrant quadrant_counts = {} gap_scores = [] for m in matches: q = m.get("quadrant", "NICHE") quadrant_counts[q] = quadrant_counts.get(q, 0) + 1 gap_scores.append(float(m.get("gap_score", 0))) opp_count = quadrant_counts.get("OPPORTUNITY", 0) mean_gap = sum(gap_scores) / len(gap_scores) if gap_scores else 0 # Color-code alignment if alignment >= 0.8: align_color = "green" align_label = "Strong" elif alignment >= 0.6: align_color = "orange" align_label = "Moderate" else: align_color = "red" align_label = "Weak" c1, c2, c3, c4 = st.columns(4) with c1: st.metric( "모델 정합도", f"{alignment:.4f}", help="ChatGPT와 Gemini 토픽 매칭 품질. " "0.5 이상이면 두 모델이 유사한 주제를 다루고 있어 Gap 분석이 신뢰할 수 있습니다.", ) st.caption(f":{align_color}[{align_label}]") with c2: st.metric( "매칭된 토픽", f"{total_matched}쌍", help="두 AI 모델에서 동일한 주제로 매칭된 토픽 쌍 수", ) with c3: st.metric( "콘텐츠 기회", f"{opp_count}개", help="Demand 높음 + Supply 낮음인 토픽 수. " "이 토픽들에 콘텐츠를 만들면 AI 인용 가능성이 높습니다.", ) with c4: st.metric( "평균 GapScore", f"{mean_gap:.4f}", help="전체 매칭 토픽의 평균 Demand-Supply Gap. " "높을수록 전반적으로 콘텐츠 기회가 많음을 의미합니다.", ) def _render_scatter(matches: list[dict]): """Render demand vs supply quadrant scatter plot.""" st.markdown(""" **ChatGPT가 자주 검색하는 토픽**(Demand)과 **Gemini가 실제 인용하는 토픽**(Supply)을 매칭하여 콘텐츠 기회를 시각화합니다. 각 점은 두 AI 모델에서 동일한 주제로 매칭된 토픽 쌍입니다. | Quadrant | 위치 | 의미 | 전략 | |----------|------|------|------| | **Opportunity** | 좌상단 | Demand 높음 + Supply 낮음 | 콘텐츠 선점 기회 -- 우선 제작 | | **Saturated** | 우상단 | Demand 높음 + Supply 높음 | 차별화 필요 -- 전문성 강화 | | **Latent Authority** | 우하단 | Demand 낮음 + Supply 높음 | 이미 인용됨 -- 브랜드 권위 활용 | | **Niche** | 좌하단 | Demand 낮음 + Supply 낮음 | 낮은 우선순위 -- 변화 모니터링 | """) xs, ys, colors, hover_texts, sizes = [], [], [], [], [] for m in matches: supply = float(m.get("supply_percentile", 0)) demand = float(m.get("demand_percentile", 0)) quadrant = m.get("quadrant", "NICHE") gap = float(m.get("gap_score", 0)) match_score = float(m.get("match_score", 0)) chatgpt_label = m.get("chatgpt_label", "") gemini_label = m.get("gemini_label", "") xs.append(supply) ys.append(demand) colors.append(QUADRANT_COLORS.get(quadrant, "#9CA3AF")) sizes.append(max(8, min(30, gap * 300))) hover_texts.append( f"{chatgpt_label}
" f"Gemini: {gemini_label}
" f"Demand: {demand:.2%}
" f"Supply: {supply:.2%}
" f"GapScore: {gap:.4f}
" f"Match: {match_score:.4f}
" f"Quadrant: {QUADRANT_LABELS.get(quadrant, quadrant)}" ) fig = go.Figure() fig.add_trace(go.Scatter( x=xs, y=ys, mode="markers", marker=dict( size=sizes, color=colors, opacity=0.7, line=dict(width=0.5, color="#333"), ), text=hover_texts, hoverinfo="text", showlegend=False, )) # Compute actual medians from data (matches quadrant_method=p50_median in gap scorer) demand_median = statistics.median(ys) if len(ys) > 1 else 0.5 supply_median = statistics.median(xs) if len(xs) > 1 else 0.5 fig.add_hline(y=demand_median, line_dash="dash", line_color="#9CA3AF", opacity=0.5) fig.add_vline(x=supply_median, line_dash="dash", line_color="#9CA3AF", opacity=0.5) # Quadrant annotations — axes: X=Supply, Y=Demand # OPPORTUNITY: high demand (top) + low supply (left) → top-left # SATURATED: high demand (top) + high supply (right) → top-right # LATENT_AUTHORITY: low demand (bottom) + high supply (right) → bottom-right # NICHE: low demand (bottom) + low supply (left) → bottom-left fig.add_annotation(x=0.05, y=0.95, text="Opportunity", showarrow=False, font=dict(size=11, color=QUADRANT_COLORS["OPPORTUNITY"])) fig.add_annotation(x=0.95, y=0.95, text="Saturated", showarrow=False, font=dict(size=11, color=QUADRANT_COLORS["SATURATED"])) fig.add_annotation(x=0.95, y=0.05, text="Latent Authority", showarrow=False, font=dict(size=11, color=QUADRANT_COLORS["LATENT_AUTHORITY"])) fig.add_annotation(x=0.05, y=0.05, text="Niche", showarrow=False, font=dict(size=11, color=QUADRANT_COLORS["NICHE"])) fig.update_layout( title="Demand vs Supply Quadrant Matrix", xaxis_title="Supply Percentile (Gemini Citation)", yaxis_title="Demand Percentile (ChatGPT Fanout)", xaxis=dict(range=[-0.05, 1.05]), yaxis=dict(range=[-0.05, 1.05]), height=600, template="plotly_white", hoverlabel=dict(bgcolor="white", font_size=12), ) st.plotly_chart(fig, use_container_width=True, key="cross_model:scatter", config={"displayModeBar": False}) # Quadrant count summary quadrant_counts = {} for m in matches: q = m.get("quadrant", "NICHE") quadrant_counts[q] = quadrant_counts.get(q, 0) + 1 q1, q2, q3, q4 = st.columns(4) with q1: st.metric("🟢 Opportunity", f"{quadrant_counts.get('OPPORTUNITY', 0)}개", help="AI가 자주 검색하지만 인용 콘텐츠가 부족한 토픽. 콘텐츠 선점 기회.") with q2: st.metric("🔵 Saturated", f"{quadrant_counts.get('SATURATED', 0)}개", help="검색도 많고 인용도 많은 경쟁 토픽. 차별화 전략 필요.") with q3: st.metric("🟡 Latent Authority", f"{quadrant_counts.get('LATENT_AUTHORITY', 0)}개", help="이미 인용되고 있지만 검색 수요는 낮은 토픽. 브랜드 권위 활용.") with q4: st.metric("⚪ Niche", f"{quadrant_counts.get('NICHE', 0)}개", help="수요와 공급 모두 낮은 틈새 영역. 변화 모니터링.") st.caption(f"Demand Median: {demand_median:.4f} | Supply Median: {supply_median:.4f}") def _render_top_gaps(matches: list[dict]): """Render GapScore Top 10 with detail expanders.""" st.markdown("#### GapScore Top 10") st.caption("Demand-Supply Gap이 큰 토픽일수록 콘텐츠 기회가 높습니다.") top10 = matches[:10] for i, m in enumerate(top10, 1): chatgpt_label = m.get("chatgpt_label", "Unknown") gemini_label = m.get("gemini_label", "Unknown") gap = float(m.get("gap_score", 0)) quadrant = m.get("quadrant", "NICHE") with st.expander( f"#{i} {chatgpt_label} | GapScore: {gap:.4f}", key=f"cross_model:gap_{m['id']}", ): left, right = st.columns(2) with left: demand = float(m.get("demand_percentile") or 0) supply = float(m.get("supply_percentile") or 0) match_score = float(m.get("match_score") or 0) st.markdown("**Metrics**") st.write(f"- Demand Percentile: {demand:.2%}") st.write(f"- Supply Percentile: {supply:.2%}") st.write(f"- Match Score: {match_score:.4f}") st.write(f"- ChatGPT Topic: {chatgpt_label}") st.write(f"- Gemini Topic: {gemini_label}") with right: q_label = QUADRANT_LABELS.get(quadrant, quadrant) action = QUADRANT_ACTIONS.get(quadrant, "") st.markdown("**Quadrant & Action**") st.markdown(f":{_st_color(quadrant)}[**{q_label}**]") st.info(action) def _st_color(quadrant: str) -> str: """Map quadrant to Streamlit markdown color name.""" return { "OPPORTUNITY": "green", "SATURATED": "blue", "LATENT_AUTHORITY": "orange", "NICHE": "gray", }.get(quadrant, "gray") # --------------------------------------------------------------------------- # Phase 3.4: Convergence Analysis # --------------------------------------------------------------------------- def _render_convergence( analysis: dict, matches: list[dict], chatgpt_clusters: list[dict], gemini_clusters: list[dict], ): """Render convergence analysis: Venn, matched/unmatched topic lists.""" st.markdown("#### 수렴 분석 (Convergence)") st.caption( "ChatGPT(Demand)와 Gemini(Supply) 토픽이 얼마나 겹치는지 분석합니다. " "매칭되지 않은 토픽은 한쪽 모델에서만 나타나는 고유 신호입니다." ) # Compute matched / unmatched sets matched_chatgpt_ids = {m["chatgpt_cluster_id"] for m in matches} matched_gemini_ids = {m["gemini_cluster_id"] for m in matches} all_chatgpt_ids = {c["id"] for c in chatgpt_clusters} all_gemini_ids = {c["id"] for c in gemini_clusters} unmatched_chatgpt_ids = all_chatgpt_ids - matched_chatgpt_ids unmatched_gemini_ids = all_gemini_ids - matched_gemini_ids n_chatgpt_only = len(unmatched_chatgpt_ids) n_matched = len(matches) n_gemini_only = len(unmatched_gemini_ids) n_total = n_chatgpt_only + n_matched + n_gemini_only # --- Venn-style overlap chart --- _render_venn_chart(n_chatgpt_only, n_matched, n_gemini_only) # --- Alignment Score gauge --- alignment = float(analysis.get("nmi_score") or 0) _render_alignment_gauge(alignment, n_matched, n_total) st.markdown("---") # --- Matched topics table --- _render_matched_topics(matches) st.markdown("---") # --- Unmatched topics per model --- _render_unmatched_topics( chatgpt_clusters, gemini_clusters, unmatched_chatgpt_ids, unmatched_gemini_ids, ) def _render_venn_chart( n_chatgpt_only: int, n_matched: int, n_gemini_only: int, ): """Render Venn-style horizontal stacked bar showing overlap proportions.""" n_total = n_chatgpt_only + n_matched + n_gemini_only if n_total == 0: return pct_chatgpt = n_chatgpt_only / n_total * 100 pct_matched = n_matched / n_total * 100 pct_gemini = n_gemini_only / n_total * 100 fig = go.Figure() fig.add_trace(go.Bar( y=["토픽 분포"], x=[pct_chatgpt], name=f"ChatGPT 고유 ({n_chatgpt_only})", orientation="h", marker_color="#3B82F6", text=f"{pct_chatgpt:.0f}%", textposition="inside", hovertemplate=( f"ChatGPT 고유 토픽: {n_chatgpt_only}개
" f"비율: {pct_chatgpt:.1f}%" ), )) fig.add_trace(go.Bar( y=["토픽 분포"], x=[pct_matched], name=f"공통 매칭 ({n_matched})", orientation="h", marker_color="#10B981", text=f"{pct_matched:.0f}%", textposition="inside", hovertemplate=( f"공통 매칭 토픽: {n_matched}개
" f"비율: {pct_matched:.1f}%" ), )) fig.add_trace(go.Bar( y=["토픽 분포"], x=[pct_gemini], name=f"Gemini 고유 ({n_gemini_only})", orientation="h", marker_color="#F59E0B", text=f"{pct_gemini:.0f}%", textposition="inside", hovertemplate=( f"Gemini 고유 토픽: {n_gemini_only}개
" f"비율: {pct_gemini:.1f}%" ), )) fig.update_layout( barmode="stack", height=120, margin=dict(l=0, r=0, t=30, b=0), title="토픽 겹침 분포 (Venn)", xaxis=dict(title="비율 (%)", range=[0, 100]), yaxis=dict(visible=False), template="plotly_white", legend=dict(orientation="h", yanchor="bottom", y=-0.5), ) st.plotly_chart(fig, use_container_width=True, key="cross_model:venn", config={"displayModeBar": False}) # Summary metrics c1, c2, c3 = st.columns(3) with c1: st.metric( "ChatGPT 고유", f"{n_chatgpt_only}개", help="ChatGPT에서만 발견된 Demand 토픽. " "소비자가 관심 있지만 Gemini가 아직 인용하지 않는 영역.", ) with c2: st.metric( "공통 매칭", f"{n_matched}개", help="두 모델 모두에서 발견된 토픽. " "Demand와 Supply가 만나는 핵심 영역.", ) with c3: st.metric( "Gemini 고유", f"{n_gemini_only}개", help="Gemini에서만 인용되는 Supply 토픽. " "AI가 근거로 사용하지만 소비자 검색 수요가 낮은 영역.", ) def _render_alignment_gauge(alignment: float, n_matched: int, n_total: int): """Render alignment score as a gauge chart with interpretation.""" coverage = n_matched / n_total * 100 if n_total > 0 else 0 fig = go.Figure(go.Indicator( mode="gauge+number", value=alignment, number=dict(suffix="", valueformat=".4f"), gauge=dict( axis=dict(range=[0, 1], tickvals=[0, 0.3, 0.6, 0.8, 1.0]), bar=dict(color="#059669"), steps=[ dict(range=[0, 0.3], color="#FEE2E2"), dict(range=[0.3, 0.6], color="#FEF3C7"), dict(range=[0.6, 0.8], color="#D1FAE5"), dict(range=[0.8, 1.0], color="#A7F3D0"), ], threshold=dict( line=dict(color="#059669", width=2), thickness=0.75, value=alignment, ), ), title=dict(text="Alignment Score"), )) fig.update_layout( height=250, margin=dict(l=30, r=30, t=50, b=10), template="plotly_white", ) left, right = st.columns([2, 1]) with left: st.plotly_chart(fig, use_container_width=True, key="cross_model:gauge", config={"displayModeBar": False}) with right: if alignment >= 0.8: st.success( f"**Strong** — 두 모델이 매우 유사한 토픽을 다루고 있습니다. " f"Gap 분석의 신뢰도가 높습니다." ) elif alignment >= 0.6: st.warning( f"**Moderate** — 부분적으로 겹치는 토픽이 있습니다. " f"Gap 분석은 참고용으로 활용하세요." ) else: st.error( f"**Weak** — 두 모델의 토픽 유사도가 낮습니다. " f"각 모델의 개별 뷰를 우선 참고하세요." ) st.caption(f"토픽 커버리지: {coverage:.1f}% ({n_matched}/{n_total})") def _render_matched_topics(matches: list[dict]): """Render matched topic pairs table with scores.""" st.markdown("#### 매칭된 토픽 쌍") st.caption( "두 모델에서 동일한 주제로 매칭된 토픽입니다. " "Match Score가 높을수록 두 토픽의 유사도가 높습니다." ) rows = [] for i, m in enumerate(matches, 1): rows.append({ "#": i, "ChatGPT 토픽": (m.get("chatgpt_label") or "")[:35], "Gemini 토픽": (m.get("gemini_label") or "")[:35], "Match Score": f"{float(m.get('match_score') or 0):.4f}", "Label Sim": f"{float(m.get('label_similarity') or 0):.4f}", "Centroid Sim": f"{float(m.get('centroid_similarity') or 0):.4f}", "GapScore": f"{float(m.get('gap_score') or 0):.4f}", "Quadrant": QUADRANT_LABELS.get(m.get("quadrant", "NICHE"), "Niche"), }) if rows: df = pd.DataFrame(rows) st.dataframe(df, use_container_width=True, hide_index=True) # Match quality stats if matches: scores = [float(m.get("match_score") or 0) for m in matches] avg_score = sum(scores) / len(scores) min_score = min(scores) max_score = max(scores) st.caption( f"Match Score — 평균: {avg_score:.4f} | " f"최소: {min_score:.4f} | 최대: {max_score:.4f}" ) def _render_unmatched_topics( chatgpt_clusters: list[dict], gemini_clusters: list[dict], unmatched_chatgpt_ids: set, unmatched_gemini_ids: set, ): """Render unmatched (model-specific) topics.""" st.markdown("#### 모델별 고유 토픽") st.caption( "한쪽 모델에서만 나타나는 토픽입니다. " "매칭되지 않은 토픽은 해당 모델 고유의 신호를 나타냅니다." ) left, right = st.columns(2) with left: st.markdown("**ChatGPT 고유 토픽 (Demand Only)**") st.caption( "소비자가 관심 있지만 Gemini가 인용하지 않는 토픽. " "아직 콘텐츠가 부족하여 AI가 근거를 찾지 못하는 영역일 수 있습니다." ) unmatched_chatgpt = [ c for c in chatgpt_clusters if c["id"] in unmatched_chatgpt_ids ] # Sort by opportunity_score DESC (already sorted from DB, but filter may reorder) unmatched_chatgpt.sort( key=lambda c: float(c.get("opportunity_score") or 0), reverse=True, ) if unmatched_chatgpt: rows = [] for c in unmatched_chatgpt[:20]: rows.append({ "토픽": (c.get("cluster_label") or f"Cluster-{c['id'][:8]}")[:30], "Opportunity": f"{float(c.get('opportunity_score') or 0):.4f}", "Attention": f"{float(c.get('attention_score') or 0):.4f}", "Fanouts": c.get("fanout_count", 0), }) st.dataframe( pd.DataFrame(rows), use_container_width=True, hide_index=True, ) if len(unmatched_chatgpt) > 20: st.caption(f"... 외 {len(unmatched_chatgpt) - 20}개") else: st.info("모든 ChatGPT 토픽이 Gemini와 매칭되었습니다.") with right: st.markdown("**Gemini 고유 토픽 (Supply Only)**") st.caption( "AI가 인용하지만 소비자 검색 수요가 낮은 토픽. " "잠재적 권위(Latent Authority) 영역이거나, 향후 수요가 증가할 수 있습니다." ) unmatched_gemini = [ c for c in gemini_clusters if c["id"] in unmatched_gemini_ids ] unmatched_gemini.sort( key=lambda c: float(c.get("opportunity_score") or 0), reverse=True, ) if unmatched_gemini: rows = [] for c in unmatched_gemini[:20]: rows.append({ "토픽": (c.get("cluster_label") or f"Cluster-{c['id'][:8]}")[:30], "Opportunity": f"{float(c.get('opportunity_score') or 0):.4f}", "Density": f"{float(c.get('citation_density') or 0):.4f}", "Citations": c.get("fanout_count", 0), }) st.dataframe( pd.DataFrame(rows), use_container_width=True, hide_index=True, ) if len(unmatched_gemini) > 20: st.caption(f"... 외 {len(unmatched_gemini) - 20}개") else: st.info("모든 Gemini 토픽이 ChatGPT와 매칭되었습니다.")