Spaces:
Sleeping
Sleeping
| """μμ¬ λΈλλ λΆμ ν. | |
| μμ¬ λΈλλ λΆμ μΈκΈ λΆμ + AI 2μ°¨ κ²μ¦ κ²°κ³Ό + μ λ΅μ μΈμ¬μ΄νΈ. | |
| """ | |
| import html | |
| import pandas as pd | |
| import streamlit as st | |
| from core.api_client import ChainShiftClient | |
| from core.charts import CONFIDENCE_TIER_COLORS, EMOTION_KO, create_domain_bar_chart | |
| from core.athena_client import fetch_full_answer | |
| from core.styles import TIER_BORDER_COLORS | |
| from core.supabase_client import ( | |
| get_false_positives, | |
| get_true_negatives, | |
| ) | |
| from core.utils import ( | |
| format_brands_list, | |
| get_confidence_tier, | |
| get_feedback_reason_label, | |
| get_feedback_type_emoji, | |
| get_llm_tier_badge, | |
| highlight_evidence_spans, | |
| truncate_text, | |
| ) | |
| def render(data: dict): | |
| """μμ¬ λΈλλ λΆμ ν λ λλ§.""" | |
| # --- Section 1: λΆμ μΈκΈ λΆμ (from insights.py) --- | |
| st.markdown("##### π μμ¬ λΈλλ λΆμ μΈκΈ AI λ΅λ³") | |
| st.caption("AIκ° μμ¬ λΈλλμ λν΄ λΆμ μ μΌλ‘ μΈκΈν λ΅λ³μ μλμΌλ‘ κ°μ§ν©λλ€") | |
| # --- Overview Card --- | |
| _render_overview(data) | |
| # Filters β Row 1: νλ«νΌ, νμ λ, (CEJ), 2μ°¨ κ²μ¦ | |
| has_cej = bool(data.get("cej_stats")) | |
| filter_cols = st.columns(4 if has_cej else 3) | |
| with filter_cols[0]: | |
| platform_options = ["μ 체"] + list(data["platform_stats"].keys()) | |
| platform_filter = st.selectbox("νλ«νΌ", options=platform_options, key="sentiment:ih_platform") | |
| with filter_cols[1]: | |
| tier_filter = st.selectbox("νμ λ", options=["μ 체", "HIGH", "MEDIUM", "LOW"], key="sentiment:ih_tier") | |
| cej_filter = "μ 체" | |
| if has_cej: | |
| with filter_cols[2]: | |
| cej_options = ["μ 체"] + list(data["cej_stats"].keys()) | |
| cej_filter = st.selectbox("CEJ λ¨κ³", options=cej_options, key="sentiment:ih_cej") | |
| with filter_cols[-1]: | |
| llm_status_filter = st.selectbox("2μ°¨ κ²μ¦", options=["μ 체", "μ ν", "μ€ν", "λ―Έκ²μ¦"], key="sentiment:ih_llm_status") | |
| # Filters β Row 2: νμ΄μ§ ν¬κΈ° (μ°μΈ‘ μ λ ¬) | |
| _, size_col = st.columns([4, 1]) | |
| with size_col: | |
| page_size = st.selectbox("νμ΄μ§ ν¬κΈ°", options=[20, 50, 100], index=1, key="sentiment:ih_page_size") | |
| # Filter candidates | |
| filtered = data["candidates"] | |
| if platform_filter != "μ 체": | |
| filtered = [c for c in filtered if c.get("platform") == platform_filter] | |
| if tier_filter != "μ 체": | |
| filtered = [c for c in filtered if get_confidence_tier(c.get("overall_confidence"))[0] == tier_filter] | |
| if cej_filter != "μ 체": | |
| filtered = [c for c in filtered if c.get("cej_depth1") == cej_filter] | |
| if llm_status_filter == "μ ν": | |
| filtered = [c for c in filtered if c.get("llm_verified") and c.get("llm_is_negative")] | |
| elif llm_status_filter == "μ€ν": | |
| filtered = [c for c in filtered if c.get("llm_verified") and not c.get("llm_is_negative")] | |
| elif llm_status_filter == "λ―Έκ²μ¦": | |
| filtered = [c for c in filtered if not c.get("llm_verified")] | |
| total_all = data.get("total_nudge", len(data["candidates"])) | |
| st.markdown(f"**{len(filtered)}건** νμ μ€ (μ 체 {total_all}건)") | |
| # Export | |
| _render_export_section(data) | |
| # Pagination | |
| total_pages = max(1, (len(filtered) + page_size - 1) // page_size) | |
| if "sentiment:ih_page" not in st.session_state: | |
| st.session_state["sentiment:ih_page"] = 1 | |
| # Reset page when filters change | |
| ih_filter_key = f"{platform_filter}_{tier_filter}_{cej_filter}_{llm_status_filter}_{page_size}" | |
| if st.session_state.get("sentiment:ih_last_filters") != ih_filter_key: | |
| st.session_state["sentiment:ih_page"] = 1 | |
| st.session_state["sentiment:ih_last_filters"] = ih_filter_key | |
| current_page = st.session_state["sentiment:ih_page"] | |
| # Pagination header (always show for consistency with other tabs) | |
| start_idx = (current_page - 1) * page_size + 1 | |
| end_idx = min(current_page * page_size, len(filtered)) | |
| if total_pages > 1: | |
| col_info, col_prev, col_page, col_next = st.columns([3, 1, 1, 1]) | |
| with col_info: | |
| st.markdown(f"**μ 체 {len(filtered):,}건** | νμ΄μ§ {current_page}/{total_pages} ({start_idx}-{end_idx}건)") | |
| with col_prev: | |
| if st.button("β¬ οΈ μ΄μ ", disabled=current_page <= 1, key="sentiment:ih_prev"): | |
| st.session_state["sentiment:ih_page"] = current_page - 1 | |
| st.rerun() | |
| with col_page: | |
| new_page = st.number_input( | |
| "νμ΄μ§", min_value=1, max_value=total_pages, | |
| value=current_page, label_visibility="collapsed", key="sentiment:ih_page_input", | |
| ) | |
| if new_page != current_page: | |
| st.session_state["sentiment:ih_page"] = new_page | |
| st.rerun() | |
| with col_next: | |
| if st.button("λ€μ β‘οΈ", disabled=current_page >= total_pages, key="sentiment:ih_next"): | |
| st.session_state["sentiment:ih_page"] = current_page + 1 | |
| st.rerun() | |
| else: | |
| st.markdown(f"**μ 체 {len(filtered):,}건**") | |
| # Candidate cards (paginated) | |
| page_start = (current_page - 1) * page_size | |
| page_end = page_start + page_size | |
| for i, item in enumerate(filtered[page_start:page_end]): | |
| unique_idx = page_start + i | |
| _render_candidate_card(data, item, unique_idx) | |
| # --- Section 2: AI 2μ°¨ κ²μ¦ κ²°κ³Ό (from verification.py) --- | |
| st.markdown("---") | |
| st.markdown("##### π€ AI 2μ°¨ κ²μ¦ κ²°κ³Ό") | |
| _render_verification_section(data) | |
| # --- Section 3: μ λ΅μ μΈμ¬μ΄νΈ --- | |
| st.markdown("---") | |
| _render_strategic_insights(data) | |
| def _render_overview(data: dict): | |
| """μμ¬ λΈλλ μ€λ²λ·° μΉ΄λ.""" | |
| candidates = data.get("candidates", []) | |
| total = data.get("total_nudge", len(candidates)) | |
| # Extract brand names from candidates | |
| all_brands: set[str] = set() | |
| for c in candidates: | |
| for b in c.get("in_house_brands", []): | |
| all_brands.add(b) | |
| brands_display = ", ".join(sorted(all_brands)[:5]) if all_brands else "N/A" | |
| if len(all_brands) > 5: | |
| brands_display += f" μΈ {len(all_brands) - 5}κ°" | |
| # Tier distribution | |
| tier_stats = data.get("tier_stats", {}) | |
| high = tier_stats.get("HIGH", 0) | |
| medium = tier_stats.get("MEDIUM", 0) | |
| low = tier_stats.get("LOW", 0) | |
| # LLM verification stats (from RPC, not limited by PostgREST page size) | |
| llm_stats = data.get("llm_verification_stats", {}) | |
| verified_count = llm_stats.get("verified_count", 0) | |
| tp_count = llm_stats.get("true_positive_count", 0) | |
| fp_count = llm_stats.get("false_positive_count", 0) | |
| llm_text = f"{verified_count}건 μλ£" | |
| if verified_count > 0: | |
| llm_text += f" (μ ν {tp_count} / μ€ν {fp_count})" | |
| st.markdown(f""" | |
| <div style="background: linear-gradient(135deg, #F8FAFC 0%, #EEF2FF 100%); border: 1px solid #C7D2FE; | |
| border-radius: 12px; padding: 16px; margin-bottom: 16px;"> | |
| <div style="font-size: 13px; color: #4338CA; font-weight: 600; margin-bottom: 8px;">μΆμ λΈλλ: {html.escape(brands_display)}</div> | |
| <div style="display: flex; gap: 24px; flex-wrap: wrap; font-size: 13px; color: #374151;"> | |
| <span>μ 체 λΆμ κ°μ§: <strong>{total}건</strong></span> | |
| <span>π΄ HIGH: <strong>{high}</strong> | π‘ MEDIUM: <strong>{medium}</strong> | π’ LOW: <strong>{low}</strong></span> | |
| <span>LLM κ²μ¦: <strong>{llm_text}</strong></span> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def _render_export_section(data: dict): | |
| """Export μμ β inline λ²νΌ (κ²½μμ¬/ν€μλ νκ³Ό ν΅μΌ).""" | |
| # Map current UI filters for export | |
| exp_platform = None | |
| exp_llm_neg = None | |
| if "sentiment:ih_platform" in st.session_state: | |
| _p = st.session_state["sentiment:ih_platform"] | |
| if _p != "μ 체": | |
| exp_platform = _p | |
| if "sentiment:ih_llm_status" in st.session_state: | |
| _s = st.session_state["sentiment:ih_llm_status"] | |
| if _s == "μ ν": | |
| exp_llm_neg = True | |
| elif _s == "μ€ν": | |
| exp_llm_neg = False | |
| _, export_col = st.columns([4, 1]) | |
| with export_col: | |
| if st.button("π₯ Excel λ€μ΄λ‘λ", key="sentiment:ih_export_btn"): | |
| with st.spinner("Excel νμΌ μμ± μ€..."): | |
| try: | |
| client = ChainShiftClient(api_key=data.get("api_key"), access_token=data.get("access_token")) | |
| xlsx = client.export_nudge_candidates( | |
| data["campaign_id"], | |
| include_full_answers=False, | |
| include_evidence=True, | |
| llm_verified_only=False, | |
| platform=exp_platform, | |
| llm_is_negative=exp_llm_neg, | |
| ) | |
| st.session_state["sentiment:ih_excel_data"] = xlsx | |
| st.session_state["sentiment:ih_excel_ready"] = True | |
| except Exception as e: | |
| st.error(f"λ€μ΄λ‘λ μ€ν¨: {e}") | |
| if st.session_state.get("sentiment:ih_excel_ready"): | |
| st.download_button( | |
| label="πΎ μ μ₯", | |
| data=st.session_state["sentiment:ih_excel_data"], | |
| file_name=f"in_house_analysis_{data['campaign_id']}.xlsx", | |
| mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| key="sentiment:ih_dl_btn", | |
| ) | |
| def _render_candidate_card(data: dict, item: dict, index: int): | |
| """Individual candidate card with LLM verification inline.""" | |
| answer_id = item.get("answer_id", "N/A") | |
| confidence = item.get("overall_confidence", 0) | |
| tier, tier_emoji, tier_desc = get_confidence_tier(confidence) | |
| tier_color = TIER_BORDER_COLORS.get(tier, "#94A3B8") | |
| raw_emotion = item.get("dominant_emotion") or "" | |
| emotion = EMOTION_KO.get(raw_emotion, raw_emotion) or "N/A" | |
| platform = item.get("platform", "N/A") | |
| brands = item.get("in_house_brands", []) | |
| # LLM verification badge | |
| llm_badge = "" | |
| if item.get("llm_verified"): | |
| if item.get("llm_is_negative"): | |
| llm_badge = '<span style="background:#DC2626;color:white;padding:2px 6px;border-radius:4px;font-size:11px;">π΄ λΆμ νμΈ</span>' | |
| else: | |
| llm_badge = '<span style="background:#059669;color:white;padding:2px 6px;border-radius:4px;font-size:11px;">π’ λΆμ μλ</span>' | |
| else: | |
| llm_badge = '<span style="background:#F59E0B;color:white;padding:2px 6px;border-radius:4px;font-size:11px;">β³ λ―Έκ²μ¦</span>' | |
| st.markdown(f""" | |
| <div style="border-left: 3px solid {tier_color}; padding: 8px 12px; margin: 4px 0; | |
| background: #F8FAFC; border-radius: 0 8px 8px 0;"> | |
| <div style="display:flex; justify-content:space-between; align-items:center;"> | |
| <span style="font-weight:600;">#{answer_id} β {', '.join(brands) if brands else 'N/A'}</span> | |
| <span>{llm_badge} <span style="background:{CONFIDENCE_TIER_COLORS.get(tier,'#94A3B8')}; | |
| color:white;padding:2px 6px;border-radius:4px;font-size:11px;">{tier}</span></span> | |
| </div> | |
| <div style="font-size:12px;color:#6B7280;margin-top:4px;"> | |
| {platform} | π λΆμ ({emotion}) | νμ λ {confidence:.0%} | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with st.expander(f"π μμΈ λ³΄κΈ° β #{answer_id}", expanded=False): | |
| _render_candidate_detail(data, item, answer_id, index) | |
| def _render_candidate_detail(data: dict, item: dict, answer_id: int, index: int): | |
| """Candidate detail: question, full answer, ABSA, citations, LLM card, feedback.""" | |
| # 1. Question context | |
| question = item.get("question_content", "") | |
| if question: | |
| st.markdown(f""" | |
| <div style="border-left: 4px solid #4338CA; background: #EEF2FF; padding: 10px 12px; | |
| border-radius: 0 8px 8px 0; margin-bottom: 8px;"> | |
| <div style="font-size: 11px; color: #4338CA; margin-bottom: 2px;">π¬ μ§λ¬Έ</div> | |
| <div style="font-size: 14px; color: #1E1B4B;">{html.escape(question[:500])}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # 2. Full answer (lazy-load from Athena) | |
| st.markdown("**π€ AI λ΅λ³**") | |
| preview = item.get("answer_preview", "") | |
| display_answer = _load_full_answer_ih(answer_id, preview, index) | |
| # 3. Brand ABSA | |
| brand_detail = item.get("brand_sentiment_detail", {}) | |
| if brand_detail: | |
| _render_brand_absa(brand_detail) | |
| # 4. Citations | |
| _render_citations_ih(answer_id, item, index) | |
| # 5. LLM verification card (styled) | |
| if item.get("llm_verified"): | |
| llm_is_negative = item.get("llm_is_negative", False) | |
| llm_confidence = item.get("llm_confidence", 0) or 0 | |
| llm_reasoning = item.get("llm_reasoning", "") | |
| llm_evidence_spans = item.get("llm_evidence_spans", []) | |
| llm_adjusted_tier = item.get("llm_adjusted_tier") | |
| badge_text, badge_color = get_llm_tier_badge(llm_adjusted_tier, llm_is_negative) | |
| badge_bg = {"green": "#10B981", "red": "#EF4444", "orange": "#F59E0B", "blue": "#3B82F6"}.get(badge_color, "#6B7280") | |
| st.markdown("---") | |
| st.markdown(f""" | |
| <div style="background: #F0FDF4; border: 1px solid #86EFAC; border-radius: 8px; padding: 12px; margin: 8px 0;"> | |
| <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"> | |
| <span style="font-weight: bold;">π¬ LLM 2μ°¨ κ²μ¦</span> | |
| <span style="background: {badge_bg}; color: white; padding: 4px 12px; border-radius: 20px; font-size: 12px;">{badge_text}</span> | |
| </div> | |
| <div style="font-size: 13px; color: #374151;"> | |
| <strong>LLM νμ λ:</strong> {llm_confidence:.0%}<br> | |
| <strong>νλ¨ κ·Όκ±°:</strong> {html.escape(llm_reasoning[:500]) if llm_reasoning else 'N/A'} | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Evidence highlighting | |
| if llm_evidence_spans and display_answer: | |
| st.markdown("**π κ·Όκ±° λ¬Έμ₯ (νμ΄λΌμ΄νΈ)**") | |
| highlighted_html = highlight_evidence_spans(display_answer, llm_evidence_spans) | |
| st.markdown( | |
| f'<div style="background: #FFFBEB; padding: 12px; border-radius: 8px; font-size: 13px; ' | |
| f'white-space: pre-wrap; max-height: 300px; overflow-y: auto;">{highlighted_html}</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| st.caption("π΄ λΆμ | π’ κΈμ | π΅ μ€λ¦½ | π‘ λΉκ΅") | |
| # Per-brand LLM results breakdown | |
| per_brand_results = item.get("in_house_llm_results") or [] | |
| if len(per_brand_results) > 0: | |
| _render_per_brand_llm_results(per_brand_results) | |
| # Re-verify button | |
| if st.button("π μ¬κ²μ¦ μμ²", key=f"sentiment:ih_reverify_{answer_id}_{index}"): | |
| with st.spinner("LLM μ¬κ²μ¦ μ€..."): | |
| result = _request_llm_verification(data.get("api_key", ""), answer_id, force=True, access_token=data.get("access_token")) | |
| if result and result.get("success") and result.get("data"): | |
| st.success("μ¬κ²μ¦ μλ£! νμ΄μ§λ₯Ό μλ‘κ³ μΉ¨νλ©΄ λ°μλ©λλ€.") | |
| st.rerun() | |
| else: | |
| st.info("μμ§ LLM 2μ°¨ κ²μ¦μ΄ μνλμ§ μμμ΅λλ€.") | |
| if st.button("π¬ LLM κ²μ¦ μμ²", key=f"sentiment:ih_verify_{answer_id}_{index}"): | |
| with st.spinner("Gemini Proλ‘ κ²μ¦ μ€... (μ΅λ 30μ΄)"): | |
| result = _request_llm_verification(data.get("api_key", ""), answer_id, access_token=data.get("access_token")) | |
| if result and result.get("success") and result.get("data"): | |
| st.success("κ²μ¦ μλ£!") | |
| st.rerun() | |
| # 6. Feedback | |
| _render_feedback_inline(data, item, answer_id) | |
| def _load_full_answer_ih(answer_id: int, preview: str, index: int) -> str: | |
| """Lazy-load full answer from Athena for in-house tab.""" | |
| display_answer = preview or "N/A" | |
| if answer_id and answer_id != "N/A": | |
| full_answer_key = f"sentiment:ih_full_{answer_id}_{index}" | |
| load_key = f"sentiment:ih_load_{answer_id}_{index}" | |
| if full_answer_key not in st.session_state: | |
| st.session_state[full_answer_key] = None | |
| cached = st.session_state.get(full_answer_key) | |
| is_loaded = isinstance(cached, str) and len(cached) > 0 | |
| load_full = st.checkbox( | |
| "π₯ μ 체 λ΅λ³ λΆλ¬μ€κΈ°", | |
| key=load_key, | |
| value=is_loaded, | |
| ) | |
| if load_full and not is_loaded: | |
| with st.spinner("Athenaμμ μ 체 λ΅λ³μ κ°μ Έμ€λ μ€..."): | |
| try: | |
| full_content = fetch_full_answer(answer_id) | |
| if full_content: | |
| st.session_state[full_answer_key] = full_content | |
| st.rerun() | |
| else: | |
| st.warning("λ΅λ³μ μ°Ύμ μ μμ΅λλ€") | |
| except Exception as e: | |
| st.warning(f"μ 체 λ΅λ³ λ‘λ μ€ν¨: {e}") | |
| display_answer = st.session_state.get(full_answer_key) or preview or "N/A" | |
| label = "β μ 체 λ΅λ³ λ‘λλ¨" if is_loaded else f"π 미리보기 ({len(preview or '')}μ)" | |
| st.caption(label) | |
| st.markdown( | |
| f'<div style="background: #FEF2F2; padding: 12px; border-radius: 8px; font-size: 14px; ' | |
| f'white-space: pre-wrap; word-break: break-word; max-height: 400px; overflow-y: auto;">' | |
| f'{html.escape(display_answer)}</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| return display_answer | |
| def _render_citations_ih(answer_id: int, item: dict, index: int): | |
| """In-house tab citation rendering (Supabase citation_urls).""" | |
| st.markdown("**π μΈμ© μΆμ²**") | |
| citation_urls = item.get("citation_urls", []) or [] | |
| if citation_urls: | |
| for url in citation_urls[:5]: | |
| display_url = url[:50] + "..." if len(url) > 50 else url | |
| st.markdown(f"β’ [{display_url}]({url})") | |
| if len(citation_urls) > 5: | |
| st.caption(f"+{len(citation_urls) - 5}κ° λ...") | |
| else: | |
| st.caption("μΈμ© μμ€ μμ") | |
| def _render_brand_absa(brand_detail: dict): | |
| """Render ABSA results per brand.""" | |
| st.markdown("**π λΈλλλ³ κ°μ± λΆμ (ABSA)**") | |
| in_house = brand_detail.get("in_house", []) | |
| # Handle dual format (list or dict) | |
| if isinstance(in_house, list): | |
| for bd in in_house: | |
| brand = bd.get("brand", "N/A") | |
| sentiment = bd.get("sentiment", "N/A") | |
| confidence = bd.get("confidence", 0) | |
| color = "#DC2626" if sentiment == "negative" else "#059669" if sentiment == "positive" else "#6B7280" | |
| st.markdown( | |
| f'<span style="color:{color};font-weight:600;">{brand}</span>: {sentiment} ({confidence:.0%})', | |
| unsafe_allow_html=True, | |
| ) | |
| elif isinstance(in_house, dict): | |
| for brand, info in in_house.items(): | |
| sentiment = info.get("sentiment", "N/A") if isinstance(info, dict) else str(info) | |
| st.markdown(f"**{brand}**: {sentiment}") | |
| def _render_per_brand_llm_results(per_brand_results: list[dict]): | |
| """Render per-brand in-house LLM verification breakdown.""" | |
| st.markdown("**π·οΈ λΈλλλ³ LLM κ²μ¦ κ²°κ³Ό**") | |
| for r in per_brand_results: | |
| brand = r.get("brand", "Unknown") | |
| is_neg = r.get("is_negative", False) | |
| conf = r.get("confidence", 0) or 0 | |
| reasoning = r.get("reasoning", "") | |
| tier = r.get("adjusted_tier", "NONE") | |
| if is_neg: | |
| badge = f'<span style="background:#DC2626;color:white;padding:2px 6px;border-radius:4px;font-size:11px;">π΄ λΆμ νμΈ ({tier})</span>' | |
| border_color = "#DC2626" | |
| else: | |
| badge = '<span style="background:#059669;color:white;padding:2px 6px;border-radius:4px;font-size:11px;">π’ λΆμ μλ</span>' | |
| border_color = "#059669" | |
| st.markdown(f""" | |
| <div style="border-left: 3px solid {border_color}; padding: 6px 10px; margin: 4px 0; | |
| background: #F8FAFC; border-radius: 0 6px 6px 0;"> | |
| <div style="display:flex; justify-content:space-between; align-items:center;"> | |
| <strong>{html.escape(brand)}</strong> | |
| {badge} | |
| </div> | |
| <div style="font-size: 12px; color: #6B7280; margin-top: 2px;"> | |
| νμ λ {conf:.0%} β {html.escape(reasoning[:200]) if reasoning else 'N/A'} | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def _render_feedback_inline(data: dict, item: dict, answer_id: int): | |
| """Inline feedback buttons.""" | |
| _token = data.get("access_token") | |
| col1, col2, col3 = st.columns(3) | |
| with col1: | |
| if st.button("π μ νν΄μ", key=f"sentiment:ih_fb_ok_{answer_id}"): | |
| _submit_feedback(data.get("api_key", ""), answer_id, data["campaign_id"], "correct", access_token=_token) | |
| with col2: | |
| if st.button("π νλ €μ", key=f"sentiment:ih_fb_wrong_{answer_id}"): | |
| _submit_feedback(data.get("api_key", ""), answer_id, data["campaign_id"], "wrong", access_token=_token) | |
| with col3: | |
| if st.button("π€ μ λ§€ν΄μ", key=f"sentiment:ih_fb_ambig_{answer_id}"): | |
| _submit_feedback(data.get("api_key", ""), answer_id, data["campaign_id"], "ambiguous", access_token=_token) | |
| def _request_llm_verification( | |
| api_key: str, answer_id: int, force: bool = False, access_token: str | None = None, | |
| ) -> dict | None: | |
| """Request LLM verification for an answer.""" | |
| try: | |
| client = ChainShiftClient(api_key=api_key, access_token=access_token) | |
| return client.verify_answer(answer_id, force=force) | |
| except Exception as e: | |
| st.error(f"LLM κ²μ¦ μμ² μ€ν¨: {e}") | |
| return None | |
| def _submit_feedback( | |
| api_key: str, answer_id: int, campaign_id: int, feedback_type: str, | |
| access_token: str | None = None, | |
| ): | |
| """Submit feedback.""" | |
| try: | |
| client = ChainShiftClient(api_key=api_key, access_token=access_token) | |
| client.submit_feedback(answer_id, campaign_id, feedback_type) | |
| st.success("νΌλλ°±μ΄ μ μ₯λμμ΅λλ€!") | |
| except Exception as e: | |
| st.error(f"νΌλλ°± μ μ₯ μ€ν¨: {e}") | |
| def _render_verification_section(data: dict): | |
| """LLM 2μ°¨ κ²μ¦ ν΅ν© κ²°κ³Ό (from verification.py).""" | |
| # Use RPC-provided stats (accurate counts, no extra queries) | |
| llm_stats = data.get("llm_verification_stats", {}) | |
| total_verified = llm_stats.get("verified_count", 0) | |
| true_positive = llm_stats.get("true_positive_count", 0) | |
| false_positive = llm_stats.get("false_positive_count", 0) | |
| if total_verified > 0: | |
| fp_rate = false_positive / total_verified * 100 | |
| col1, col2, col3 = st.columns(3) | |
| with col1: | |
| st.metric("κ²μ¦ μλ£", f"{total_verified}건") | |
| with col2: | |
| st.metric("β μ ν (True Positive)", f"{true_positive}건") | |
| with col3: | |
| st.metric("β μ€ν (False Positive)", f"{false_positive}건", delta=f"{fp_rate:.1f}%", delta_color="inverse") | |
| # False positive list | |
| fp_tab, tp_tab, citation_tab = st.tabs(["β μ€ν λͺ©λ‘", "β μ ν λͺ©λ‘", "π μΈμ© λΆμ"]) | |
| with fp_tab: | |
| _render_false_positives(data, false_positive) | |
| with tp_tab: | |
| _render_true_negatives(data, true_positive) | |
| with citation_tab: | |
| _render_citation_analysis(data) | |
| else: | |
| st.info("LLM 2μ°¨ κ²μ¦ λ°μ΄ν°κ° μμ΅λλ€") | |
| def _render_false_positives(data: dict, count: int): | |
| """Show false positive cases.""" | |
| if count == 0: | |
| st.success("μ€ν μμ") | |
| return | |
| try: | |
| fp_list, _ = get_false_positives(data["campaign_id"], page_size=20) | |
| except Exception: | |
| fp_list = [] | |
| for item in fp_list: | |
| answer_id = item.get("answer_id", "N/A") | |
| reasoning = item.get("llm_reasoning", "") | |
| st.markdown(f""" | |
| <div style="border-left:3px solid #059669; padding:6px 10px; margin:4px 0; background:#F0FDF4; border-radius:0 6px 6px 0;"> | |
| <strong>#{answer_id}</strong> β μ€ν νμ | |
| <div style="font-size:12px;color:#6B7280;margin-top:2px;">{html.escape(reasoning[:200])}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def _render_true_negatives(data: dict, count: int): | |
| """Show true positive (confirmed negative) cases.""" | |
| if count == 0: | |
| st.info("μ ν μμ") | |
| return | |
| try: | |
| tn_list, _ = get_true_negatives(data["campaign_id"], page_size=20) | |
| except Exception: | |
| tn_list = [] | |
| for item in tn_list: | |
| answer_id = item.get("answer_id", "N/A") | |
| reasoning = item.get("llm_reasoning", "") | |
| st.markdown(f""" | |
| <div style="border-left:3px solid #DC2626; padding:6px 10px; margin:4px 0; background:#FEF2F2; border-radius:0 6px 6px 0;"> | |
| <strong>#{answer_id}</strong> β λΆμ νμ | |
| <div style="font-size:12px;color:#6B7280;margin-top:2px;">{html.escape(reasoning[:200])}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def _render_citation_analysis(data: dict): | |
| """Citation domain analysis.""" | |
| domain_counts = data.get("domain_counts", {}) | |
| if not domain_counts: | |
| st.info("μΈμ© λ°μ΄ν°κ° μμ΅λλ€") | |
| return | |
| st.markdown("**μΈμ© λλ©μΈ λΆν¬**") | |
| fig = create_domain_bar_chart(domain_counts) | |
| st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False}) | |
| df = pd.DataFrame( | |
| [(d, c) for d, c in sorted(domain_counts.items(), key=lambda x: -x[1])], | |
| columns=["λλ©μΈ", "μΈμ© νμ"], | |
| ) | |
| st.dataframe(df, use_container_width=True, hide_index=True) | |
| def _render_strategic_insights(data: dict): | |
| """μ λ΅μ μΈμ¬μ΄νΈ (BIT + CEJ).""" | |
| st.markdown("##### π μ λ΅μ μΈμ¬μ΄νΈ") | |
| bit_stats = data.get("bit_stats", {}) | |
| cej_stats = data.get("cej_stats", {}) | |
| if bit_stats: | |
| st.markdown("**BIT μ¬λΆλ©΄ λΆν¬**") | |
| df = pd.DataFrame( | |
| [(k, v) for k, v in sorted(bit_stats.items(), key=lambda x: -x[1])], | |
| columns=["μ¬λΆλ©΄", "건μ"], | |
| ) | |
| st.dataframe(df, use_container_width=True, hide_index=True) | |
| if cej_stats: | |
| st.markdown("**CEJ λ¨κ³λ³ λΆν¬**") | |
| df = pd.DataFrame( | |
| [(k, v) for k, v in sorted(cej_stats.items(), key=lambda x: -x[1])], | |
| columns=["λ¨κ³", "건μ"], | |
| ) | |
| st.dataframe(df, use_container_width=True, hide_index=True) | |