GitHub Action
Sync from GitHub
ef78361
Raw
History Blame Contribute Delete
11.3 kB
"""감성뢄석 μ˜€λ²„λ·° νƒ­.
전체 감성 뢄석 + λΈŒλžœλ“œ λ©˜μ…˜ 뢄석 + LLM 2μ°¨ 검증 κ²°κ³Ό.
"""
import streamlit as st
from core.charts import create_brand_sentiment_chart
from core.supabase_client import get_polarity_stats, get_answers_by_polarity
from core.utils import truncate_text
def render(data: dict):
"""μ˜€λ²„λ·° νƒ­ λ Œλ”λ§."""
# --- 전체 감성 뢄석 ---
st.markdown("##### πŸ“ˆ 전체 감성 뢄석")
st.caption("AI λ‹΅λ³€μ˜ 전체 감성 뢄포λ₯Ό ν™•μΈν•©λ‹ˆλ‹€ (긍정/쀑립/λΆ€μ •)")
polarity_in_house_only = st.checkbox(
"🏠 μžμ‚¬ λΈŒλžœλ“œ μ–ΈκΈ‰ λ‹΅λ³€λ§Œ 보기",
value=False,
key="sentiment:polarity_in_house_filter",
help="체크 μ‹œ μžμ‚¬ λΈŒλžœλ“œκ°€ μ–ΈκΈ‰λœ λ‹΅λ³€λ§Œ ν‘œμ‹œν•©λ‹ˆλ‹€.",
)
try:
polarity_stats = get_polarity_stats(data["campaign_id"], in_house_only=polarity_in_house_only)
positive_count = polarity_stats.get("positive", 0)
neutral_count = polarity_stats.get("neutral", 0)
negative_count = polarity_stats.get("negative", 0)
total_answers_pol = positive_count + neutral_count + negative_count
pol_col1, pol_col2, pol_col3, pol_col4 = st.columns(4)
with pol_col1:
st.metric("전체 뢄석", f"{total_answers_pol:,}건")
with pol_col2:
pos_rate = (positive_count / total_answers_pol * 100) if total_answers_pol > 0 else 0
st.metric("😊 긍정", f"{positive_count:,}건", f"{pos_rate:.1f}%")
with pol_col3:
neu_rate = (neutral_count / total_answers_pol * 100) if total_answers_pol > 0 else 0
st.metric("😐 쀑립", f"{neutral_count:,}건", f"{neu_rate:.1f}%")
with pol_col4:
neg_rate = (negative_count / total_answers_pol * 100) if total_answers_pol > 0 else 0
st.metric("😞 λΆ€μ •", f"{negative_count:,}건", f"{neg_rate:.1f}%")
st.markdown("---")
polarity_filter = st.selectbox(
"감성 λΆ„λ₯˜ 선택",
options=["positive", "neutral", "negative"],
format_func=lambda x: {"positive": "😊 긍정", "neutral": "😐 쀑립", "negative": "😞 λΆ€μ •"}[x],
key="sentiment:polarity_filter_tab6",
)
polarity_page = st.number_input("νŽ˜μ΄μ§€", min_value=1, value=1, key="sentiment:polarity_page")
polarity_items, polarity_total = get_answers_by_polarity(
data["campaign_id"], polarity_filter, page=polarity_page, page_size=20,
in_house_only=polarity_in_house_only,
)
st.markdown(f"**{polarity_total:,}건** 쀑 {len(polarity_items)}건 ν‘œμ‹œ")
for item in polarity_items:
_render_polarity_item(item)
except Exception as e:
st.error(f"감성 데이터 λ‘œλ“œ μ‹€νŒ¨: {e}")
st.info("Supabase μ—°κ²° 섀정을 ν™•μΈν•˜μ„Έμš”")
# --- LLM 2μ°¨ 검증 κ²°κ³Ό ---
_render_llm_verification_summary(data)
# --- λΈŒλžœλ“œ 뢄석 ---
st.markdown("---")
st.markdown("##### 🏷️ λΈŒλžœλ“œ λ©˜μ…˜ 뢄석")
st.caption("μžμ‚¬ λΈŒλžœλ“œμ™€ κ²½μŸμ‚¬ λΈŒλžœλ“œκ°€ AI λ‹΅λ³€μ—μ„œ μ–΄λ–»κ²Œ μ–ΈκΈ‰λ˜λŠ”μ§€ λΆ„μ„ν•©λ‹ˆλ‹€")
brand_data = data["brand_data"] or {}
in_house_summary = brand_data.get("in_house_summary", [])
competitor_summary = brand_data.get("competitor_summary", [])
total_answers = brand_data.get("total_answers", 0)
st.markdown(f"**λΆ„μ„λœ AI λ‹΅λ³€**: {total_answers}건")
st.markdown("---")
brand_col1, brand_col2 = st.columns(2)
with brand_col1:
st.markdown("##### 🏠 μžμ‚¬ λΈŒλžœλ“œ")
if in_house_summary:
for brand in in_house_summary[:5]:
_render_brand_card(brand, "in_house")
else:
st.info("μžμ‚¬ λΈŒλžœλ“œ 데이터가 μ—†μŠ΅λ‹ˆλ‹€")
with brand_col2:
st.markdown("##### 🏒 κ²½μŸμ‚¬ λΈŒλžœλ“œ")
if competitor_summary:
for brand in competitor_summary[:5]:
_render_brand_card(brand, "competitor")
else:
st.info("κ²½μŸμ‚¬ λΈŒλžœλ“œ 데이터가 μ—†μŠ΅λ‹ˆλ‹€")
if in_house_summary or competitor_summary:
st.markdown("---")
st.markdown("##### πŸ“Š λΈŒλžœλ“œλ³„ 감성 비ꡐ")
all_brands = in_house_summary + competitor_summary
if all_brands:
fig = create_brand_sentiment_chart(all_brands)
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
def _render_llm_verification_summary(data: dict):
"""LLM 2μ°¨ 검증 κ²°κ³Ό μš”μ•½ λ Œλ”λ§."""
st.markdown("---")
st.markdown("##### πŸ€– LLM 2μ°¨ 검증 κ²°κ³Ό")
st.caption(
"DeBERTa(1μ°¨ AI)κ°€ λΆ€μ • κ°μ§€ν•œ 닡변을 LLM(2μ°¨ AI)이 μž¬κ²€μ¦ν•œ κ²°κ³Όμž…λ‹ˆλ‹€. "
"πŸ”΄ 정탐 = μ‹€μ œ λΆ€μ • 확인 (리슀크) | 🟒 μ˜€νƒ = λΆ€μ • μ•„λ‹˜ 확인 (μ•ˆμ „)"
)
llm_stats = data.get("llm_verification_stats") or {}
total_nudge = data.get("total_nudge", 0)
total_verified = llm_stats.get("total_verified", 0)
true_negatives = llm_stats.get("true_negatives", 0)
false_positives = llm_stats.get("false_positives", 0)
pending = total_nudge - total_verified
if total_nudge == 0:
st.info("λΆ€μ • κ°μ§€λœ λ„›μ§€ 후보가 μ—†μŠ΅λ‹ˆλ‹€.")
return
# --- Metrics ---
llm_col1, llm_col2, llm_col3, llm_col4 = st.columns(4)
with llm_col1:
st.metric("πŸ” λ„›μ§€ 후보", f"{total_nudge:,}건")
with llm_col2:
verify_rate = (total_verified / total_nudge * 100) if total_nudge > 0 else 0
st.metric("βœ… 검증 μ™„λ£Œ", f"{total_verified:,}건", f"{verify_rate:.0f}%")
with llm_col3:
tp_rate = (true_negatives / total_verified * 100) if total_verified > 0 else 0
st.metric("🎯 정탐", f"{true_negatives:,}건", f"{tp_rate:.1f}%")
with llm_col4:
fp_rate = (false_positives / total_verified * 100) if total_verified > 0 else 0
st.metric("🚫 μ˜€νƒ", f"{false_positives:,}건", f"{fp_rate:.1f}%")
# --- Visual bar ---
if total_verified > 0:
tp_pct = true_negatives / total_nudge * 100
fp_pct = false_positives / total_nudge * 100
pending_pct = pending / total_nudge * 100
st.markdown(f"""
<div style="display: flex; height: 28px; border-radius: 6px; overflow: hidden; margin: 8px 0;">
<div style="width: {tp_pct}%; background: #EF4444; display: flex; align-items: center; justify-content: center; color: white; font-size: 12px; font-weight: bold;">
{'정탐' if tp_pct > 8 else ''}
</div>
<div style="width: {fp_pct}%; background: #10B981; display: flex; align-items: center; justify-content: center; color: white; font-size: 12px; font-weight: bold;">
{'μ˜€νƒ' if fp_pct > 8 else ''}
</div>
<div style="width: {pending_pct}%; background: #D1D5DB; display: flex; align-items: center; justify-content: center; color: #6B7280; font-size: 12px;">
{'미검증' if pending_pct > 8 else ''}
</div>
</div>
<div style="display: flex; gap: 16px; font-size: 12px; color: #6B7280; margin-bottom: 4px;">
<span>πŸ”΄ 정탐 {tp_pct:.1f}%</span>
<span>🟒 μ˜€νƒ {fp_pct:.1f}%</span>
<span>βšͺ 미검증 {pending_pct:.1f}%</span>
</div>
""", unsafe_allow_html=True)
# --- Confirmed negative tier distribution ---
candidates = data.get("candidates", [])
confirmed = [c for c in candidates if c.get("llm_verified") and c.get("llm_is_negative")]
if confirmed:
tier_dist: dict[str, int] = {}
for c in confirmed:
tier = c.get("llm_adjusted_tier") or "UNKNOWN"
tier_dist[tier] = tier_dist.get(tier, 0) + 1
tier_colors = {"HIGH": "#EF4444", "MEDIUM": "#F59E0B", "LOW": "#3B82F6", "NONE": "#10B981", "UNKNOWN": "#9CA3AF"}
st.markdown("**정탐 λ‹΅λ³€μ˜ LLM λ“±κΈ‰ 뢄포**")
tier_cols = st.columns(len(tier_dist))
for i, (tier, count) in enumerate(sorted(tier_dist.items(), key=lambda x: -x[1])):
color = tier_colors.get(tier, "#9CA3AF")
pct = count / len(confirmed) * 100
with tier_cols[i]:
st.markdown(f"""
<div style="text-align: center; padding: 8px; background: {color}15; border-radius: 8px; border: 1px solid {color}40;">
<div style="font-size: 20px; font-weight: bold; color: {color};">{count}</div>
<div style="font-size: 12px; color: #6B7280;">{tier} ({pct:.0f}%)</div>
</div>
""", unsafe_allow_html=True)
def _render_polarity_item(item: dict):
"""감성 ν•­λͺ© λ Œλ”λ§."""
polarity_emoji = {"positive": "😊", "neutral": "😐", "negative": "😞"}.get(item.get('overall_polarity'), "❓")
confidence = item.get('overall_confidence', 0) or 0
with st.expander(f"{polarity_emoji} {truncate_text(item.get('question_content', 'N/A'), 80)}", expanded=False):
st.markdown(f"**질문**: {item.get('question_content', 'N/A')}")
st.markdown(f"**λ‹΅λ³€ 미리보기**: {item.get('answer_preview', 'N/A')}")
st.markdown("---")
info_col1, info_col2, info_col3 = st.columns(3)
with info_col1:
st.markdown(f"**감성**: {item.get('overall_polarity', 'N/A')}")
st.markdown(f"**신뒰도**: {confidence:.1%}")
with info_col2:
st.markdown(f"**ν”Œλž«νΌ**: {item.get('platform', 'N/A')}")
st.markdown(f"**CEJ**: {item.get('cej_depth1', 'N/A')} / {item.get('cej_depth2', 'N/A')}")
with info_col3:
st.markdown(f"**Tier**: {item.get('routing_tier', 'N/A')}")
st.markdown(f"**감정**: {item.get('dominant_emotion', 'N/A')}")
in_house = item.get('in_house_brands', []) or []
mentioned = item.get('mentioned_brands', []) or []
if in_house or mentioned:
st.markdown(f"**μžμ‚¬ λΈŒλžœλ“œ**: {', '.join(in_house) if in_house else 'N/A'}")
st.markdown(f"**μ–ΈκΈ‰ λΈŒλžœλ“œ**: {', '.join(mentioned) if mentioned else 'N/A'}")
def _render_brand_card(brand: dict, brand_type: str):
"""λΈŒλžœλ“œ μΉ΄λ“œ λ Œλ”λ§."""
brand_name = brand.get("brand_name", "Unknown")
total_mentions = brand.get("total_mentions", 0)
positive_rate = brand.get("positive_rate", 0)
negative_rate = brand.get("negative_rate", 0)
bg_color = "#F0F9FF" if brand_type == "in_house" else "#FEF3C7"
brand_html = f'<div style="background: {bg_color}; border-radius: 8px; padding: 12px; margin: 8px 0;">'
brand_html += f'<div style="font-weight: bold; font-size: 16px; margin-bottom: 8px;">{brand_name}</div>'
brand_html += f'<div style="display: flex; gap: 16px; font-size: 13px;">'
brand_html += f'<span>πŸ“Š μ–ΈκΈ‰: <strong>{total_mentions}</strong></span>'
brand_html += f'<span style="color: #10B981;">βœ… 긍정: <strong>{positive_rate:.1f}%</strong></span>'
brand_html += f'<span style="color: #EF4444;">❌ λΆ€μ •: <strong>{negative_rate:.1f}%</strong></span>'
brand_html += '</div></div>'
st.markdown(brand_html, unsafe_allow_html=True)