GitHub Action
Sync from GitHub
ef78361
Raw
History Blame Contribute Delete
10.7 kB
"""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)