Spaces:
Sleeping
Sleeping
| """κ°μ±λΆμ κ²½μμ¬ λΆμ ν. | |
| κ²½μμ¬ λΈλλλ³ κ°μ± λΆμ κ²°κ³Ό λ° λΆμ μΈκΈ λΆμ. | |
| """ | |
| import html | |
| import streamlit as st | |
| from core.api_client import ChainShiftClient | |
| from core.charts import CONFIDENCE_TIER_COLORS, EMOTION_KO, create_brand_sentiment_chart | |
| from core.athena_client import fetch_full_answer | |
| from core.styles import TIER_BORDER_COLORS | |
| from core.utils import ( | |
| get_confidence_tier, | |
| get_llm_tier_badge, | |
| highlight_evidence_spans, | |
| truncate_text, | |
| ) | |
| from .data import _get_competitor_mentions | |
| def render(data: dict): | |
| """κ²½μμ¬ λΆμ ν λ λλ§.""" | |
| st.markdown("##### π’ κ²½μμ¬ λΈλλ λΆμ μΈκΈ λΆμ") | |
| st.caption("AI νλ«νΌμμ κ²½μμ¬ λΈλλκ° λΆμ μ μΌλ‘ μΈκΈλλ μ¬λ‘λ₯Ό λΆμν©λλ€") | |
| # Initialize page state | |
| if "sentiment:comp_page" not in st.session_state: | |
| st.session_state["sentiment:comp_page"] = 1 | |
| # Brand summary from pre-loaded data (Supabase RPC via data.py) | |
| brand_data = data.get("brand_data", {}) | |
| competitor_summary = brand_data.get("competitor_summary", []) | |
| brand_names = ["μ 체"] + [b.get("brand_name", "") for b in competitor_summary if b.get("brand_name")] | |
| # Filters β Row 1: κ°μ±, νλ«νΌ, 2μ°¨ κ²μ¦, λΈλλ | |
| f1, f2, f3, f4 = st.columns(4) | |
| with f1: | |
| polarity_filter = st.selectbox( | |
| "κ°μ±", | |
| options=["μ 체", "negative", "positive", "neutral"], | |
| format_func=lambda x: {"μ 체": "μ 체", "negative": "λΆμ ", "positive": "κΈμ ", "neutral": "μ€λ¦½"}.get(x, x), | |
| key="sentiment:comp_polarity", | |
| ) | |
| with f2: | |
| platform_filter = st.selectbox( | |
| "νλ«νΌ", | |
| options=["μ 체", "CHATGPT", "GEMINI", "PERPLEXITY", "CLAUDE"], | |
| key="sentiment:comp_platform", | |
| ) | |
| with f3: | |
| llm_filter = st.selectbox( | |
| "2μ°¨ κ²μ¦", | |
| options=["μ 체", "μ ν", "μ€ν", "λ―Έκ²μ¦"], | |
| key="sentiment:comp_llm", | |
| ) | |
| with f4: | |
| brand_filter = st.selectbox( | |
| "λΈλλ", | |
| options=brand_names, | |
| key="sentiment:comp_brand", | |
| ) | |
| # Filters β Row 2: νμ΄μ§ ν¬κΈ° (μ°μΈ‘ μ λ ¬) | |
| _, size_col = st.columns([4, 1]) | |
| with size_col: | |
| page_size = st.selectbox("νμ΄μ§ ν¬κΈ°", options=[20, 50, 100], index=1, key="sentiment:comp_page_size") | |
| # Reset page when filter changes | |
| current_filters = f"{polarity_filter}_{platform_filter}_{llm_filter}_{brand_filter}_{page_size}" | |
| if st.session_state.get("sentiment:comp_last_filters") != current_filters: | |
| st.session_state["sentiment:comp_page"] = 1 | |
| st.session_state["sentiment:comp_last_filters"] = current_filters | |
| current_page = st.session_state["sentiment:comp_page"] | |
| # Fetch competitor data with all filters (server-side via RPC) | |
| try: | |
| polarity_param = polarity_filter if polarity_filter != "μ 체" else None | |
| platform_param = platform_filter if platform_filter != "μ 체" else None | |
| brand_param = brand_filter if brand_filter != "μ 체" else None | |
| # Map μ ν/μ€ν/λ―Έκ²μ¦ β direct bool params (server-side filtering) | |
| llm_verified_param: bool | None = None | |
| llm_is_neg_param: bool | None = None | |
| if llm_filter == "μ ν": | |
| llm_verified_param = True | |
| llm_is_neg_param = True | |
| elif llm_filter == "μ€ν": | |
| llm_verified_param = True | |
| llm_is_neg_param = False | |
| elif llm_filter == "λ―Έκ²μ¦": | |
| llm_verified_param = False | |
| resp_data = _get_competitor_mentions( | |
| "sb", | |
| data["campaign_id"], | |
| polarity=polarity_param, | |
| competitor_llm_verified=llm_verified_param, | |
| competitor_llm_is_negative=llm_is_neg_param, | |
| brand_name=brand_param, | |
| platform=platform_param, | |
| page=current_page, | |
| page_size=page_size, | |
| ) | |
| recent_mentions = resp_data.get("recent_mentions", []) | |
| total_answers = resp_data.get("total_answers", 0) | |
| except Exception as e: | |
| st.error(f"κ²½μμ¬ λ°μ΄ν° λ‘λ μ€ν¨: {e}") | |
| return | |
| # Summary stats with filter info | |
| filter_tags = [] | |
| if polarity_filter != "μ 체": | |
| filter_tags.append(f"κ°μ±:{polarity_filter}") | |
| if platform_filter != "μ 체": | |
| filter_tags.append(f"νλ«νΌ:{platform_filter}") | |
| if llm_filter != "μ 체": | |
| filter_tags.append(f"LLM:{llm_filter}") | |
| if brand_filter != "μ 체": | |
| filter_tags.append(f"λΈλλ:{brand_filter}") | |
| # Stats and Export button row | |
| stat_col, export_col = st.columns([4, 1]) | |
| with stat_col: | |
| if filter_tags: | |
| st.markdown(f"**νν° μ μ©**: {' | '.join(filter_tags)} β **{total_answers:,}건**") | |
| else: | |
| st.markdown(f"**λΆμλ AI λ΅λ³**: {total_answers:,}건") | |
| with export_col: | |
| if st.button("π₯ Excel λ€μ΄λ‘λ", key="sentiment:comp_export_btn"): | |
| with st.spinner("Excel νμΌ μμ± μ€..."): | |
| try: | |
| client = ChainShiftClient(api_key=data.get("api_key"), access_token=data.get("access_token")) | |
| # Map bool params back to string for API export endpoint | |
| export_llm = None | |
| if llm_verified_param is True: | |
| export_llm = "verified" | |
| elif llm_verified_param is False: | |
| export_llm = "unverified" | |
| excel_data = client.export_brand_mentions( | |
| data["campaign_id"], | |
| brand_type="competitor", | |
| polarity=polarity_param, | |
| llm_verified=export_llm, | |
| brand_name=brand_param, | |
| ) | |
| st.session_state["sentiment:comp_excel_data"] = excel_data | |
| st.session_state["sentiment:comp_excel_ready"] = True | |
| except Exception as e: | |
| st.error(f"Excel μμ± μ€ν¨: {e}") | |
| # Download button if data is ready | |
| if st.session_state.get("sentiment:comp_excel_ready"): | |
| st.download_button( | |
| label="πΎ μ μ₯", | |
| data=st.session_state["sentiment:comp_excel_data"], | |
| file_name=f"competitor_mentions_{data['campaign_id']}.xlsx", | |
| mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| key="sentiment:comp_dl_btn", | |
| ) | |
| if not competitor_summary and not recent_mentions: | |
| st.info("κ²½μμ¬ λΈλλ μΈκΈ λ°μ΄ν°κ° μμ΅λλ€") | |
| return | |
| # Brand summary cards (always shows ALL data for context) | |
| if competitor_summary: | |
| st.markdown("---") | |
| st.markdown("##### π’ κ²½μμ¬ λΈλλ μμ½") | |
| st.caption("π μ 체 λ°μ΄ν° κΈ°μ€ (νν° λ―Έμ μ©)") | |
| # Create columns for brand cards (max 3 per row) | |
| for i in range(0, len(competitor_summary), 3): | |
| cols = st.columns(3) | |
| for j, col in enumerate(cols): | |
| if i + j < len(competitor_summary): | |
| brand = competitor_summary[i + j] | |
| with col: | |
| _render_brand_summary_card(brand) | |
| # Brand sentiment comparison chart | |
| st.markdown("---") | |
| st.markdown("##### π κ²½μμ¬ λΈλλλ³ κ°μ± λΉκ΅") | |
| if len(competitor_summary) > 0: | |
| fig = create_brand_sentiment_chart(competitor_summary) | |
| st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False}) | |
| # Recent mentions list | |
| st.markdown("---") | |
| st.markdown("##### π κ²½μμ¬ μΈκΈ λͺ©λ‘") | |
| # Calculate pagination info | |
| total_pages = max(1, (total_answers + page_size - 1) // page_size) | |
| start_idx = (current_page - 1) * page_size + 1 | |
| end_idx = min(current_page * page_size, total_answers) | |
| # Pagination header | |
| col_info, col_prev, col_page, col_next = st.columns([3, 1, 1, 1]) | |
| with col_info: | |
| st.markdown(f"**μ 체 {total_answers:,}건** | νμ΄μ§ {current_page}/{total_pages} ({start_idx}-{end_idx}건)") | |
| with col_prev: | |
| if st.button("β¬ οΈ μ΄μ ", disabled=current_page <= 1, key="sentiment:comp_prev"): | |
| st.session_state["sentiment:comp_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:comp_page_input", | |
| ) | |
| if new_page != current_page: | |
| st.session_state["sentiment:comp_page"] = new_page | |
| st.rerun() | |
| with col_next: | |
| if st.button("λ€μ β‘οΈ", disabled=current_page >= total_pages, key="sentiment:comp_next"): | |
| st.session_state["sentiment:comp_page"] = current_page + 1 | |
| st.rerun() | |
| if not recent_mentions: | |
| st.info("νμ¬ νμ΄μ§μ νμν λ°μ΄ν°κ° μμ΅λλ€.") | |
| return | |
| # Render mention cards - use unique index for each card | |
| for i, item in enumerate(recent_mentions): | |
| # Create unique index combining page and position to avoid key collisions | |
| unique_idx = (current_page - 1) * page_size + i | |
| _render_mention_card(data, item, unique_idx) | |
| def _render_brand_summary_card(brand: dict): | |
| """λΈλλ μμ½ μΉ΄λ λ λλ§.""" | |
| brand_name = brand.get("brand_name", "Unknown") | |
| total_mentions = brand.get("total_mentions", 0) | |
| positive_count = brand.get("positive_count", 0) | |
| negative_count = brand.get("negative_count", 0) | |
| neutral_count = brand.get("neutral_count", 0) | |
| positive_rate = brand.get("positive_rate", 0) | |
| negative_rate = brand.get("negative_rate", 0) | |
| neutral_rate = round(neutral_count / total_mentions * 100, 1) if total_mentions > 0 else 0.0 | |
| # Determine primary sentiment color | |
| if negative_rate > positive_rate: | |
| bg_color = "#FEF2F2" # Light red | |
| border_color = "#FCA5A5" | |
| elif positive_rate > negative_rate: | |
| bg_color = "#F0FDF4" # Light green | |
| border_color = "#86EFAC" | |
| else: | |
| bg_color = "#FEF3C7" # Light yellow | |
| border_color = "#FCD34D" | |
| # Build optional lines | |
| extra_lines = [] | |
| aliases = brand.get("aliases", []) | |
| if aliases: | |
| alias_str = ", ".join(aliases[:4]) | |
| if len(aliases) > 4: | |
| alias_str += f" μΈ {len(aliases) - 4}κ°" | |
| extra_lines.append(f'<div style="font-size:11px;color:#9CA3AF;margin-bottom:8px;">{alias_str}</div>') | |
| verified_count = brand.get("llm_verified_count", 0) | |
| if verified_count > 0: | |
| verified_rate = (verified_count / total_mentions * 100) if total_mentions > 0 else 0 | |
| extra_lines.append(f'<div style="font-size:12px;color:#6B7280;margin-top:8px;">LLM κ²μ¦: {verified_count}건 ({verified_rate:.0f}%)</div>') | |
| alias_html = extra_lines[0] if aliases else "" | |
| llm_html = extra_lines[-1] if verified_count > 0 else "" | |
| card_html = ( | |
| f'<div style="background:{bg_color};border:2px solid {border_color};border-radius:12px;padding:16px;margin:8px 0;">' | |
| f'<div style="font-weight:bold;font-size:18px;margin-bottom:4px;">{brand_name}</div>' | |
| f'{alias_html}' | |
| f'<div style="font-size:14px;color:#374151;margin-bottom:8px;">μ΄ μΈκΈ: <strong>{total_mentions:,}건</strong></div>' | |
| f'<div style="display:flex;gap:8px;flex-wrap:wrap;font-size:13px;">' | |
| f'<span style="background:#10B981;color:white;padding:2px 8px;border-radius:4px;">κΈμ {positive_count}건 ({positive_rate:.1f}%)</span>' | |
| f'<span style="background:#6B7280;color:white;padding:2px 8px;border-radius:4px;">μ€λ¦½ {neutral_count}건 ({neutral_rate:.1f}%)</span>' | |
| f'<span style="background:#EF4444;color:white;padding:2px 8px;border-radius:4px;">λΆμ {negative_count}건 ({negative_rate:.1f}%)</span>' | |
| f'</div>' | |
| f'{llm_html}' | |
| f'</div>' | |
| ) | |
| st.markdown(card_html, unsafe_allow_html=True) | |
| def _render_mention_card(data: dict, item: dict, index: int): | |
| """κ°λ³ μΈκΈ μΉ΄λ λ λλ§.""" | |
| # Extract data | |
| polarity = item.get("overall_polarity", "neutral") | |
| confidence = item.get("overall_confidence", 0) or 0 | |
| tier, emoji, tier_desc = get_confidence_tier(confidence) | |
| platform = item.get("platform", "N/A") | |
| question = item.get("question_content", "") | |
| answer = item.get("answer_content") or item.get("answer_preview") or "" | |
| # BrandMention already has brand_name field for the specific brand | |
| brand_name = item.get("brand_name", "") | |
| # For display, show the main brand from this mention | |
| competitor_brands = [brand_name] if brand_name else [] | |
| # Polarity styling | |
| polarity_colors = { | |
| "negative": ("#FEF2F2", "#EF4444", "π λΆμ "), | |
| "positive": ("#F0FDF4", "#10B981", "π κΈμ "), | |
| "neutral": ("#F5F5F4", "#6B7280", "π μ€λ¦½"), | |
| } | |
| bg_color, accent_color, polarity_label = polarity_colors.get(polarity, polarity_colors["neutral"]) | |
| tier_color = CONFIDENCE_TIER_COLORS.get(tier, "#6B7280") | |
| border_color = TIER_BORDER_COLORS.get(tier, "#6B7280") | |
| answer_id = item.get("answer_id") | |
| question_display = html.escape(truncate_text(question, 200)) | |
| answer_short = html.escape(truncate_text(answer, 150)) | |
| brands_display = ", ".join(competitor_brands[:3]) if competitor_brands else "N/A" | |
| # LLM verification status (flat DB fields from get_nudge_export_data RPC) | |
| llm_verified = item.get("competitor_llm_verified", False) | |
| if llm_verified: | |
| llm_is_neg = item.get("competitor_llm_is_negative", False) | |
| if llm_is_neg: | |
| llm_badge = "π΄ λΆμ νμΈ" | |
| llm_badge_color = "#DC2626" | |
| else: | |
| llm_badge = "π’ λΆμ μλ" | |
| llm_badge_color = "#059669" | |
| else: | |
| llm_badge = "β³ λ―Έκ²μ¦" | |
| llm_badge_color = "#F59E0B" | |
| # Card header β left border strip style (matches in_house tab) | |
| header_html = f''' | |
| <div style="border-left: 3px solid {border_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 or index+1} β {brands_display}</span> | |
| <span> | |
| <span style="background:{llm_badge_color};color:white;padding:2px 6px;border-radius:4px;font-size:11px;">{llm_badge}</span> | |
| <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} | {polarity_label}{f" ({EMOTION_KO.get(item.get('dominant_emotion', ''), item.get('dominant_emotion', ''))})" if item.get("dominant_emotion") else ""} | νμ λ {confidence:.0%} | |
| </div> | |
| </div> | |
| ''' | |
| st.markdown(header_html, unsafe_allow_html=True) | |
| # Expander for full details | |
| with st.expander(f"π μμΈ λ³΄κΈ° β #{answer_id or index+1}"): | |
| _render_mention_detail(data, item, answer_id, answer, index) | |
| def _render_mention_detail(data: dict, item: dict, answer_id: int | None, answer: str, index: int): | |
| """μΈκΈ μμΈ μ 보 λ λλ§.""" | |
| confidence = item.get("overall_confidence", 0) or 0 | |
| tier, _, _ = get_confidence_tier(confidence) | |
| # 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. AI λ΅λ³ | |
| st.markdown("**π€ AI λ΅λ³**") | |
| display_answer = _load_full_answer(answer_id, answer, index) | |
| # 3. κ°μ± λΆμ (ABSA) | |
| brand_detail = item.get("brand_sentiments") or {} | |
| if brand_detail and isinstance(brand_detail, dict): | |
| _render_competitor_absa(brand_detail) | |
| # 4. μΈμ© μΆμ² | |
| _render_citations(answer_id, item, index) | |
| # 5. LLM 2μ°¨ κ²μ¦ | |
| st.markdown("---") | |
| _render_llm_verification(data, item, answer_id, display_answer, index) | |
| def _load_full_answer(answer_id: int | None, answer: str, index: int) -> str: | |
| """μ 체 λ΅λ³ λ‘λ.""" | |
| display_answer = answer or "N/A" | |
| if answer_id: | |
| full_answer_key = f"sentiment:comp_full_{answer_id}_{index}" | |
| load_full_key = f"sentiment:comp_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_full_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 answer or "N/A" | |
| label = "β μ 체 λ΅λ³ λ‘λλ¨" if is_loaded else f"π 미리보기 ({len(answer 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_competitor_absa(brand_detail: dict): | |
| """κ²½μμ¬ ABSA κ²°κ³Ό λ λλ§.""" | |
| st.markdown("**π λΈλλλ³ κ°μ± λΆμ (ABSA)**") | |
| # Parse competitor ABSA results | |
| competitor_data = brand_detail.get("competitor", []) | |
| competitor_absa = [] | |
| if isinstance(competitor_data, list): | |
| competitor_absa = competitor_data | |
| elif isinstance(competitor_data, dict): | |
| competitor_absa = competitor_data.get("absa_results", []) | |
| if competitor_absa: | |
| for absa in competitor_absa: | |
| if isinstance(absa, dict): | |
| brand_name = absa.get("brand", "Unknown") | |
| sentiment = absa.get("sentiment", "N/A") | |
| conf = absa.get("confidence", 0) | |
| absa_tier, absa_emoji, _ = get_confidence_tier(conf) | |
| sent_color = "#10B981" if sentiment == "positive" else "#EF4444" if sentiment == "negative" else "#6B7280" | |
| st.markdown( | |
| f'<span style="background: {sent_color}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-right: 8px;">' | |
| f'{sentiment}</span> <strong>{brand_name}</strong> (π’ κ²½μμ¬) - {absa_emoji} νμ λ {conf:.0%} ({absa_tier})', | |
| unsafe_allow_html=True | |
| ) | |
| else: | |
| st.caption("ABSA λΆμ κ²°κ³Ό μμ") | |
| def _render_citations(answer_id: int | None, item: dict, index: int): | |
| """μΈμ© μΆμ² λ λλ§ (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_llm_verification(data: dict, item: dict, answer_id: int | None, display_answer: str, index: int): | |
| """LLM κ²μ¦ μΉμ λ λλ§.""" | |
| # Read from flat DB fields (raw Supabase row, not nested API dict) | |
| llm_verified = item.get("competitor_llm_verified", False) | |
| llm_is_negative = item.get("competitor_llm_is_negative") | |
| llm_confidence = item.get("competitor_llm_confidence") | |
| llm_evidence_spans = item.get("competitor_llm_evidence_spans") or [] | |
| llm_reasoning = item.get("competitor_llm_reasoning") or "" | |
| llm_adjusted_tier = item.get("competitor_llm_adjusted_tier") | |
| verify_key = f"sentiment:comp_verify_{answer_id}_{index}" | |
| if verify_key not in st.session_state: | |
| st.session_state[verify_key] = None | |
| if llm_verified or st.session_state.get(verify_key): | |
| verify_data = st.session_state.get(verify_key) or { | |
| "is_negative": llm_is_negative, | |
| "confidence": llm_confidence, | |
| "evidence_spans": llm_evidence_spans, | |
| "reasoning": llm_reasoning, | |
| "adjusted_tier": llm_adjusted_tier, | |
| } | |
| badge_text, badge_color = get_llm_tier_badge( | |
| verify_data.get("adjusted_tier"), | |
| verify_data.get("is_negative") | |
| ) | |
| llm_conf = verify_data.get("confidence", 0) or 0 | |
| 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: {"#10B981" if badge_color == "green" else "#EF4444" if badge_color == "red" else "#F59E0B"}; 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_conf:.0%}<br> | |
| <strong>νλ¨ κ·Όκ±°:</strong> {html.escape(verify_data.get("reasoning", "N/A"))} | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Evidence spans | |
| evidence_spans = verify_data.get("evidence_spans", []) | |
| if evidence_spans and display_answer: | |
| st.markdown("**π κ·Όκ±° λ¬Έμ₯ (νμ΄λΌμ΄νΈ)**") | |
| highlighted_html = highlight_evidence_spans(display_answer, 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("π΄ λΆμ | π’ κΈμ | π΅ μ€λ¦½ | π‘ λΉκ΅") | |
| # Re-verify button | |
| if st.button("π μ¬κ²μ¦ μμ²", key=f"sentiment:comp_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"): | |
| verified = result["data"].get("verified") | |
| st.session_state[verify_key] = verified | |
| st.rerun() | |
| else: | |
| st.info("μμ§ LLM 2μ°¨ κ²μ¦μ΄ μνλμ§ μμμ΅λλ€.") | |
| if st.button("π¬ LLM κ²μ¦ μμ²", key=f"sentiment:comp_verify_req_{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"): | |
| verified = result["data"].get("verified") | |
| st.session_state[verify_key] = verified | |
| st.success("κ²μ¦ μλ£!") | |
| st.rerun() | |
| 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 | |