"""감성분석 경쟁사 분석 탭. 경쟁사 브랜드별 감성 분석 결과 및 부정 언급 분석. """ 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'
{alias_str}
') 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'
LLM 검증: {verified_count}건 ({verified_rate:.0f}%)
') alias_html = extra_lines[0] if aliases else "" llm_html = extra_lines[-1] if verified_count > 0 else "" card_html = ( f'
' f'
{brand_name}
' f'{alias_html}' f'
총 언급: {total_mentions:,}건
' f'
' f'긍정 {positive_count}건 ({positive_rate:.1f}%)' f'중립 {neutral_count}건 ({neutral_rate:.1f}%)' f'부정 {negative_count}건 ({negative_rate:.1f}%)' f'
' f'{llm_html}' f'
' ) 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'''
#{answer_id or index+1} — {brands_display} {llm_badge} {tier}
{platform} | {polarity_label}{f" ({EMOTION_KO.get(item.get('dominant_emotion', ''), item.get('dominant_emotion', ''))})" if item.get("dominant_emotion") else ""} | 확신도 {confidence:.0%}
''' 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"""
💬 질문
{html.escape(question[:500])}
""", 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'
' f'{html.escape(display_answer)}
', 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'' f'{sentiment} {brand_name} (🏢 경쟁사) - {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"""
🔬 LLM 2차 검증 {badge_text}
LLM 확신도: {llm_conf:.0%}
판단 근거: {html.escape(verify_data.get("reasoning", "N/A"))}
""", 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'
{highlighted_html}
', 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