Spaces:
Sleeping
Sleeping
| """Dashboard expander and section components.""" | |
| import html | |
| import streamlit as st | |
| from core.charts import EMOTION_KO | |
| from core.utils import get_confidence_tier, truncate_text | |
| # Content type labels for citations | |
| CONTENT_TYPE_LABELS = { | |
| "EDITORIAL": "📰 에디토리얼", | |
| "TUTORIAL_REVIEW": "📝 리뷰/튜토리얼", | |
| "COMPARISON": "⚖️ 비교 분석", | |
| "RANKED_LIST": "📊 순위 목록", | |
| "FORUM_THREAD": "💬 포럼/커뮤니티", | |
| "HOMEPAGE": "🏠 홈페이지", | |
| "CATALOG": "📦 카탈로그", | |
| "DOCUMENTATION": "📚 문서", | |
| "FAQ": "❓ FAQ", | |
| "WHITEPAPER": "📄 백서", | |
| "PRESS_RELEASE": "📢 보도자료", | |
| "CASE_STUDY": "💼 사례연구", | |
| "PRICING": "💰 가격정보", | |
| "DETAIL": "🔍 상세페이지", | |
| "DIRECTORY_ENTRY": "📋 디렉토리", | |
| "SUBSTITUTE": "🔄 대체제", | |
| "OTHERS": "📎 기타", | |
| } | |
| def render_citation(cit: dict) -> None: | |
| """Render a single citation item. | |
| Args: | |
| cit: Citation dict with source_url, content_type, page_title | |
| """ | |
| url = cit.get("source_url", "") | |
| ctype = cit.get("content_type") or "OTHERS" | |
| title = cit.get("page_title") or "" | |
| type_label = CONTENT_TYPE_LABELS.get(ctype, f"📎 {ctype}") | |
| display_url = url[:50] + "..." if len(url) > 50 else url | |
| display_title = f' "{title[:30]}..."' if title and len(title) > 30 else f' "{title}"' if title else "" | |
| st.markdown( | |
| f'<span style="background: #E0E7FF; color: #3730A3; padding: 2px 6px; ' | |
| f'border-radius: 4px; font-size: 11px; margin-right: 4px;">{type_label}</span> ' | |
| f'<a href="{url}" target="_blank">{display_url}</a>{display_title}', | |
| unsafe_allow_html=True | |
| ) | |
| def render_nudge_expander( | |
| item: dict, | |
| answer_id: int | None, | |
| index: int, | |
| fetch_full_answer_fn, | |
| fetch_citations_fn, | |
| ) -> None: | |
| """Render nudge candidate expander with full details. | |
| Args: | |
| item: Nudge candidate data dict | |
| answer_id: Answer ID for Athena fetch | |
| index: Item index for display | |
| fetch_full_answer_fn: Function to fetch full answer from Athena | |
| fetch_citations_fn: Function to fetch citations (Supabase fallback) | |
| """ | |
| confidence = item.get("overall_confidence", 0) or 0 | |
| tier, _, _ = get_confidence_tier(confidence) | |
| emotion = item.get("dominant_emotion", "N/A") | |
| emotion_ko = EMOTION_KO.get(emotion, emotion) if emotion else "N/A" | |
| answer = item.get("answer_preview", "") | |
| brand_detail = item.get("brand_sentiment_detail", {}) | |
| with st.expander(f"📖 상세 보기 (답변 #{answer_id or index+1})"): | |
| # Analysis explanation box | |
| st.markdown(f""" | |
| <div style="background: #FFF7ED; border-left: 4px solid #F59E0B; padding: 12px; margin-bottom: 12px; border-radius: 0 8px 8px 0; font-size: 13px;"> | |
| <strong>📊 분석 결과 해석</strong><br><br> | |
| <strong>📄 답변 전체 부정 확신도: {confidence:.0%} ({tier})</strong><br> | |
| 답변 전체가 부정적인 톤인지 판단한 점수입니다. (여러 브랜드가 언급되면 혼합됨)<br><br> | |
| <strong>🔍 브랜드별 부정 확신도</strong> (아래 ABSA 참조)<br> | |
| 특정 브랜드에 대한 언급만 추출하여 그 언급이 부정적인지 판단한 점수입니다.<br> | |
| <em style="color: #9CA3AF;">예: 답변 전체는 64%(LOW)여도, 특정 브랜드 언급은 91%(HIGH)일 수 있음</em><br><br> | |
| <strong>답변 톤: {emotion_ko}</strong><br> | |
| 답변 전체의 감정적 분위기입니다. | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Full answer from Athena | |
| st.markdown("**🤖 AI 답변 전문**") | |
| if answer_id: | |
| full_answer_key = f"full_answer_{answer_id}" | |
| load_full_key = f"load_full_{answer_id}" | |
| if full_answer_key not in st.session_state: | |
| st.session_state[full_answer_key] = None | |
| load_full = st.checkbox( | |
| "📥 전체 답변 불러오기", | |
| key=load_full_key, | |
| value=st.session_state.get(full_answer_key) is not None | |
| ) | |
| if load_full and st.session_state.get(full_answer_key) is None: | |
| with st.spinner("전체 답변을 가져오는 중..."): | |
| full_content = fetch_full_answer_fn(answer_id) | |
| if isinstance(full_content, str) and len(full_content) > 0: | |
| st.session_state[full_answer_key] = full_content | |
| st.rerun() | |
| else: | |
| # Store empty string to prevent infinite re-fetch loop | |
| st.session_state[full_answer_key] = "" | |
| cached = st.session_state.get(full_answer_key) | |
| display_answer = cached if (isinstance(cached, str) and len(cached) > 0) else answer or "N/A" | |
| is_full = isinstance(cached, str) and len(cached) > 0 | |
| label = "✅ 전체 답변 로드됨" if is_full else f"📄 미리보기 ({len(answer or '')}자)" | |
| st.caption(label) | |
| else: | |
| display_answer = answer or "N/A" | |
| st.markdown( | |
| f'<div style="background: #FEF2F2; padding: 12px; border-radius: 8px; ' | |
| f'font-size: 14px; white-space: pre-wrap; word-break: break-word; ' | |
| f'max-height: 400px; overflow-y: auto;">{html.escape(display_answer)}</div>', | |
| unsafe_allow_html=True | |
| ) | |
| # Brand sentiment detail | |
| if brand_detail and isinstance(brand_detail, dict): | |
| st.markdown("**🔍 브랜드별 감성 분석 (ABSA) - 브랜드별 부정 확신도**") | |
| _render_brand_absa(brand_detail) | |
| # Citations | |
| st.markdown("**🔗 인용 출처 (Citation Sources)**") | |
| _render_citations_section(answer_id, item.get("citation_urls", []), fetch_citations_fn) | |
| def _render_brand_absa(brand_detail: dict) -> None: | |
| """Render brand ABSA results.""" | |
| in_house_data = brand_detail.get("in_house", {}) | |
| in_house_absa = in_house_data.get("absa_results", []) | |
| for absa in in_house_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; ' | |
| f'border-radius: 4px; font-size: 12px; margin-right: 8px;">{sentiment}</span> ' | |
| f'<strong>{brand_name}</strong> (🏠 자사) - {absa_emoji} 브랜드 확신도 {conf:.0%} ({absa_tier})', | |
| unsafe_allow_html=True | |
| ) | |
| competitor_data = brand_detail.get("competitor", {}) | |
| competitor_brands = competitor_data.get("brands", []) | |
| 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; ' | |
| f'border-radius: 4px; font-size: 12px; margin-right: 8px;">{sentiment}</span> ' | |
| f'<strong>{brand_name}</strong> (🏢 경쟁사) - {absa_emoji} 브랜드 확신도 {conf:.0%} ({absa_tier})', | |
| unsafe_allow_html=True | |
| ) | |
| elif competitor_brands: | |
| st.markdown( | |
| f'<span style="background: #6B7280; color: white; padding: 2px 8px; ' | |
| f'border-radius: 4px; font-size: 12px;">언급됨</span> ' | |
| f'<strong>{", ".join(competitor_brands)}</strong> (🏢 경쟁사)', | |
| unsafe_allow_html=True | |
| ) | |
| def _render_citations_section(answer_id: int | None, citation_urls: list, fetch_citations_fn) -> None: | |
| """Render citations section.""" | |
| citations_key = f"citations_{answer_id}" | |
| if citations_key not in st.session_state: | |
| st.session_state[citations_key] = None | |
| if st.session_state.get(citations_key) is None and answer_id: | |
| citations = fetch_citations_fn(answer_id) | |
| st.session_state[citations_key] = citations if citations else [] | |
| citations = st.session_state.get(citations_key, []) | |
| if citations: | |
| if len(citations) <= 5: | |
| for cit in citations: | |
| render_citation(cit) | |
| else: | |
| for cit in citations[:5]: | |
| render_citation(cit) | |
| with st.expander(f"📂 나머지 {len(citations) - 5}개 더 보기"): | |
| for cit in citations[5:]: | |
| render_citation(cit) | |
| elif citation_urls: | |
| if len(citation_urls) <= 5: | |
| for url in citation_urls: | |
| st.markdown(f"• [{url[:60]}...]({url})" if len(url) > 60 else f"• [{url}]({url})") | |
| else: | |
| for url in citation_urls[:5]: | |
| st.markdown(f"• [{url[:60]}...]({url})" if len(url) > 60 else f"• [{url}]({url})") | |
| with st.expander(f"📂 나머지 {len(citation_urls) - 5}개 더 보기"): | |
| for url in citation_urls[5:]: | |
| st.markdown(f"• [{url[:60]}...]({url})" if len(url) > 60 else f"• [{url}]({url})") | |
| else: | |
| st.caption("인용 소스 없음") | |
| def render_feedback_section(feedback_stats: dict) -> None: | |
| """Render feedback statistics expander section. | |
| Args: | |
| feedback_stats: Dict with feedback counts and accuracy | |
| """ | |
| from .metrics import render_feedback_stats | |
| fb_total = feedback_stats.get("total_feedback", 0) | |
| if fb_total > 0: | |
| with st.expander("📝 **피드백 분석** - 사용자 검증 현황", expanded=False): | |
| render_feedback_stats(feedback_stats) | |
| def render_llm_verification_section(item: dict, is_false_positive: bool = True) -> None: | |
| """Render LLM verification item section (used inside expander). | |
| This is a wrapper that calls render_verification_item from cards module. | |
| Args: | |
| item: Verification result dict | |
| is_false_positive: True for FP, False for TN | |
| """ | |
| from .cards import render_verification_item | |
| render_verification_item(item, is_false_positive) | |