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)