"""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'{type_label} '
f'{display_url}{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"""
๐ ๋ถ์ ๊ฒฐ๊ณผ ํด์
๐ ๋ต๋ณ ์ ์ฒด ๋ถ์ ํ์ ๋: {confidence:.0%} ({tier})
๋ต๋ณ ์ ์ฒด๊ฐ ๋ถ์ ์ ์ธ ํค์ธ์ง ํ๋จํ ์ ์์
๋๋ค. (์ฌ๋ฌ ๋ธ๋๋๊ฐ ์ธ๊ธ๋๋ฉด ํผํฉ๋จ)
๐ ๋ธ๋๋๋ณ ๋ถ์ ํ์ ๋ (์๋ ABSA ์ฐธ์กฐ)
ํน์ ๋ธ๋๋์ ๋ํ ์ธ๊ธ๋ง ์ถ์ถํ์ฌ ๊ทธ ์ธ๊ธ์ด ๋ถ์ ์ ์ธ์ง ํ๋จํ ์ ์์
๋๋ค.
์: ๋ต๋ณ ์ ์ฒด๋ 64%(LOW)์ฌ๋, ํน์ ๋ธ๋๋ ์ธ๊ธ์ 91%(HIGH)์ผ ์ ์์
๋ต๋ณ ํค: {emotion_ko}
๋ต๋ณ ์ ์ฒด์ ๊ฐ์ ์ ๋ถ์๊ธฐ์
๋๋ค.
""", 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'{html.escape(display_answer)}
',
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'{sentiment} '
f'{brand_name} (๐ ์์ฌ) - {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'{sentiment} '
f'{brand_name} (๐ข ๊ฒฝ์์ฌ) - {absa_emoji} ๋ธ๋๋ ํ์ ๋ {conf:.0%} ({absa_tier})',
unsafe_allow_html=True
)
elif competitor_brands:
st.markdown(
f'์ธ๊ธ๋จ '
f'{", ".join(competitor_brands)} (๐ข ๊ฒฝ์์ฌ)',
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)