Spaces:
Sleeping
Sleeping
Download features/sentiment/competitor.py from chainshiftlabs/chainshift-dashboard: direct link, hf CLI and curl.
- Browser
- Download file 24.9 kB
-
https://huggingface.co/spaces/chainshiftlabs/chainshift-dashboard/resolve/main/features/sentiment/competitor.py
- Command line
-
hf download hf://spaces/chainshiftlabs/chainshift-dashboard/features/sentiment/competitor.py
-
curl -L -o competitor.py https://huggingface.co/spaces/chainshiftlabs/chainshift-dashboard/resolve/main/features/sentiment/competitor.py
24.9 kB
| """๊ฐ์ฑ๋ถ์ ๊ฒฝ์์ฌ ๋ถ์ ํญ. | |
| ๊ฒฝ์์ฌ ๋ธ๋๋๋ณ ๊ฐ์ฑ ๋ถ์ ๊ฒฐ๊ณผ ๋ฐ ๋ถ์ ์ธ๊ธ ๋ถ์. | |
| """ | |
| 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 | |