GitHub Action
Sync from GitHub
ef78361
Raw
History Blame Contribute Delete
12.7 kB
"""감성뢄석 μ‹€ν–‰ νƒ­.
Job 관리, Pre-flight 체크, νŒŒμ΄ν”„λΌμΈ μ‹œκ°ν™”.
"""
import streamlit as st
import pandas as pd
from core.api_client import ChainShiftClient
from core.job_realtime import (
get_active_jobs,
get_recent_jobs,
format_job_duration,
get_status_emoji,
get_status_label,
)
def render(data: dict):
"""μ‹€ν–‰ νƒ­ λ Œλ”λ§."""
st.markdown("##### πŸš€ 감성뢄석 μ‹€ν–‰")
st.caption("캠페인의 감성뢄석 Job을 μ‹œμž‘ν•˜κ³  μ§„ν–‰ 상황을 ν™•μΈν•©λ‹ˆλ‹€")
if not data or (not data.get("api_key") and not data.get("access_token")):
st.warning("인증 정보가 μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.")
return
analysis_client = ChainShiftClient(api_key=data.get("api_key"), access_token=data.get("access_token"))
# --- Job Status ---
if st.button("πŸ”„ μƒˆλ‘œκ³ μΉ¨", key="sentiment:manual_refresh_jobs"):
st.rerun()
try:
active_jobs = get_active_jobs(campaign_id=data["campaign_id"], limit=5)
recent_jobs = get_recent_jobs(campaign_id=data["campaign_id"], limit=20)
active_ids = {j["id"] for j in active_jobs}
jobs_list = active_jobs + [j for j in recent_jobs if j["id"] not in active_ids]
except Exception as e:
active_jobs = []
jobs_list = []
st.warning(f"Job λͺ©λ‘ λ‘œλ“œ μ‹€νŒ¨: {e}")
has_active = len(active_jobs) > 0
# --- Pipeline Visualization ---
st.markdown("---")
st.markdown("###### πŸ“Š 데이터 νŒŒμ΄ν”„λΌμΈ")
_render_pipeline(data)
# --- Brand Status ---
_render_brand_status(analysis_client, data.get("campaign_id"))
# --- Current status + Start button ---
st.markdown("---")
st.markdown("###### ▢️ 뢄석 μ‹€ν–‰")
if has_active:
_render_active_job(active_jobs[0], analysis_client)
else:
_render_inactive_state(jobs_list, analysis_client, data.get("campaign_id"))
# --- Job history ---
st.markdown("---")
st.markdown("###### πŸ“‹ Job 이λ ₯")
if jobs_list:
_render_job_history(jobs_list)
else:
st.caption("Job 이λ ₯이 μ—†μŠ΅λ‹ˆλ‹€.")
def _fetch_brands(client: ChainShiftClient, campaign_id: int) -> list[dict]:
"""캠페인 λΈŒλžœλ“œ λͺ©λ‘ 쑰회 (session_state μΊμ‹œ)."""
cache_key = f"campaign_brands_{campaign_id}"
if cache_key in st.session_state:
return st.session_state[cache_key]
try:
brands = client.get_campaign_brands(campaign_id)
st.session_state[cache_key] = brands
return brands
except Exception:
return []
def _render_brand_status(client: ChainShiftClient, campaign_id: int):
"""캠페인 λΈŒλžœλ“œ ν˜„ν™© ν‘œμ‹œ."""
brands = _fetch_brands(client, campaign_id)
if not brands:
return
in_house = [b for b in brands if b.get("brand_type") in ("PRIMARY", "USER")]
competitor = [b for b in brands if b.get("brand_type") == "SECONDARY"]
with st.expander(f"🏷️ 캠페인 λΈŒλžœλ“œ ν˜„ν™© (μžμ‚¬ {len(in_house)}개 / κ²½μŸμ‚¬ {len(competitor)}개)", expanded=False):
col1, col2 = st.columns(2)
with col1:
st.markdown(f"**μžμ‚¬ λΈŒλžœλ“œ** ({len(in_house)}개)")
if in_house:
for b in in_house:
synonyms = b.get("synonyms") or []
syn_text = f" \nμœ μ‚¬μ–΄: {', '.join(synonyms)}" if synonyms else ""
st.markdown(f"- **{b['name']}**{syn_text}")
else:
st.caption("λ“±λ‘λœ μžμ‚¬ λΈŒλžœλ“œ μ—†μŒ")
with col2:
st.markdown(f"**κ²½μŸμ‚¬ λΈŒλžœλ“œ** ({len(competitor)}개)")
if competitor:
for b in competitor:
synonyms = b.get("synonyms") or []
syn_text = f" \nμœ μ‚¬μ–΄: {', '.join(synonyms)}" if synonyms else ""
st.markdown(f"- **{b['name']}**{syn_text}")
else:
st.caption("λ“±λ‘λœ κ²½μŸμ‚¬ λΈŒλžœλ“œ μ—†μŒ")
def _render_pipeline(data: dict):
"""νŒŒμ΄ν”„λΌμΈ μ‹œκ°ν™”."""
pipe_col1, pipe_col2, pipe_col3, pipe_col4 = st.columns(4)
overview_total = data.get("overview_total_answers") or 0
overview_ih_neg = data.get("overview_nudge_candidates") or 0
overview_llm_done = data.get("overview_llm_verified") or 0
fp_rate = data.get("overview_false_positive_rate") or 0
overview_llm_confirmed = overview_llm_done - int(overview_llm_done * fp_rate)
with pipe_col1:
st.markdown(f"""
<div style="background: #DBEAFE; border-radius: 8px; padding: 12px; text-align: center;">
<div style="font-size: 24px;">πŸ“₯</div>
<div style="font-weight: bold;">1. 데이터 μˆ˜μ§‘</div>
<div style="font-size: 20px; color: #1D4ED8;">{overview_total:,}</div>
<div style="font-size: 12px; color: #6B7280;">AI λ‹΅λ³€</div>
</div>
""", unsafe_allow_html=True)
with pipe_col2:
ih_rate = (overview_ih_neg / overview_total * 100) if overview_total > 0 else 0
st.markdown(f"""
<div style="background: #FEF3C7; border-radius: 8px; padding: 12px; text-align: center;">
<div style="font-size: 24px;">πŸ”</div>
<div style="font-weight: bold;">2. DeBERTa 뢄석</div>
<div style="font-size: 20px; color: #D97706;">{overview_ih_neg:,}</div>
<div style="font-size: 12px; color: #6B7280;">λΆ€μ • 감지 ({ih_rate:.1f}%)</div>
</div>
""", unsafe_allow_html=True)
with pipe_col3:
verify_rate = (overview_llm_done / overview_ih_neg * 100) if overview_ih_neg > 0 else 0
st.markdown(f"""
<div style="background: #D1FAE5; border-radius: 8px; padding: 12px; text-align: center;">
<div style="font-size: 24px;">πŸ€–</div>
<div style="font-weight: bold;">3. LLM 검증</div>
<div style="font-size: 20px; color: #059669;">{overview_llm_done:,}</div>
<div style="font-size: 12px; color: #6B7280;">μ™„λ£Œ ({verify_rate:.0f}%)</div>
</div>
""", unsafe_allow_html=True)
with pipe_col4:
confirm_rate = (overview_llm_confirmed / overview_llm_done * 100) if overview_llm_done > 0 else 0
st.markdown(f"""
<div style="background: #FEE2E2; border-radius: 8px; padding: 12px; text-align: center;">
<div style="font-size: 24px;">🎯</div>
<div style="font-weight: bold;">4. 정탐</div>
<div style="font-size: 20px; color: #DC2626;">{overview_llm_confirmed:,}</div>
<div style="font-size: 12px; color: #6B7280;">정탐λ₯  {confirm_rate:.1f}%</div>
</div>
""", unsafe_allow_html=True)
def _render_active_job(active: dict, client: ChainShiftClient):
"""ν™œμ„± Job λ Œλ”λ§."""
progress = active.get("progress", 0)
status = active.get("status", "")
status_emoji = get_status_emoji(status)
status_label = get_status_label(status)
duration = format_job_duration(active)
message = active.get("message", "처리 쀑...")
total_answers = active.get("total_answers", 0)
processed = active.get("processed_answers", 0)
st.markdown(f"""
<div style="background: linear-gradient(135deg, #EBF5FF 0%, #F0F9FF 100%);
border-radius: 12px; padding: 20px; margin-bottom: 16px;
border: 1px solid #BFDBFE;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<span style="font-size: 28px;">{status_emoji}</span>
<span style="font-size: 20px; font-weight: bold; margin-left: 8px;">{status_label}</span>
</div>
<div style="text-align: right;">
<div style="font-size: 32px; font-weight: bold; color: #1D4ED8;">{progress}%</div>
<div style="font-size: 12px; color: #6B7280;">μ†Œμš”μ‹œκ°„: {duration}</div>
</div>
</div>
<div style="margin-top: 12px; font-size: 14px; color: #374151;">
{message}
</div>
<div style="margin-top: 8px; font-size: 12px; color: #6B7280;">
처리: {processed:,} / {total_answers:,} λ‹΅λ³€
</div>
</div>
""", unsafe_allow_html=True)
st.progress(progress / 100)
if st.button("β›” 뢄석 μ·¨μ†Œ", key="sentiment:cancel_job", type="secondary"):
try:
client.cancel_analysis_job(active["id"])
st.success("μ·¨μ†Œ μš”μ²­ μ™„λ£Œ")
st.rerun()
except Exception as e:
st.error(f"μ·¨μ†Œ μ‹€νŒ¨: {e}")
def _render_inactive_state(jobs_list: list, client: ChainShiftClient, campaign_id: int):
"""λΉ„ν™œμ„± μƒνƒœ λ Œλ”λ§."""
# --- 졜근 뢄석 μƒνƒœ ---
if jobs_list:
latest = jobs_list[0]
latest_status = latest.get("status", "")
latest_emoji = get_status_emoji(latest_status)
latest_label = get_status_label(latest_status)
latest_duration = format_job_duration(latest)
completed_at = latest.get("completed_at") or latest.get("created_at") or ""
if completed_at:
completed_at = completed_at[:19].replace("T", " ")
if latest_status == "completed":
st.success(f"{latest_emoji} 졜근 뢄석: **{latest_label}** (μ†Œμš”: {latest_duration}, {completed_at})")
elif latest_status == "failed":
st.error(f"{latest_emoji} 졜근 뢄석: **{latest_label}** - {(latest.get('error_message') or 'μ•Œ 수 μ—†λŠ” 였λ₯˜')[:50]}")
else:
st.info(f"{latest_emoji} 졜근 뢄석: **{latest_label}** ({completed_at})")
else:
st.info("아직 μ‹€ν–‰λœ 뢄석이 μ—†μŠ΅λ‹ˆλ‹€.")
# --- 뢄석 μ„€μ • ---
st.markdown("###### 뢄석 μ„€μ •")
col1, col2 = st.columns(2)
with col1:
run_brand = st.checkbox("μžμ‚¬/κ²½μŸμ‚¬ 감성 뢄석", value=True, key="run:brand")
with col2:
run_keyword = st.checkbox("ν‚€μ›Œλ“œ 감성 뢄석", value=False, key="run:keyword")
# ν‚€μ›Œλ“œ μž…λ ₯ (keyword scope 선택 μ‹œ)
keywords = []
if run_keyword:
keywords_input = st.text_input(
"뢄석 ν‚€μ›Œλ“œ (μ‰Όν‘œ ꡬ뢄)",
key="run:keywords",
placeholder="μ‚¬λ£Œ, μ†Œν™”, μ•ŒλŸ¬μ§€",
)
keywords = [k.strip() for k in keywords_input.split(",") if k.strip()]
# LLM 검증 μ˜΅μ…˜
include_llm = st.checkbox(
"2μ°¨ LLM 검증 포함",
value=False,
key="run:llm",
help="λΆ€μ • 감지 κ²°κ³Όλ₯Ό LLM으둜 ꡐ차 κ²€μ¦ν•©λ‹ˆλ‹€ (μ‹œκ°„ μΆ”κ°€)",
)
# 뢄석 μ‹œμž‘ λ²„νŠΌ (keyword 선택 μ‹œ ν‚€μ›Œλ“œ μž…λ ₯ ν•„μˆ˜)
can_start = run_brand or (run_keyword and len(keywords) > 0)
if st.button(
"▢️ 뢄석 μ‹œμž‘",
type="primary",
key="sentiment:start_analysis",
disabled=not can_start,
):
scope = []
if run_brand:
scope.append("brand")
if run_keyword:
scope.append("keyword")
options = {
"scope": scope,
"keywords": keywords if run_keyword else [],
"include_llm_verification": include_llm,
}
try:
client.start_analysis_job(campaign_id, options=options)
st.success("뢄석 Job이 μƒμ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€!")
st.rerun()
except Exception as e:
st.error(f"뢄석 μ‹œμž‘ μ‹€νŒ¨: {e}")
# --- 연ꡬ 뢄석 μŠ€μΊν΄λ”© ---
st.markdown("---")
st.markdown("###### 연ꡬ 뢄석 (μ€€λΉ„ 쀑)")
st.info(
"연ꡬ 뢄석은 λ°μ΄ν„°νŒ€ Athena ν…Œμ΄λΈ” μ„ΈνŒ… μ™„λ£Œ ν›„ μ‚¬μš© κ°€λŠ₯ν•©λ‹ˆλ‹€.\n"
"ν•„μš” ν…Œμ΄λΈ”: `fanouts` (S3 μŠ€λƒ…μƒ· 미포함)"
)
def _render_job_history(jobs_list: list):
"""Job 이λ ₯ ν…Œμ΄λΈ”."""
rows = []
for j in jobs_list:
status = j.get("status", "")
status_emoji = get_status_emoji(status)
status_label = get_status_label(status)
duration = format_job_duration(j)
total = j.get("total_answers", 0)
nudge = j.get("nudge_candidates", 0)
rows.append({
"μƒνƒœ": f"{status_emoji} {status_label}",
"μ§„ν–‰λ₯ ": f"{j.get('progress', 0)}%",
"μ²˜λ¦¬λŸ‰": f"{total:,}건" if total else "-",
"λ„›μ§€ 후보": f"{nudge:,}건" if nudge else "-",
"μ†Œμš”μ‹œκ°„": duration,
"생성일": (j.get("created_at") or "")[:19].replace("T", " "),
"ID": (j.get("id") or "")[:8],
})
st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True)