File size: 11,282 Bytes
ef78361
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
"""감성뢄석 μ˜€λ²„λ·° νƒ­.

전체 감성 뢄석 + λΈŒλžœλ“œ λ©˜μ…˜ 뢄석 + 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)