chainshift-dashboard / features /research /unified_scoring.py
GitHub Action
Sync from GitHub
ef78361
Raw
History Blame Contribute Delete
5.99 kB
"""Unified Scoring (ADR-014 Phase 4).
Cross-model unified score = weighted average of demand/supply percentiles.
Displayed in "์ „์ฒด" mode when cross-model pair exists.
"""
import streamlit as st
import pandas as pd
import plotly.graph_objects as go
from core.supabase_client import (
get_gap_scores, get_topic_clusters,
)
def render_unified_scoring(base_ctx: dict, pair: dict):
"""Render unified scoring view combining demand + supply signals.
Args:
base_ctx: Dashboard base context.
pair: Cross-model pair dict from find_cross_model_pair().
"""
campaign_chatgpt = pair["campaign_chatgpt"]
campaign_gemini = pair["campaign_gemini"]
matches = get_gap_scores(campaign_chatgpt, campaign_gemini)
if not matches:
st.info("Unified Scoring์— ํ•„์š”ํ•œ Cross-Model ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.")
return
# Fetch cluster counts for weight calculation
chatgpt_clusters = get_topic_clusters(campaign_chatgpt, source="chatgpt")
gemini_clusters = get_topic_clusters(campaign_gemini, source="gemini")
chatgpt_volume = sum(c.get("fanout_count", 0) for c in chatgpt_clusters)
gemini_volume = sum(c.get("fanout_count", 0) for c in gemini_clusters)
total_volume = chatgpt_volume + gemini_volume
# Weight by data volume (ADR-014 spec)
w_chatgpt = chatgpt_volume / total_volume if total_volume > 0 else 0.5
w_gemini = gemini_volume / total_volume if total_volume > 0 else 0.5
st.caption(
"Demand(ChatGPT)์™€ Supply(Gemini) ์‹ ํ˜ธ๋ฅผ ๋ฐ์ดํ„ฐ ๋ณผ๋ฅจ ๋น„๋ก€๋กœ ํ†ตํ•ฉํ•œ ์ ์ˆ˜์ž…๋‹ˆ๋‹ค."
)
# Weight info
c1, c2, c3 = st.columns(3)
with c1:
st.metric(
"Demand ๊ฐ€์ค‘์น˜",
f"{w_chatgpt:.1%}",
help=f"ChatGPT fanout ๋ณผ๋ฅจ: {chatgpt_volume:,}",
)
with c2:
st.metric(
"Supply ๊ฐ€์ค‘์น˜",
f"{w_gemini:.1%}",
help=f"Gemini citation ๋ณผ๋ฅจ: {gemini_volume:,}",
)
with c3:
st.metric("๋งค์นญ ํ† ํ”ฝ", f"{len(matches)}๊ฐœ")
st.markdown("---")
# Compute unified scores
scored = []
for m in matches:
demand_pct = float(m.get("demand_percentile") or 0)
supply_pct = float(m.get("supply_percentile") or 0)
unified = w_chatgpt * demand_pct + w_gemini * supply_pct
scored.append({
**m,
"unified_score": unified,
})
# Sort by unified score DESC
scored.sort(key=lambda x: x["unified_score"], reverse=True)
# --- Unified Ranking Table ---
st.markdown("#### Unified Score ๋žญํ‚น")
st.caption(
"๋‘ ๋ชจ๋ธ์˜ ์‹ ํ˜ธ๋ฅผ ํ†ตํ•ฉํ•œ ์ˆœ์œ„์ž…๋‹ˆ๋‹ค. "
"Unified Score๊ฐ€ ๋†’์„์ˆ˜๋ก Demand์™€ Supply ๋ชจ๋‘์—์„œ ์ค‘์š”ํ•œ ํ† ํ”ฝ์ž…๋‹ˆ๋‹ค."
)
rows = []
for i, s in enumerate(scored, 1):
demand_pct = float(s.get("demand_percentile") or 0)
supply_pct = float(s.get("supply_percentile") or 0)
rows.append({
"#": i,
"ํ† ํ”ฝ (ChatGPT)": (s.get("chatgpt_label") or "")[:30],
"ํ† ํ”ฝ (Gemini)": (s.get("gemini_label") or "")[:30],
"Unified": f"{s['unified_score']:.4f}",
"Demand": f"{demand_pct:.2%}",
"Supply": f"{supply_pct:.2%}",
"GapScore": f"{float(s.get('gap_score') or 0):.4f}",
"Quadrant": s.get("quadrant", "NICHE").replace("_", " ").title(),
})
df = pd.DataFrame(rows)
st.dataframe(df, use_container_width=True, hide_index=True)
st.markdown("---")
# --- Unified Score Distribution ---
st.markdown("#### Unified Score vs GapScore")
st.caption(
"X์ถ•์€ ํ†ตํ•ฉ ์ค‘์š”๋„(๋†’์„์ˆ˜๋ก ๋‘ ๋ชจ๋ธ ๋ชจ๋‘ ์ค‘์š”), "
"Y์ถ•์€ ๊ธฐํšŒ ํฌ๊ธฐ(๋†’์„์ˆ˜๋ก ์ฝ˜ํ…์ธ  ์ œ์ž‘ ROI๊ฐ€ ๋†’์Œ)."
)
_render_unified_scatter(scored)
def _render_unified_scatter(scored: list[dict]):
"""Scatter: Unified Score (x) vs GapScore (y)."""
from .cross_model import QUADRANT_COLORS, QUADRANT_LABELS
xs, ys, colors, hovers, sizes = [], [], [], [], []
for s in scored:
unified = s["unified_score"]
gap = float(s.get("gap_score") or 0)
quadrant = s.get("quadrant", "NICHE")
chatgpt_label = s.get("chatgpt_label", "")
xs.append(unified)
ys.append(gap)
colors.append(QUADRANT_COLORS.get(quadrant, "#9CA3AF"))
sizes.append(max(8, min(25, unified * 30)))
hovers.append(
f"<b>{chatgpt_label}</b><br>"
f"Unified: {unified:.4f}<br>"
f"GapScore: {gap:.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=hovers,
hoverinfo="text",
showlegend=False,
))
fig.update_layout(
title="Unified Score vs GapScore",
xaxis_title="Unified Score (ํ†ตํ•ฉ ์ค‘์š”๋„)",
yaxis_title="GapScore (์ฝ˜ํ…์ธ  ๊ธฐํšŒ)",
height=450,
template="plotly_white",
hoverlabel=dict(bgcolor="white", font_size=12),
)
st.plotly_chart(fig, use_container_width=True, key="cross_model:unified_scatter", config={"displayModeBar": False})
# Insight: Top-right quadrant = high importance + high opportunity
high_unified = [s for s in scored if s["unified_score"] > 0.5]
high_gap_and_unified = [
s for s in high_unified
if float(s.get("gap_score") or 0) > 0.05
]
if high_gap_and_unified:
st.info(
f"Unified Score > 0.5 ์ด๋ฉด์„œ GapScore๊ฐ€ ๋†’์€ ํ† ํ”ฝ์ด "
f"**{len(high_gap_and_unified)}๊ฐœ** ์žˆ์Šต๋‹ˆ๋‹ค. "
f"์ด ํ† ํ”ฝ๋“ค์€ ๋‘ ๋ชจ๋ธ ๋ชจ๋‘์—์„œ ์ค‘์š”ํ•˜๋ฉด์„œ ์ฝ˜ํ…์ธ  ๊ธฐํšŒ๋„ ํฐ ์ตœ์šฐ์„  ์˜์—ญ์ž…๋‹ˆ๋‹ค."
)