GitHub Action
Sync from GitHub
ef78361
Raw
History Blame Contribute Delete
26.8 kB
"""μžμ‚¬ λΈŒλžœλ“œ 뢄석 νƒ­.
μžμ‚¬ λΈŒλžœλ“œ λΆ€μ • μ–ΈκΈ‰ 뢄석 + AI 2μ°¨ 검증 κ²°κ³Ό + μ „λž΅μ  μΈμ‚¬μ΄νŠΈ.
"""
import html
import pandas as pd
import streamlit as st
from core.api_client import ChainShiftClient
from core.charts import CONFIDENCE_TIER_COLORS, EMOTION_KO, create_domain_bar_chart
from core.athena_client import fetch_full_answer
from core.styles import TIER_BORDER_COLORS
from core.supabase_client import (
get_false_positives,
get_true_negatives,
)
from core.utils import (
format_brands_list,
get_confidence_tier,
get_feedback_reason_label,
get_feedback_type_emoji,
get_llm_tier_badge,
highlight_evidence_spans,
truncate_text,
)
def render(data: dict):
"""μžμ‚¬ λΈŒλžœλ“œ 뢄석 νƒ­ λ Œλ”λ§."""
# --- Section 1: λΆ€μ • μ–ΈκΈ‰ 뢄석 (from insights.py) ---
st.markdown("##### 🏠 μžμ‚¬ λΈŒλžœλ“œ λΆ€μ • μ–ΈκΈ‰ AI λ‹΅λ³€")
st.caption("AIκ°€ μžμ‚¬ λΈŒλžœλ“œμ— λŒ€ν•΄ λΆ€μ •μ μœΌλ‘œ μ–ΈκΈ‰ν•œ 닡변을 μžλ™μœΌλ‘œ κ°μ§€ν•©λ‹ˆλ‹€")
# --- Overview Card ---
_render_overview(data)
# Filters β€” Row 1: ν”Œλž«νΌ, 확신도, (CEJ), 2μ°¨ 검증
has_cej = bool(data.get("cej_stats"))
filter_cols = st.columns(4 if has_cej else 3)
with filter_cols[0]:
platform_options = ["전체"] + list(data["platform_stats"].keys())
platform_filter = st.selectbox("ν”Œλž«νΌ", options=platform_options, key="sentiment:ih_platform")
with filter_cols[1]:
tier_filter = st.selectbox("확신도", options=["전체", "HIGH", "MEDIUM", "LOW"], key="sentiment:ih_tier")
cej_filter = "전체"
if has_cej:
with filter_cols[2]:
cej_options = ["전체"] + list(data["cej_stats"].keys())
cej_filter = st.selectbox("CEJ 단계", options=cej_options, key="sentiment:ih_cej")
with filter_cols[-1]:
llm_status_filter = st.selectbox("2μ°¨ 검증", options=["전체", "정탐", "μ˜€νƒ", "미검증"], key="sentiment:ih_llm_status")
# Filters β€” Row 2: νŽ˜μ΄μ§€ 크기 (우츑 μ •λ ¬)
_, size_col = st.columns([4, 1])
with size_col:
page_size = st.selectbox("νŽ˜μ΄μ§€ 크기", options=[20, 50, 100], index=1, key="sentiment:ih_page_size")
# Filter candidates
filtered = data["candidates"]
if platform_filter != "전체":
filtered = [c for c in filtered if c.get("platform") == platform_filter]
if tier_filter != "전체":
filtered = [c for c in filtered if get_confidence_tier(c.get("overall_confidence"))[0] == tier_filter]
if cej_filter != "전체":
filtered = [c for c in filtered if c.get("cej_depth1") == cej_filter]
if llm_status_filter == "정탐":
filtered = [c for c in filtered if c.get("llm_verified") and c.get("llm_is_negative")]
elif llm_status_filter == "μ˜€νƒ":
filtered = [c for c in filtered if c.get("llm_verified") and not c.get("llm_is_negative")]
elif llm_status_filter == "미검증":
filtered = [c for c in filtered if not c.get("llm_verified")]
total_all = data.get("total_nudge", len(data["candidates"]))
st.markdown(f"**{len(filtered)}건** ν‘œμ‹œ 쀑 (전체 {total_all}건)")
# Export
_render_export_section(data)
# Pagination
total_pages = max(1, (len(filtered) + page_size - 1) // page_size)
if "sentiment:ih_page" not in st.session_state:
st.session_state["sentiment:ih_page"] = 1
# Reset page when filters change
ih_filter_key = f"{platform_filter}_{tier_filter}_{cej_filter}_{llm_status_filter}_{page_size}"
if st.session_state.get("sentiment:ih_last_filters") != ih_filter_key:
st.session_state["sentiment:ih_page"] = 1
st.session_state["sentiment:ih_last_filters"] = ih_filter_key
current_page = st.session_state["sentiment:ih_page"]
# Pagination header (always show for consistency with other tabs)
start_idx = (current_page - 1) * page_size + 1
end_idx = min(current_page * page_size, len(filtered))
if total_pages > 1:
col_info, col_prev, col_page, col_next = st.columns([3, 1, 1, 1])
with col_info:
st.markdown(f"**전체 {len(filtered):,}건** | νŽ˜μ΄μ§€ {current_page}/{total_pages} ({start_idx}-{end_idx}건)")
with col_prev:
if st.button("⬅️ 이전", disabled=current_page <= 1, key="sentiment:ih_prev"):
st.session_state["sentiment:ih_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:ih_page_input",
)
if new_page != current_page:
st.session_state["sentiment:ih_page"] = new_page
st.rerun()
with col_next:
if st.button("λ‹€μŒ ➑️", disabled=current_page >= total_pages, key="sentiment:ih_next"):
st.session_state["sentiment:ih_page"] = current_page + 1
st.rerun()
else:
st.markdown(f"**전체 {len(filtered):,}건**")
# Candidate cards (paginated)
page_start = (current_page - 1) * page_size
page_end = page_start + page_size
for i, item in enumerate(filtered[page_start:page_end]):
unique_idx = page_start + i
_render_candidate_card(data, item, unique_idx)
# --- Section 2: AI 2μ°¨ 검증 κ²°κ³Ό (from verification.py) ---
st.markdown("---")
st.markdown("##### πŸ€– AI 2μ°¨ 검증 κ²°κ³Ό")
_render_verification_section(data)
# --- Section 3: μ „λž΅μ  μΈμ‚¬μ΄νŠΈ ---
st.markdown("---")
_render_strategic_insights(data)
def _render_overview(data: dict):
"""μžμ‚¬ λΈŒλžœλ“œ μ˜€λ²„λ·° μΉ΄λ“œ."""
candidates = data.get("candidates", [])
total = data.get("total_nudge", len(candidates))
# Extract brand names from candidates
all_brands: set[str] = set()
for c in candidates:
for b in c.get("in_house_brands", []):
all_brands.add(b)
brands_display = ", ".join(sorted(all_brands)[:5]) if all_brands else "N/A"
if len(all_brands) > 5:
brands_display += f" μ™Έ {len(all_brands) - 5}개"
# Tier distribution
tier_stats = data.get("tier_stats", {})
high = tier_stats.get("HIGH", 0)
medium = tier_stats.get("MEDIUM", 0)
low = tier_stats.get("LOW", 0)
# LLM verification stats (from RPC, not limited by PostgREST page size)
llm_stats = data.get("llm_verification_stats", {})
verified_count = llm_stats.get("verified_count", 0)
tp_count = llm_stats.get("true_positive_count", 0)
fp_count = llm_stats.get("false_positive_count", 0)
llm_text = f"{verified_count}건 μ™„λ£Œ"
if verified_count > 0:
llm_text += f" (정탐 {tp_count} / μ˜€νƒ {fp_count})"
st.markdown(f"""
<div style="background: linear-gradient(135deg, #F8FAFC 0%, #EEF2FF 100%); border: 1px solid #C7D2FE;
border-radius: 12px; padding: 16px; margin-bottom: 16px;">
<div style="font-size: 13px; color: #4338CA; font-weight: 600; margin-bottom: 8px;">좔적 λΈŒλžœλ“œ: {html.escape(brands_display)}</div>
<div style="display: flex; gap: 24px; flex-wrap: wrap; font-size: 13px; color: #374151;">
<span>전체 λΆ€μ • 감지: <strong>{total}건</strong></span>
<span>πŸ”΄ HIGH: <strong>{high}</strong> | 🟑 MEDIUM: <strong>{medium}</strong> | 🟒 LOW: <strong>{low}</strong></span>
<span>LLM 검증: <strong>{llm_text}</strong></span>
</div>
</div>
""", unsafe_allow_html=True)
def _render_export_section(data: dict):
"""Export μ˜μ—­ β€” inline λ²„νŠΌ (κ²½μŸμ‚¬/ν‚€μ›Œλ“œ νƒ­κ³Ό 톡일)."""
# Map current UI filters for export
exp_platform = None
exp_llm_neg = None
if "sentiment:ih_platform" in st.session_state:
_p = st.session_state["sentiment:ih_platform"]
if _p != "전체":
exp_platform = _p
if "sentiment:ih_llm_status" in st.session_state:
_s = st.session_state["sentiment:ih_llm_status"]
if _s == "정탐":
exp_llm_neg = True
elif _s == "μ˜€νƒ":
exp_llm_neg = False
_, export_col = st.columns([4, 1])
with export_col:
if st.button("πŸ“₯ Excel λ‹€μš΄λ‘œλ“œ", key="sentiment:ih_export_btn"):
with st.spinner("Excel 파일 생성 쀑..."):
try:
client = ChainShiftClient(api_key=data.get("api_key"), access_token=data.get("access_token"))
xlsx = client.export_nudge_candidates(
data["campaign_id"],
include_full_answers=False,
include_evidence=True,
llm_verified_only=False,
platform=exp_platform,
llm_is_negative=exp_llm_neg,
)
st.session_state["sentiment:ih_excel_data"] = xlsx
st.session_state["sentiment:ih_excel_ready"] = True
except Exception as e:
st.error(f"λ‹€μš΄λ‘œλ“œ μ‹€νŒ¨: {e}")
if st.session_state.get("sentiment:ih_excel_ready"):
st.download_button(
label="πŸ’Ύ μ €μž₯",
data=st.session_state["sentiment:ih_excel_data"],
file_name=f"in_house_analysis_{data['campaign_id']}.xlsx",
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
key="sentiment:ih_dl_btn",
)
def _render_candidate_card(data: dict, item: dict, index: int):
"""Individual candidate card with LLM verification inline."""
answer_id = item.get("answer_id", "N/A")
confidence = item.get("overall_confidence", 0)
tier, tier_emoji, tier_desc = get_confidence_tier(confidence)
tier_color = TIER_BORDER_COLORS.get(tier, "#94A3B8")
raw_emotion = item.get("dominant_emotion") or ""
emotion = EMOTION_KO.get(raw_emotion, raw_emotion) or "N/A"
platform = item.get("platform", "N/A")
brands = item.get("in_house_brands", [])
# LLM verification badge
llm_badge = ""
if item.get("llm_verified"):
if item.get("llm_is_negative"):
llm_badge = '<span style="background:#DC2626;color:white;padding:2px 6px;border-radius:4px;font-size:11px;">πŸ”΄ λΆ€μ • 확인</span>'
else:
llm_badge = '<span style="background:#059669;color:white;padding:2px 6px;border-radius:4px;font-size:11px;">🟒 λΆ€μ • μ•„λ‹˜</span>'
else:
llm_badge = '<span style="background:#F59E0B;color:white;padding:2px 6px;border-radius:4px;font-size:11px;">⏳ 미검증</span>'
st.markdown(f"""
<div style="border-left: 3px solid {tier_color}; padding: 8px 12px; margin: 4px 0;
background: #F8FAFC; border-radius: 0 8px 8px 0;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<span style="font-weight:600;">#{answer_id} β€” {', '.join(brands) if brands else 'N/A'}</span>
<span>{llm_badge} <span style="background:{CONFIDENCE_TIER_COLORS.get(tier,'#94A3B8')};
color:white;padding:2px 6px;border-radius:4px;font-size:11px;">{tier}</span></span>
</div>
<div style="font-size:12px;color:#6B7280;margin-top:4px;">
{platform} | 😞 λΆ€μ • ({emotion}) | 확신도 {confidence:.0%}
</div>
</div>
""", unsafe_allow_html=True)
with st.expander(f"πŸ“– 상세 보기 β€” #{answer_id}", expanded=False):
_render_candidate_detail(data, item, answer_id, index)
def _render_candidate_detail(data: dict, item: dict, answer_id: int, index: int):
"""Candidate detail: question, full answer, ABSA, citations, LLM card, feedback."""
# 1. Question context
question = item.get("question_content", "")
if question:
st.markdown(f"""
<div style="border-left: 4px solid #4338CA; background: #EEF2FF; padding: 10px 12px;
border-radius: 0 8px 8px 0; margin-bottom: 8px;">
<div style="font-size: 11px; color: #4338CA; margin-bottom: 2px;">πŸ’¬ 질문</div>
<div style="font-size: 14px; color: #1E1B4B;">{html.escape(question[:500])}</div>
</div>
""", unsafe_allow_html=True)
# 2. Full answer (lazy-load from Athena)
st.markdown("**πŸ€– AI λ‹΅λ³€**")
preview = item.get("answer_preview", "")
display_answer = _load_full_answer_ih(answer_id, preview, index)
# 3. Brand ABSA
brand_detail = item.get("brand_sentiment_detail", {})
if brand_detail:
_render_brand_absa(brand_detail)
# 4. Citations
_render_citations_ih(answer_id, item, index)
# 5. LLM verification card (styled)
if item.get("llm_verified"):
llm_is_negative = item.get("llm_is_negative", False)
llm_confidence = item.get("llm_confidence", 0) or 0
llm_reasoning = item.get("llm_reasoning", "")
llm_evidence_spans = item.get("llm_evidence_spans", [])
llm_adjusted_tier = item.get("llm_adjusted_tier")
badge_text, badge_color = get_llm_tier_badge(llm_adjusted_tier, llm_is_negative)
badge_bg = {"green": "#10B981", "red": "#EF4444", "orange": "#F59E0B", "blue": "#3B82F6"}.get(badge_color, "#6B7280")
st.markdown("---")
st.markdown(f"""
<div style="background: #F0FDF4; border: 1px solid #86EFAC; border-radius: 8px; padding: 12px; margin: 8px 0;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="font-weight: bold;">πŸ”¬ LLM 2μ°¨ 검증</span>
<span style="background: {badge_bg}; color: white; padding: 4px 12px; border-radius: 20px; font-size: 12px;">{badge_text}</span>
</div>
<div style="font-size: 13px; color: #374151;">
<strong>LLM 확신도:</strong> {llm_confidence:.0%}<br>
<strong>νŒλ‹¨ κ·Όκ±°:</strong> {html.escape(llm_reasoning[:500]) if llm_reasoning else 'N/A'}
</div>
</div>
""", unsafe_allow_html=True)
# Evidence highlighting
if llm_evidence_spans and display_answer:
st.markdown("**πŸ“ κ·Όκ±° λ¬Έμž₯ (ν•˜μ΄λΌμ΄νŠΈ)**")
highlighted_html = highlight_evidence_spans(display_answer, llm_evidence_spans)
st.markdown(
f'<div style="background: #FFFBEB; padding: 12px; border-radius: 8px; font-size: 13px; '
f'white-space: pre-wrap; max-height: 300px; overflow-y: auto;">{highlighted_html}</div>',
unsafe_allow_html=True,
)
st.caption("πŸ”΄ λΆ€μ • | 🟒 긍정 | πŸ”΅ 쀑립 | 🟑 비ꡐ")
# Per-brand LLM results breakdown
per_brand_results = item.get("in_house_llm_results") or []
if len(per_brand_results) > 0:
_render_per_brand_llm_results(per_brand_results)
# Re-verify button
if st.button("πŸ”„ μž¬κ²€μ¦ μš”μ²­", key=f"sentiment:ih_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"):
st.success("μž¬κ²€μ¦ μ™„λ£Œ! νŽ˜μ΄μ§€λ₯Ό μƒˆλ‘œκ³ μΉ¨ν•˜λ©΄ λ°˜μ˜λ©λ‹ˆλ‹€.")
st.rerun()
else:
st.info("아직 LLM 2μ°¨ 검증이 μˆ˜ν–‰λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.")
if st.button("πŸ”¬ LLM 검증 μš”μ²­", key=f"sentiment:ih_verify_{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"):
st.success("검증 μ™„λ£Œ!")
st.rerun()
# 6. Feedback
_render_feedback_inline(data, item, answer_id)
def _load_full_answer_ih(answer_id: int, preview: str, index: int) -> str:
"""Lazy-load full answer from Athena for in-house tab."""
display_answer = preview or "N/A"
if answer_id and answer_id != "N/A":
full_answer_key = f"sentiment:ih_full_{answer_id}_{index}"
load_key = f"sentiment:ih_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_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 preview or "N/A"
label = "βœ… 전체 λ‹΅λ³€ λ‘œλ“œλ¨" if is_loaded else f"πŸ“„ 미리보기 ({len(preview or '')}자)"
st.caption(label)
st.markdown(
f'<div style="background: #FEF2F2; padding: 12px; border-radius: 8px; font-size: 14px; '
f'white-space: pre-wrap; word-break: break-word; max-height: 400px; overflow-y: auto;">'
f'{html.escape(display_answer)}</div>',
unsafe_allow_html=True,
)
return display_answer
def _render_citations_ih(answer_id: int, item: dict, index: int):
"""In-house tab citation rendering (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_brand_absa(brand_detail: dict):
"""Render ABSA results per brand."""
st.markdown("**πŸ” λΈŒλžœλ“œλ³„ 감성 뢄석 (ABSA)**")
in_house = brand_detail.get("in_house", [])
# Handle dual format (list or dict)
if isinstance(in_house, list):
for bd in in_house:
brand = bd.get("brand", "N/A")
sentiment = bd.get("sentiment", "N/A")
confidence = bd.get("confidence", 0)
color = "#DC2626" if sentiment == "negative" else "#059669" if sentiment == "positive" else "#6B7280"
st.markdown(
f'<span style="color:{color};font-weight:600;">{brand}</span>: {sentiment} ({confidence:.0%})',
unsafe_allow_html=True,
)
elif isinstance(in_house, dict):
for brand, info in in_house.items():
sentiment = info.get("sentiment", "N/A") if isinstance(info, dict) else str(info)
st.markdown(f"**{brand}**: {sentiment}")
def _render_per_brand_llm_results(per_brand_results: list[dict]):
"""Render per-brand in-house LLM verification breakdown."""
st.markdown("**🏷️ λΈŒλžœλ“œλ³„ LLM 검증 κ²°κ³Ό**")
for r in per_brand_results:
brand = r.get("brand", "Unknown")
is_neg = r.get("is_negative", False)
conf = r.get("confidence", 0) or 0
reasoning = r.get("reasoning", "")
tier = r.get("adjusted_tier", "NONE")
if is_neg:
badge = f'<span style="background:#DC2626;color:white;padding:2px 6px;border-radius:4px;font-size:11px;">πŸ”΄ λΆ€μ • 확인 ({tier})</span>'
border_color = "#DC2626"
else:
badge = '<span style="background:#059669;color:white;padding:2px 6px;border-radius:4px;font-size:11px;">🟒 λΆ€μ • μ•„λ‹˜</span>'
border_color = "#059669"
st.markdown(f"""
<div style="border-left: 3px solid {border_color}; padding: 6px 10px; margin: 4px 0;
background: #F8FAFC; border-radius: 0 6px 6px 0;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<strong>{html.escape(brand)}</strong>
{badge}
</div>
<div style="font-size: 12px; color: #6B7280; margin-top: 2px;">
확신도 {conf:.0%} β€” {html.escape(reasoning[:200]) if reasoning else 'N/A'}
</div>
</div>
""", unsafe_allow_html=True)
def _render_feedback_inline(data: dict, item: dict, answer_id: int):
"""Inline feedback buttons."""
_token = data.get("access_token")
col1, col2, col3 = st.columns(3)
with col1:
if st.button("πŸ‘ μ •ν™•ν•΄μš”", key=f"sentiment:ih_fb_ok_{answer_id}"):
_submit_feedback(data.get("api_key", ""), answer_id, data["campaign_id"], "correct", access_token=_token)
with col2:
if st.button("πŸ‘Ž ν‹€λ €μš”", key=f"sentiment:ih_fb_wrong_{answer_id}"):
_submit_feedback(data.get("api_key", ""), answer_id, data["campaign_id"], "wrong", access_token=_token)
with col3:
if st.button("πŸ€” μ• λ§€ν•΄μš”", key=f"sentiment:ih_fb_ambig_{answer_id}"):
_submit_feedback(data.get("api_key", ""), answer_id, data["campaign_id"], "ambiguous", access_token=_token)
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
def _submit_feedback(
api_key: str, answer_id: int, campaign_id: int, feedback_type: str,
access_token: str | None = None,
):
"""Submit feedback."""
try:
client = ChainShiftClient(api_key=api_key, access_token=access_token)
client.submit_feedback(answer_id, campaign_id, feedback_type)
st.success("ν”Όλ“œλ°±μ΄ μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€!")
except Exception as e:
st.error(f"ν”Όλ“œλ°± μ €μž₯ μ‹€νŒ¨: {e}")
def _render_verification_section(data: dict):
"""LLM 2μ°¨ 검증 톡합 κ²°κ³Ό (from verification.py)."""
# Use RPC-provided stats (accurate counts, no extra queries)
llm_stats = data.get("llm_verification_stats", {})
total_verified = llm_stats.get("verified_count", 0)
true_positive = llm_stats.get("true_positive_count", 0)
false_positive = llm_stats.get("false_positive_count", 0)
if total_verified > 0:
fp_rate = false_positive / total_verified * 100
col1, col2, col3 = st.columns(3)
with col1:
st.metric("검증 μ™„λ£Œ", f"{total_verified}건")
with col2:
st.metric("βœ… 정탐 (True Positive)", f"{true_positive}건")
with col3:
st.metric("❌ μ˜€νƒ (False Positive)", f"{false_positive}건", delta=f"{fp_rate:.1f}%", delta_color="inverse")
# False positive list
fp_tab, tp_tab, citation_tab = st.tabs(["❌ μ˜€νƒ λͺ©λ‘", "βœ… 정탐 λͺ©λ‘", "πŸ“Ž 인용 뢄석"])
with fp_tab:
_render_false_positives(data, false_positive)
with tp_tab:
_render_true_negatives(data, true_positive)
with citation_tab:
_render_citation_analysis(data)
else:
st.info("LLM 2μ°¨ 검증 데이터가 μ—†μŠ΅λ‹ˆλ‹€")
def _render_false_positives(data: dict, count: int):
"""Show false positive cases."""
if count == 0:
st.success("μ˜€νƒ μ—†μŒ")
return
try:
fp_list, _ = get_false_positives(data["campaign_id"], page_size=20)
except Exception:
fp_list = []
for item in fp_list:
answer_id = item.get("answer_id", "N/A")
reasoning = item.get("llm_reasoning", "")
st.markdown(f"""
<div style="border-left:3px solid #059669; padding:6px 10px; margin:4px 0; background:#F0FDF4; border-radius:0 6px 6px 0;">
<strong>#{answer_id}</strong> β€” μ˜€νƒ ν™•μ •
<div style="font-size:12px;color:#6B7280;margin-top:2px;">{html.escape(reasoning[:200])}</div>
</div>
""", unsafe_allow_html=True)
def _render_true_negatives(data: dict, count: int):
"""Show true positive (confirmed negative) cases."""
if count == 0:
st.info("정탐 μ—†μŒ")
return
try:
tn_list, _ = get_true_negatives(data["campaign_id"], page_size=20)
except Exception:
tn_list = []
for item in tn_list:
answer_id = item.get("answer_id", "N/A")
reasoning = item.get("llm_reasoning", "")
st.markdown(f"""
<div style="border-left:3px solid #DC2626; padding:6px 10px; margin:4px 0; background:#FEF2F2; border-radius:0 6px 6px 0;">
<strong>#{answer_id}</strong> β€” λΆ€μ • ν™•μ •
<div style="font-size:12px;color:#6B7280;margin-top:2px;">{html.escape(reasoning[:200])}</div>
</div>
""", unsafe_allow_html=True)
def _render_citation_analysis(data: dict):
"""Citation domain analysis."""
domain_counts = data.get("domain_counts", {})
if not domain_counts:
st.info("인용 데이터가 μ—†μŠ΅λ‹ˆλ‹€")
return
st.markdown("**인용 도메인 뢄포**")
fig = create_domain_bar_chart(domain_counts)
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
df = pd.DataFrame(
[(d, c) for d, c in sorted(domain_counts.items(), key=lambda x: -x[1])],
columns=["도메인", "인용 횟수"],
)
st.dataframe(df, use_container_width=True, hide_index=True)
def _render_strategic_insights(data: dict):
"""μ „λž΅μ  μΈμ‚¬μ΄νŠΈ (BIT + CEJ)."""
st.markdown("##### πŸ“Š μ „λž΅μ  μΈμ‚¬μ΄νŠΈ")
bit_stats = data.get("bit_stats", {})
cej_stats = data.get("cej_stats", {})
if bit_stats:
st.markdown("**BIT 사뢄면 뢄포**")
df = pd.DataFrame(
[(k, v) for k, v in sorted(bit_stats.items(), key=lambda x: -x[1])],
columns=["사뢄면", "건수"],
)
st.dataframe(df, use_container_width=True, hide_index=True)
if cej_stats:
st.markdown("**CEJ 단계별 뢄포**")
df = pd.DataFrame(
[(k, v) for k, v in sorted(cej_stats.items(), key=lambda x: -x[1])],
columns=["단계", "건수"],
)
st.dataframe(df, use_container_width=True, hide_index=True)