Spaces:
Sleeping
Sleeping
| """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์ ๋งค์นญ๋์์ต๋๋ค.") | |