"""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와 매칭되었습니다.")