GitHub Action
Sync from GitHub
ef78361
Raw
History Blame Contribute Delete
24.5 kB
"""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"<b>{chatgpt_label}</b><br>"
f"Gemini: {gemini_label}<br>"
f"Demand: {demand:.2%}<br>"
f"Supply: {supply:.2%}<br>"
f"GapScore: {gap:.4f}<br>"
f"Match: {match_score:.4f}<br>"
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}๊ฐœ<br>"
f"๋น„์œจ: {pct_chatgpt:.1f}%<extra></extra>"
),
))
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}๊ฐœ<br>"
f"๋น„์œจ: {pct_matched:.1f}%<extra></extra>"
),
))
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}๊ฐœ<br>"
f"๋น„์œจ: {pct_gemini:.1f}%<extra></extra>"
),
))
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์™€ ๋งค์นญ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.")