diff --git a/.streamlit/config.toml b/.streamlit/config.toml
new file mode 100644
index 0000000000000000000000000000000000000000..e6ef945ce1d4e99fb9f09a362c72e0280b9f8a76
--- /dev/null
+++ b/.streamlit/config.toml
@@ -0,0 +1,14 @@
+[theme]
+primaryColor = "#3B82F6"
+backgroundColor = "#FFFFFF"
+secondaryBackgroundColor = "#F8FAFC"
+textColor = "#1E293B"
+font = "sans serif"
+
+[server]
+headless = true
+enableCORS = false
+enableXsrfProtection = true
+
+[client]
+showSidebarNavigation = false
diff --git a/.streamlit/secrets.toml.example b/.streamlit/secrets.toml.example
new file mode 100644
index 0000000000000000000000000000000000000000..b9be80e935f2412a48be7490cac553e194b8f865
--- /dev/null
+++ b/.streamlit/secrets.toml.example
@@ -0,0 +1,7 @@
+# Streamlit Cloud Secrets Template
+# Copy this to Streamlit Cloud Dashboard > App Settings > Secrets
+
+# Optional: Default API settings
+[api]
+base_url = "https://chainshift-service-api.vercel.app"
+# api_key = "sk_live_xxx" # Optional: pre-fill API key
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..4a41bb8f2c61a045565180fec7a90fec61e0149d
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,9 @@
+FROM python:3.11-slim
+WORKDIR /app
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+COPY . .
+EXPOSE 7860
+ENV STREAMLIT_SERVER_PORT=7860
+ENV STREAMLIT_SERVER_ADDRESS=0.0.0.0
+CMD ["streamlit", "run", "app.py"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..9ff2fe437ebed54d376f4119c2b3db3ae33e7588
--- /dev/null
+++ b/README.md
@@ -0,0 +1,8 @@
+---
+title: ChainShift Sentiment Dashboard
+emoji: ๐
+colorFrom: blue
+colorTo: purple
+sdk: docker
+pinned: false
+---
diff --git a/app.py b/app.py
new file mode 100644
index 0000000000000000000000000000000000000000..df05655873b86b510d70d88f8c21b935fa2550fb
--- /dev/null
+++ b/app.py
@@ -0,0 +1,77 @@
+"""ChainShift Brand Risk Monitoring Dashboard v10.0.
+
+Feature Plugin Architecture: ์ ๊ธฐ๋ฅ = features/ ๋๋ ํ ๋ฆฌ ์์ฑ โ ์๋ ๋ฑ๋ก.
+"""
+
+import streamlit as st
+
+from core.styles import DASHBOARD_CSS
+from sidebar import render_sidebar
+from registry import discover_features
+
+
+# =============================================================================
+# Page Config
+# =============================================================================
+
+st.set_page_config(
+ page_title="ChainShift Dashboard",
+ page_icon="โก",
+ layout="wide",
+ initial_sidebar_state="expanded",
+)
+
+st.markdown(DASHBOARD_CSS, unsafe_allow_html=True)
+
+st.title("ChainShift")
+st.caption("AI ๊ฒ์ ํ๋ซํผ์์ ๋ธ๋๋๊ฐ ์ด๋ป๊ฒ ์ธ๊ธ๋๊ณ ์ธ์ฉ๋๋์ง ๋ถ์ํฉ๋๋ค")
+
+
+# =============================================================================
+# Sidebar (์ธ์ฆ + ์บ ํ์ธ ์ ํ)
+# =============================================================================
+
+sidebar_result = render_sidebar()
+if not sidebar_result:
+ st.stop()
+
+auth_info, selected_campaign_id, selected_campaign_display = sidebar_result
+
+
+# =============================================================================
+# Feature Discovery + Rendering
+# =============================================================================
+
+features = discover_features()
+
+if not features:
+ st.error("๋ฑ๋ก๋ Feature๊ฐ ์์ต๋๋ค. features/ ๋๋ ํ ๋ฆฌ๋ฅผ ํ์ธํ์ธ์.")
+ st.stop()
+
+# Base context shared by all features
+base_ctx = {
+ "api_key": auth_info.get("api_key"),
+ "access_token": auth_info.get("access_token"),
+ "campaign_id": selected_campaign_id,
+ "campaign_name": selected_campaign_display,
+ "user_email": auth_info.get("email"),
+}
+
+# Create tabs from discovered features
+tab_labels = [f"{f['config']['icon']} {f['config']['name']}" for f in features]
+tabs = st.tabs(tab_labels)
+
+for tab, feature in zip(tabs, features):
+ with tab:
+ try:
+ feature["module"].render(base_ctx)
+ except Exception as e:
+ st.error(f"{feature['config']['name']} ๋ก๋ฉ ์คํจ: {e}")
+
+
+# =============================================================================
+# Footer
+# =============================================================================
+
+st.markdown("---")
+st.caption("ChainShift v10.0")
diff --git a/components/__init__.py b/components/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..3827eaebd86f23f42c878735a3a08fdbb757a72e
--- /dev/null
+++ b/components/__init__.py
@@ -0,0 +1,31 @@
+"""Dashboard UI components."""
+
+from .cards import (
+ render_nudge_card,
+ render_brand_card,
+ render_verification_item,
+ render_polarity_item,
+)
+from .expanders import (
+ render_nudge_expander,
+ render_feedback_section,
+ render_llm_verification_section,
+)
+from .metrics import (
+ render_kpi_row,
+ render_verification_stats,
+ render_polarity_stats,
+)
+
+__all__ = [
+ "render_nudge_card",
+ "render_brand_card",
+ "render_verification_item",
+ "render_polarity_item",
+ "render_nudge_expander",
+ "render_feedback_section",
+ "render_llm_verification_section",
+ "render_kpi_row",
+ "render_verification_stats",
+ "render_polarity_stats",
+]
diff --git a/components/cards.py b/components/cards.py
new file mode 100644
index 0000000000000000000000000000000000000000..d86f5c6f0d9a1e64fa793eac6af839c0fcd59076
--- /dev/null
+++ b/components/cards.py
@@ -0,0 +1,188 @@
+"""Dashboard card components."""
+
+import html
+import streamlit as st
+
+from core.charts import CONFIDENCE_TIER_COLORS
+from core.styles import TIER_BORDER_COLORS
+from core.utils import get_confidence_tier, truncate_text, format_brands_list
+
+
+def render_nudge_card(
+ item: dict,
+ tier: str,
+ emoji: str,
+ tier_desc: str,
+ confidence: float,
+) -> None:
+ """Render nudge candidate card with summary info.
+
+ Args:
+ item: Nudge candidate data dict
+ tier: Confidence tier (HIGH/MEDIUM/LOW)
+ emoji: Tier emoji
+ tier_desc: Tier description
+ confidence: Confidence score (0-1)
+ """
+ cej_stage = item.get("cej_depth2") or item.get("cej_depth1") or "N/A"
+ platform = item.get("platform", "N/A")
+
+ in_house = item.get("in_house_brands", [])
+ mentioned = item.get("mentioned_brands", [])
+
+ question = item.get("question_content", "")
+ answer = item.get("answer_preview", "")
+
+ tier_color = CONFIDENCE_TIER_COLORS.get(tier, "#6B7280")
+ border_color = TIER_BORDER_COLORS.get(tier, "#6B7280")
+
+ question_display = html.escape(truncate_text(question, 200))
+ answer_short = html.escape(truncate_text(answer, 150))
+ in_house_display = html.escape(format_brands_list(in_house))
+ mentioned_display = html.escape(format_brands_list(mentioned))
+
+ header_html = f"""
+
+
+
+ ๐ CEJ: {cej_stage}
+
+
+ {emoji} ๋ต๋ณ ์ ์ฒด: {tier} ({confidence:.0%})
+
+
+
+
๐ฌ ์ง๋ฌธ
+
{question_display}
+
+
{answer_short}...
+
+ ๐ท๏ธ {in_house_display}
+ ๐ข {mentioned_display}
+ ๐ฅ๏ธ {platform}
+
+
+ """
+ st.markdown(header_html, unsafe_allow_html=True)
+
+
+def render_brand_card(
+ brand_name: str,
+ brand_type: str,
+ sentiment_data: dict,
+) -> None:
+ """Render brand mention card.
+
+ Args:
+ brand_name: Brand name
+ brand_type: 'in_house' or 'competitor'
+ sentiment_data: Dict with sentiment, confidence, count
+ """
+ sentiment = sentiment_data.get("sentiment", "neutral")
+ confidence = sentiment_data.get("confidence", 0)
+ mention_count = sentiment_data.get("count", 0)
+
+ type_badge = "๐ ์์ฌ" if brand_type == "in_house" else "๐ข ๊ฒฝ์์ฌ"
+ type_bg = "#DBEAFE" if brand_type == "in_house" else "#FEE2E2"
+
+ sentiment_colors = {
+ "positive": "#10B981",
+ "negative": "#EF4444",
+ "neutral": "#6B7280",
+ }
+ sent_color = sentiment_colors.get(sentiment, "#6B7280")
+ sentiment_ko = {"positive": "๊ธ์ ", "negative": "๋ถ์ ", "neutral": "์ค๋ฆฝ"}.get(sentiment, sentiment)
+
+ card_html = f"""
+
+
+ {html.escape(brand_name)}
+ {type_badge}
+
+
+ {sentiment_ko}
+ ์ ๋ขฐ๋: {confidence:.0%}
+ ์ธ๊ธ: {mention_count}ํ
+
+
+ """
+ st.markdown(card_html, unsafe_allow_html=True)
+
+
+def render_verification_item(item: dict, is_false_positive: bool = True) -> None:
+ """Render LLM verification item info (used inside expander).
+
+ Args:
+ item: Verification result dict
+ is_false_positive: True for FP, False for TN
+ """
+ st.markdown(f"**์ง๋ฌธ**: {item.get('question_content', 'N/A')}")
+ st.markdown(f"**๋ต๋ณ ๋ฏธ๋ฆฌ๋ณด๊ธฐ**: {item.get('answer_preview', 'N/A')}")
+ st.markdown("---")
+
+ info_col1, info_col2, info_col3 = st.columns(3)
+ with info_col1:
+ st.markdown(f"**ํ๋ซํผ**: {item.get('platform', 'N/A')}")
+ st.markdown(f"**CEJ**: {item.get('cej_depth1', 'N/A')} / {item.get('cej_depth2', 'N/A')}")
+ with info_col2:
+ st.markdown(f"**1์ฐจ ํ์ **: {item.get('routing_tier', 'N/A')}")
+ st.markdown(f"**1์ฐจ ๊ฐ์ฑ**: {item.get('overall_polarity', 'N/A')}")
+ with info_col3:
+ llm_conf = item.get('llm_confidence', 0) or 0
+ st.markdown(f"**LLM ์ ๋ขฐ๋**: {llm_conf:.1%}")
+ st.markdown(f"**LLM ์กฐ์ Tier**: {item.get('llm_adjusted_tier', 'N/A')}")
+
+ # LLM reasoning
+ if item.get('llm_reasoning'):
+ st.markdown("**LLM ํ๋จ ๊ทผ๊ฑฐ**:")
+ if is_false_positive:
+ st.info(item.get('llm_reasoning'))
+ else:
+ st.warning(item.get('llm_reasoning'))
+
+ # Evidence spans
+ if item.get('llm_evidence_spans'):
+ label = "**๊ทผ๊ฑฐ ๋ฌธ์ฅ**:" if is_false_positive else "**๋ถ์ ๊ทผ๊ฑฐ ๋ฌธ์ฅ**:"
+ st.markdown(label)
+ for span in (item.get('llm_evidence_spans') or []):
+ st.markdown(f"- _{span}_")
+
+ # Brands
+ in_house = item.get('in_house_brands', []) or []
+ mentioned = item.get('mentioned_brands', []) or []
+ if in_house or mentioned:
+ st.markdown(f"**์์ฌ ๋ธ๋๋**: {', '.join(in_house) if in_house else 'N/A'}")
+ st.markdown(f"**์ธ๊ธ ๋ธ๋๋**: {', '.join(mentioned) if mentioned else 'N/A'}")
+
+
+def render_polarity_item(item: dict) -> None:
+ """Render polarity (sentiment) item info (used inside expander).
+
+ Args:
+ item: Sentiment summary dict
+ """
+ confidence = item.get('overall_confidence', 0) or 0
+
+ st.markdown(f"**์ง๋ฌธ**: {item.get('question_content', 'N/A')}")
+ st.markdown(f"**๋ต๋ณ ๋ฏธ๋ฆฌ๋ณด๊ธฐ**: {item.get('answer_preview', 'N/A')}")
+ st.markdown("---")
+
+ info_col1, info_col2, info_col3 = st.columns(3)
+ with info_col1:
+ st.markdown(f"**๊ฐ์ฑ**: {item.get('overall_polarity', 'N/A')}")
+ st.markdown(f"**์ ๋ขฐ๋**: {confidence:.1%}")
+ with info_col2:
+ st.markdown(f"**ํ๋ซํผ**: {item.get('platform', 'N/A')}")
+ st.markdown(f"**CEJ**: {item.get('cej_depth1', 'N/A')} / {item.get('cej_depth2', 'N/A')}")
+ with info_col3:
+ tier = item.get('routing_tier', 'N/A')
+ st.markdown(f"**๋ผ์ฐํ
Tier**: {tier}")
+ emotion = item.get('dominant_emotion', 'N/A')
+ st.markdown(f"**๊ฐ์ **: {emotion}")
+
+ # Brands
+ in_house = item.get('in_house_brands', []) or []
+ mentioned = item.get('mentioned_brands', []) or []
+ if in_house or mentioned:
+ st.markdown(f"**์์ฌ ๋ธ๋๋**: {', '.join(in_house) if in_house else 'N/A'}")
+ st.markdown(f"**์ธ๊ธ ๋ธ๋๋**: {', '.join(mentioned) if mentioned else 'N/A'}")
diff --git a/components/expanders.py b/components/expanders.py
new file mode 100644
index 0000000000000000000000000000000000000000..d591cd59737bf673be231df360efcf53a63b1fe4
--- /dev/null
+++ b/components/expanders.py
@@ -0,0 +1,244 @@
+"""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)
diff --git a/components/metrics.py b/components/metrics.py
new file mode 100644
index 0000000000000000000000000000000000000000..0bd0c244eb440f8a92608226a1452c1b694bf1de
--- /dev/null
+++ b/components/metrics.py
@@ -0,0 +1,175 @@
+"""Dashboard metrics and KPI components."""
+
+import streamlit as st
+
+
+def render_kpi_row(
+ total_nudge: int,
+ high_count: int,
+ medium_count: int,
+ risk_score: float,
+ citation_total: int,
+) -> None:
+ """Render key metrics row with 5 KPIs.
+
+ Args:
+ total_nudge: Total negative mentions
+ high_count: HIGH tier count
+ medium_count: MEDIUM tier count
+ risk_score: Calculated risk score
+ citation_total: Total citation sources
+ """
+ kpi1, kpi2, kpi3, kpi4, kpi5 = st.columns(5)
+
+ with kpi1:
+ st.metric(
+ label="์ด ๋ถ์ ์ธ๊ธ",
+ value=f"{total_nudge}๊ฑด",
+ help="AI๊ฐ ์์ฌ ๋ธ๋๋๋ฅผ ๋ถ์ ์ ์ผ๋ก ์ธ๊ธํ ๋ต๋ณ ์",
+ )
+
+ with kpi2:
+ st.metric(
+ label="๐ด HIGH (์ฆ์ ๋์)",
+ value=f"{high_count}๊ฑด",
+ help="โฅ85% ํ์ ๋ - ์ฆ์ ๋์ ๊ถ์ฅ",
+ )
+
+ with kpi3:
+ st.metric(
+ label="๐ก MEDIUM (๊ฒํ )",
+ value=f"{medium_count}๊ฑด",
+ help="70-85% ํ์ ๋ - ๊ฒํ ํ์",
+ )
+
+ with kpi4:
+ st.metric(
+ label="๋ฆฌ์คํฌ ์ ์",
+ value=f"{risk_score:.1f}",
+ help="HIGH=100%, MEDIUM=50%, LOW=20% ๊ฐ์ค ํ๊ท ",
+ )
+
+ with kpi5:
+ st.metric(
+ label="์ด ์ธ์ฉ ์์ค",
+ value=f"{citation_total}๊ฐ",
+ help="AI ๋ต๋ณ์์ ์ธ์ฉ๋ ์ด ์์ค ์",
+ )
+
+
+def render_verification_stats(
+ total_verified: int,
+ false_positives_count: int,
+ true_negatives_count: int,
+) -> None:
+ """Render LLM verification statistics row.
+
+ Args:
+ total_verified: Total verified items
+ false_positives_count: False positive count
+ true_negatives_count: True negative count
+ """
+ stat_col1, stat_col2, stat_col3, stat_col4 = st.columns(4)
+
+ with stat_col1:
+ st.metric("๊ฒ์ฆ ์๋ฃ", f"{total_verified}๊ฑด")
+
+ with stat_col2:
+ fp_rate = (false_positives_count / total_verified * 100) if total_verified > 0 else 0
+ st.metric("์คํ (False Positive)", f"{false_positives_count}๊ฑด", f"{fp_rate:.1f}%")
+
+ with stat_col3:
+ tn_rate = (true_negatives_count / total_verified * 100) if total_verified > 0 else 0
+ st.metric("์ง์์ฑ (True Negative)", f"{true_negatives_count}๊ฑด", f"{tn_rate:.1f}%")
+
+ with stat_col4:
+ if total_verified > 0:
+ st.metric("์คํ๋ฅ ", f"{fp_rate:.1f}%", delta=None)
+ else:
+ st.metric("์คํ๋ฅ ", "N/A")
+
+
+def render_polarity_stats(
+ positive_count: int,
+ neutral_count: int,
+ negative_count: int,
+) -> None:
+ """Render polarity distribution statistics.
+
+ Args:
+ positive_count: Positive sentiment count
+ neutral_count: Neutral sentiment count
+ negative_count: Negative sentiment count
+ """
+ total_answers = positive_count + neutral_count + negative_count
+
+ pol_col1, pol_col2, pol_col3, pol_col4 = st.columns(4)
+
+ with pol_col1:
+ st.metric("์ ์ฒด ๋ถ์", f"{total_answers:,}๊ฑด")
+
+ with pol_col2:
+ pos_rate = (positive_count / total_answers * 100) if total_answers > 0 else 0
+ st.metric("๐ ๊ธ์ ", f"{positive_count:,}๊ฑด", f"{pos_rate:.1f}%")
+
+ with pol_col3:
+ neu_rate = (neutral_count / total_answers * 100) if total_answers > 0 else 0
+ st.metric("๐ ์ค๋ฆฝ", f"{neutral_count:,}๊ฑด", f"{neu_rate:.1f}%")
+
+ with pol_col4:
+ neg_rate = (negative_count / total_answers * 100) if total_answers > 0 else 0
+ st.metric("๐ ๋ถ์ ", f"{negative_count:,}๊ฑด", f"{neg_rate:.1f}%")
+
+
+def render_feedback_stats(feedback_stats: dict) -> None:
+ """Render feedback statistics row.
+
+ Args:
+ feedback_stats: Dict with feedback counts and accuracy
+ """
+ fb_total = feedback_stats.get("total_feedback", 0)
+ fb_correct = feedback_stats.get("correct_count", 0)
+ fb_wrong = feedback_stats.get("wrong_count", 0)
+ fb_ambiguous = feedback_stats.get("ambiguous_count", 0)
+ accuracy = feedback_stats.get("accuracy_rate", 0)
+
+ fb_col1, fb_col2, fb_col3, fb_col4, fb_col5 = st.columns(5)
+
+ with fb_col1:
+ st.metric(
+ label="์ด ํผ๋๋ฐฑ",
+ value=f"{fb_total}๊ฑด",
+ help="์ฌ์ฉ์๊ฐ ์ ์ถํ ์ด ํผ๋๋ฐฑ ์",
+ )
+
+ with fb_col2:
+ st.metric(
+ label="๐ ์ ํ",
+ value=f"{fb_correct}๊ฑด",
+ delta=f"{fb_correct/fb_total*100:.0f}%" if fb_total > 0 else None,
+ delta_color="normal",
+ help="์ ํํ๋ค๊ณ ํ๊ฐ๋ ๋ถ์ ์",
+ )
+
+ with fb_col3:
+ st.metric(
+ label="๐ ์ค๋ฅ",
+ value=f"{fb_wrong}๊ฑด",
+ delta=f"{fb_wrong/fb_total*100:.0f}%" if fb_total > 0 else None,
+ delta_color="inverse",
+ help="ํ๋ ธ๋ค๊ณ ํ๊ฐ๋ ๋ถ์ ์",
+ )
+
+ with fb_col4:
+ st.metric(
+ label="๐ค ์ ๋งค",
+ value=f"{fb_ambiguous}๊ฑด",
+ help="ํ๋จํ๊ธฐ ์ด๋ ค์ด ๊ฒฝ์ฐ",
+ )
+
+ with fb_col5:
+ st.metric(
+ label="์ ํ๋",
+ value=f"{accuracy:.1f}%",
+ help="์ ํ / (์ ํ + ์ค๋ฅ) ๋น์จ",
+ )
diff --git a/core/__init__.py b/core/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..ffd91e78133d22ec179d0a038d58159c6ad5394a
--- /dev/null
+++ b/core/__init__.py
@@ -0,0 +1,45 @@
+"""Core infrastructure modules.
+
+Re-exports for backward compatibility and convenience.
+"""
+from .api_client import ChainShiftClient
+from .supabase_client import (
+ get_campaign_overview,
+ get_campaign_date_range,
+ get_false_positives,
+ get_llm_verification_stats,
+ get_polarity_stats,
+ get_answers_by_polarity,
+ get_topic_clusters,
+ get_topic_map_snapshot,
+ get_true_negatives,
+)
+from .athena_client import fetch_full_answer
+from .data_fetchers import get_campaigns
+from .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,
+)
+from .charts import (
+ CONFIDENCE_TIER_COLORS,
+ EMOTION_KO,
+ create_brand_sentiment_chart,
+ create_confidence_tier_pie_chart,
+ create_domain_bar_chart,
+ create_nudge_by_cej_bar_chart,
+ create_platform_bar_chart,
+)
+from .export_utils import render_export_component
+from .job_realtime import (
+ get_active_jobs,
+ get_recent_jobs,
+ format_job_duration,
+ get_status_emoji,
+ get_status_label,
+)
+from .styles import DASHBOARD_CSS, TIER_BORDER_COLORS
diff --git a/core/api_client.py b/core/api_client.py
new file mode 100644
index 0000000000000000000000000000000000000000..7bac7213e493ca3c276073b89041d4acd55ada1d
--- /dev/null
+++ b/core/api_client.py
@@ -0,0 +1,150 @@
+"""ChainShift Gen3 API Client - Nudge Detection Focus.
+
+Domain methods are in mixin files:
+- api_client_sentiment.py: SentimentApiMixin (Gen3, Verification, Keyword)
+- api_client_reports.py: ReportsApiMixin (Reports, Action Items)
+- api_client_hierarchy.py: HierarchyApiMixin (Analysis Jobs, Hierarchy)
+"""
+
+import os
+from typing import Any
+from urllib.parse import urlparse
+
+import requests
+from dotenv import load_dotenv
+from requests.adapters import HTTPAdapter
+from urllib3.util.retry import Retry
+
+from core.api_client_sentiment import SentimentApiMixin
+from core.api_client_reports import ReportsApiMixin
+from core.api_client_hierarchy import HierarchyApiMixin
+
+load_dotenv()
+
+BASE_URL = os.getenv(
+ "CHAINSHIFT_API_URL",
+ "https://chainshift-service-api.vercel.app"
+)
+API_KEY = os.getenv("CHAINSHIFT_API_KEY", "")
+
+
+def _create_session() -> requests.Session:
+ """Create requests session with retry for transient errors."""
+ session = requests.Session()
+ retry = Retry(
+ total=3,
+ backoff_factor=0.5,
+ status_forcelist=[502, 503, 504],
+ )
+ session.mount("https://", HTTPAdapter(max_retries=retry))
+ session.mount("http://", HTTPAdapter(max_retries=retry))
+ return session
+
+
+class ChainShiftClient(SentimentApiMixin, ReportsApiMixin, HierarchyApiMixin):
+ """Gen3 API client for ChainShift Nudge Detection."""
+
+ def __init__(
+ self,
+ api_key: str | None = None,
+ access_token: str | None = None,
+ base_url: str | None = None,
+ ):
+ self.api_key = api_key or API_KEY
+ self.base_url = base_url or BASE_URL
+ self.headers = {"X-API-Key": self.api_key} if self.api_key else {}
+ self._session = _create_session()
+
+ def set_api_key(self, api_key: str):
+ """Set or update API key."""
+ self.api_key = api_key
+ self.headers = {"X-API-Key": self.api_key}
+
+ def _get(self, endpoint: str, params: dict | None = None) -> dict[str, Any]:
+ """Make GET request to API."""
+ url = f"{self.base_url}{endpoint}"
+ response = self._session.get(url, headers=self.headers, params=params, timeout=120)
+ response.raise_for_status()
+ return response.json()
+
+ def _post(self, endpoint: str, data: dict | None = None) -> dict[str, Any]:
+ """Make POST request to API."""
+ url = f"{self.base_url}{endpoint}"
+ response = self._session.post(url, headers=self.headers, json=data, timeout=300)
+ response.raise_for_status()
+ return response.json()
+
+ def _patch(self, endpoint: str, data: dict | None = None) -> dict[str, Any]:
+ """Make PATCH request to API."""
+ url = f"{self.base_url}{endpoint}"
+ response = self._session.patch(url, headers=self.headers, json=data, timeout=120)
+ response.raise_for_status()
+ return response.json()
+
+ def _delete(self, endpoint: str) -> dict[str, Any]:
+ """Make DELETE request to API."""
+ url = f"{self.base_url}{endpoint}"
+ response = self._session.delete(url, headers=self.headers, timeout=120)
+ response.raise_for_status()
+ return response.json()
+
+ # ========================================================================
+ # Campaign APIs
+ # ========================================================================
+
+ def get_campaigns(self, page: int = 1, page_size: int = 100) -> dict:
+ """Get list of campaigns."""
+ return self._get("/api/v1/campaigns", {"page": page, "page_size": page_size})
+
+ def get_campaign(self, campaign_id: int) -> dict:
+ """Get campaign details."""
+ return self._get(f"/api/v1/campaigns/{campaign_id}")
+
+ def get_campaign_brands(self, campaign_id: int) -> list[dict]:
+ """Get brands for a campaign."""
+ resp = self._get(f"/api/v1/campaigns/{campaign_id}/brands")
+ return (resp or {}).get("data") or []
+
+ # ========================================================================
+ # Utility Methods
+ # ========================================================================
+
+ @staticmethod
+ def extract_domain(url: str) -> str:
+ """Extract domain from URL."""
+ try:
+ parsed = urlparse(url)
+ return parsed.netloc or url
+ except Exception:
+ return url
+
+ @staticmethod
+ def aggregate_citation_domains(candidates: list[dict]) -> dict[str, int]:
+ """Aggregate citation URLs by domain.
+
+ Returns: {domain: count}
+ """
+ domain_counts: dict[str, int] = {}
+ for candidate in candidates:
+ urls = candidate.get("citation_urls", []) or []
+ for url in urls:
+ domain = ChainShiftClient.extract_domain(url)
+ if domain:
+ domain_counts[domain] = domain_counts.get(domain, 0) + 1
+ return dict(sorted(domain_counts.items(), key=lambda x: x[1], reverse=True))
+
+ @staticmethod
+ def calculate_risk_score(tier_stats: dict) -> float:
+ """Calculate risk score (0-100) based on confidence tiers.
+
+ Formula: (HIGH * 1.0 + MEDIUM * 0.5 + LOW * 0.2) / total * 100
+ """
+ high = tier_stats.get("HIGH", 0)
+ medium = tier_stats.get("MEDIUM", 0)
+ low = tier_stats.get("LOW", 0)
+ total = high + medium + low
+ if total == 0:
+ return 0.0
+ weighted = high * 1.0 + medium * 0.5 + low * 0.2
+ return min(100.0, (weighted / total) * 100)
+
diff --git a/core/api_client_hierarchy.py b/core/api_client_hierarchy.py
new file mode 100644
index 0000000000000000000000000000000000000000..1ed9f245ef1b1cfe5392b12bf6d90cf340008c8d
--- /dev/null
+++ b/core/api_client_hierarchy.py
@@ -0,0 +1,102 @@
+"""Hierarchy & Analysis Job API methods for ChainShiftClient.
+
+Mixin class extracted from api_client.py.
+Methods access self._get, self._post from the parent class.
+"""
+from typing import Any
+
+
+class HierarchyApiMixin:
+ """Hierarchy Analysis and Analysis Job APIs."""
+
+ # ========================================================================
+ # Analysis Job APIs
+ # ========================================================================
+
+ def start_analysis_job(self, campaign_id: int, options: dict | None = None) -> dict:
+ """Start a sentiment analysis job. POST /api/v1/sentiment/campaigns/{campaign_id}/run"""
+ body: dict = {}
+ if options:
+ body["options"] = options
+ return self._post(
+ f"/api/v1/sentiment/campaigns/{campaign_id}/run",
+ body,
+ )
+
+ def list_analysis_jobs(
+ self,
+ campaign_id: int | None = None,
+ status: str | None = None,
+ page: int = 1,
+ page_size: int = 20,
+ ) -> dict:
+ """List analysis jobs. GET /api/v1/sentiment/jobs"""
+ params: dict[str, Any] = {"page": page, "page_size": page_size}
+ if campaign_id is not None:
+ params["campaign_id"] = campaign_id
+ if status:
+ params["status"] = status
+ return self._get("/api/v1/sentiment/jobs", params)
+
+ def get_analysis_job(self, job_id: str) -> dict:
+ """Get analysis job details. GET /api/v1/sentiment/jobs/{job_id}"""
+ return self._get(f"/api/v1/sentiment/jobs/{job_id}")
+
+ def cancel_analysis_job(self, job_id: str) -> dict:
+ """Cancel a queued or running analysis job. POST /api/v1/sentiment/jobs/{job_id}/cancel"""
+ return self._post(f"/api/v1/sentiment/jobs/{job_id}/cancel")
+
+ # ========================================================================
+ # Hierarchy Analysis APIs
+ # ========================================================================
+
+ def create_hierarchy_job(
+ self,
+ prompt: str,
+ title: str | None = None,
+ processor_config: dict | None = None,
+ settings: dict | None = None,
+ ) -> dict:
+ """Create a hierarchy analysis job. POST /api/v1/hierarchy/jobs"""
+ body: dict = {"prompt": prompt, "processor_config": processor_config or {}}
+ if title:
+ body["title"] = title
+ if settings:
+ body["settings"] = settings
+ return self._post("/api/v1/hierarchy/jobs", body)
+
+ def list_hierarchy_jobs(
+ self,
+ page: int = 1,
+ page_size: int = 20,
+ status: str | None = None,
+ ) -> dict:
+ """List hierarchy analysis jobs. GET /api/v1/hierarchy/jobs"""
+ params: dict[str, Any] = {"page": page, "page_size": page_size}
+ if status:
+ params["status"] = status
+ return self._get("/api/v1/hierarchy/jobs", params)
+
+ def get_hierarchy_job(self, job_id: str) -> dict:
+ """Get hierarchy job details. GET /api/v1/hierarchy/jobs/{job_id}"""
+ return self._get(f"/api/v1/hierarchy/jobs/{job_id}")
+
+ def get_hierarchy_questions(
+ self,
+ job_id: str,
+ page: int = 1,
+ page_size: int = 50,
+ journey_depth1: str | None = None,
+ export: str | None = None,
+ ) -> dict:
+ """Get questions for a hierarchy job. GET /api/v1/hierarchy/jobs/{job_id}/questions"""
+ params: dict[str, Any] = {"page": page, "page_size": page_size}
+ if journey_depth1:
+ params["journey_depth1"] = journey_depth1
+ if export:
+ params["export"] = export
+ return self._get(f"/api/v1/hierarchy/jobs/{job_id}/questions", params)
+
+ def get_hierarchy_question_stats(self, job_id: str) -> dict:
+ """Get question statistics. GET /api/v1/hierarchy/jobs/{job_id}/questions/stats"""
+ return self._get(f"/api/v1/hierarchy/jobs/{job_id}/questions/stats")
diff --git a/core/api_client_reports.py b/core/api_client_reports.py
new file mode 100644
index 0000000000000000000000000000000000000000..8bc4a3f6cb131b025c3b6ba2f2e78396356a6076
--- /dev/null
+++ b/core/api_client_reports.py
@@ -0,0 +1,196 @@
+"""Reports & Action Items API methods for ChainShiftClient.
+
+Mixin class extracted from api_client.py.
+Methods access self._get, self._post, self._patch, self._delete, self.base_url from the parent class.
+"""
+
+
+class ReportsApiMixin:
+ """Reports (HTML, data) and Action Items APIs."""
+
+ # ========================================================================
+ # Reports API (HTML Report Generation)
+ # ========================================================================
+
+ def get_report_overview(
+ self, campaign_id: int, start_date: str | None = None, end_date: str | None = None,
+ ) -> dict:
+ """GET /api/v1/reports/{campaign_id}/overview"""
+ params = {}
+ if start_date:
+ params["start_date"] = start_date
+ if end_date:
+ params["end_date"] = end_date
+ return self._get(f"/api/v1/reports/{campaign_id}/overview", params or None)
+
+ def get_report_visibility(
+ self, campaign_id: int, start_date: str | None = None, end_date: str | None = None,
+ ) -> dict:
+ """GET /api/v1/reports/{campaign_id}/visibility"""
+ params = {}
+ if start_date:
+ params["start_date"] = start_date
+ if end_date:
+ params["end_date"] = end_date
+ return self._get(f"/api/v1/reports/{campaign_id}/visibility", params or None)
+
+ def get_report_citations(
+ self, campaign_id: int, start_date: str | None = None,
+ end_date: str | None = None, limit: int = 30,
+ ) -> dict:
+ """GET /api/v1/reports/{campaign_id}/citations"""
+ params: dict = {"limit": limit}
+ if start_date:
+ params["start_date"] = start_date
+ if end_date:
+ params["end_date"] = end_date
+ return self._get(f"/api/v1/reports/{campaign_id}/citations", params)
+
+ def get_report_citation_trends(
+ self, campaign_id: int, start_date: str | None = None,
+ end_date: str | None = None, limit: int = 20,
+ ) -> dict:
+ """GET /api/v1/reports/{campaign_id}/citation-trends"""
+ params: dict = {"limit": limit}
+ if start_date:
+ params["start_date"] = start_date
+ if end_date:
+ params["end_date"] = end_date
+ return self._get(f"/api/v1/reports/{campaign_id}/citation-trends", params)
+
+ def get_report_content_types(
+ self, campaign_id: int, start_date: str | None = None, end_date: str | None = None,
+ ) -> dict:
+ """GET /api/v1/reports/{campaign_id}/content-types"""
+ params = {}
+ if start_date:
+ params["start_date"] = start_date
+ if end_date:
+ params["end_date"] = end_date
+ return self._get(f"/api/v1/reports/{campaign_id}/content-types", params or None)
+
+ def get_report_sentiment(self, campaign_id: int) -> dict:
+ """GET /api/v1/reports/{campaign_id}/sentiment"""
+ return self._get(f"/api/v1/reports/{campaign_id}/sentiment")
+
+ def get_report_homepage_citations(
+ self, campaign_id: int, start_date: str | None = None,
+ end_date: str | None = None, homepage_urls: str | None = None,
+ ) -> dict:
+ """GET /api/v1/reports/{campaign_id}/homepage-citations"""
+ params = {}
+ if start_date:
+ params["start_date"] = start_date
+ if end_date:
+ params["end_date"] = end_date
+ if homepage_urls:
+ params["homepage_urls"] = homepage_urls
+ return self._get(f"/api/v1/reports/{campaign_id}/homepage-citations", params or None)
+
+ def generate_html_report(
+ self,
+ campaign_id: int,
+ start_date: str | None = None,
+ end_date: str | None = None,
+ features: list[str] | None = None,
+ enable_insights: bool = False,
+ output_mode: str = "url",
+ ) -> dict:
+ """Generate HTML report. POST /api/v1/reports/{campaign_id}/html"""
+ body: dict = {"output_mode": output_mode, "enable_insights": enable_insights}
+ if start_date:
+ body["start_date"] = start_date
+ if end_date:
+ body["end_date"] = end_date
+ if features:
+ body["features"] = features
+ return self._post(f"/api/v1/reports/{campaign_id}/html", body)
+
+ def build_html_feature(
+ self,
+ campaign_id: int,
+ feature: str,
+ start_date: str | None = None,
+ end_date: str | None = None,
+ enable_insights: bool = False,
+ enable_action_items: bool = False,
+ homepage_urls: list[str] | None = None,
+ period: str = "1w",
+ ) -> dict:
+ """Build a single report feature. POST /api/v1/reports/{campaign_id}/html/build-feature"""
+ body: dict = {"feature": feature, "enable_insights": enable_insights, "period": period}
+ if start_date:
+ body["start_date"] = start_date
+ if end_date:
+ body["end_date"] = end_date
+ if enable_action_items:
+ body["enable_action_items"] = enable_action_items
+ if homepage_urls:
+ body["homepage_urls"] = homepage_urls
+ return self._post(f"/api/v1/reports/{campaign_id}/html/build-feature", body)
+
+ def render_html_report(
+ self,
+ campaign_id: int,
+ features_data: list[dict],
+ title: str | None = None,
+ subtitle: str | None = None,
+ start_date: str | None = None,
+ end_date: str | None = None,
+ enable_insights: bool = False,
+ enable_action_items: bool = False,
+ output_mode: str = "url",
+ ) -> dict:
+ """Assemble and render final HTML report. POST /api/v1/reports/{campaign_id}/html/render"""
+ body: dict = {
+ "features_data": features_data,
+ "output_mode": output_mode,
+ "enable_insights": enable_insights,
+ }
+ if title:
+ body["title"] = title
+ if subtitle:
+ body["subtitle"] = subtitle
+ if start_date:
+ body["start_date"] = start_date
+ if end_date:
+ body["end_date"] = end_date
+ if enable_action_items:
+ body["enable_action_items"] = enable_action_items
+ return self._post(f"/api/v1/reports/{campaign_id}/html/render", body)
+
+ def get_html_report_history(self, campaign_id: int, page: int = 1, page_size: int = 20) -> dict:
+ """Get HTML report history. GET /api/v1/reports/{campaign_id}/html/history"""
+ return self._get(f"/api/v1/reports/{campaign_id}/html/history", {"page": page, "page_size": page_size})
+
+ def get_html_report_detail(self, campaign_id: int, report_id: str) -> dict:
+ """Get HTML report detail. GET /api/v1/reports/{campaign_id}/html/{report_id}"""
+ return self._get(f"/api/v1/reports/{campaign_id}/html/{report_id}")
+
+ # ========================================================================
+ # Action Items APIs (ADR-018 Phase 2)
+ # ========================================================================
+
+ def save_action_items(
+ self,
+ campaign_id: int,
+ items: list[dict],
+ report_id: str | None = None,
+ ) -> dict:
+ """Save action items from report. POST /api/v1/action-items"""
+ body: dict = {"campaign_id": campaign_id, "items": items}
+ if report_id:
+ body["report_id"] = report_id
+ return self._post("/api/v1/action-items", body)
+
+ def update_action_item(self, item_id: str, data: dict) -> dict:
+ """Update action item. PATCH /api/v1/action-items/{item_id}"""
+ return self._patch(f"/api/v1/action-items/{item_id}", data)
+
+ def delete_action_item(self, item_id: str) -> dict:
+ """Delete action item. DELETE /api/v1/action-items/{item_id}"""
+ return self._delete(f"/api/v1/action-items/{item_id}")
+
+ def export_action_item_html(self, item_id: str) -> dict:
+ """Export action item as HTML. GET /api/v1/action-items/{item_id}/export/html"""
+ return self._get(f"/api/v1/action-items/{item_id}/export/html")
diff --git a/core/api_client_sentiment.py b/core/api_client_sentiment.py
new file mode 100644
index 0000000000000000000000000000000000000000..880dbe4e343b654c37b69147d45c9f164275a95b
--- /dev/null
+++ b/core/api_client_sentiment.py
@@ -0,0 +1,279 @@
+"""Sentiment API methods for ChainShiftClient.
+
+Mixin class extracted from api_client.py.
+Methods access self._get, self._post, self._session, self.base_url, self.headers from the parent class.
+"""
+from typing import Any
+
+
+class SentimentApiMixin:
+ """Gen3 Nudge, Brand Mentions, LLM Verification, Feedback, Keyword APIs."""
+
+ # ========================================================================
+ # Gen3 APIs - Nudge Detection
+ # ========================================================================
+
+ def get_nudge_candidates(
+ self,
+ campaign_id: int,
+ page: int = 1,
+ page_size: int = 50,
+ bit_quadrant: str | None = None,
+ platform: str | None = None,
+ ) -> dict:
+ """Get answers flagged for nudge (in-house brand negative mentions).
+
+ Returns:
+ - total_nudge_candidates: int
+ - by_confidence_tier: {HIGH: n, MEDIUM: n, LOW: n}
+ - by_platform: {CHATGPT: n, GOOGLE_AI: n, ...}
+ - by_cej: {AWARENESS_COMPARISON: n, PURCHASE: n, ...}
+ - by_bit_quadrant: {neutral: n, ...}
+ - candidates: list of nudge items
+ """
+ params = {"page": page, "page_size": page_size}
+ if bit_quadrant:
+ params["bit_quadrant"] = bit_quadrant
+ if platform:
+ params["platform"] = platform
+ return self._get(f"/api/v1/sentiment/campaigns/{campaign_id}/nudge-candidates", params)
+
+ def get_brand_mentions(
+ self,
+ campaign_id: int,
+ page: int = 1,
+ page_size: int = 50,
+ brand_type: str | None = None,
+ polarity: str | None = None,
+ llm_verified: str | None = None,
+ brand_name: str | None = None,
+ sort_by: str | None = "analyzed_at",
+ sort_desc: bool = True,
+ ) -> dict:
+ """Get brand mention analysis."""
+ params = {"page": page, "page_size": page_size, "sort_desc": sort_desc}
+ if brand_type:
+ params["brand_type"] = brand_type
+ if polarity:
+ params["polarity"] = polarity
+ if llm_verified:
+ params["llm_verified"] = llm_verified
+ if brand_name:
+ params["brand_name"] = brand_name
+ if sort_by:
+ params["sort_by"] = sort_by
+ return self._get(f"/api/v1/sentiment/campaigns/{campaign_id}/brand-mentions", params)
+
+ def export_brand_mentions(
+ self,
+ campaign_id: int,
+ brand_type: str | None = None,
+ polarity: str | None = None,
+ llm_verified: str | None = None,
+ brand_name: str | None = None,
+ include_full_answers: bool = False,
+ ) -> bytes:
+ """Export brand mentions to Excel."""
+ params = {}
+ if brand_type:
+ params["brand_type"] = brand_type
+ if polarity:
+ params["polarity"] = polarity
+ if llm_verified:
+ params["llm_verified"] = llm_verified
+ if brand_name:
+ params["brand_name"] = brand_name
+ if include_full_answers:
+ params["include_full_answers"] = "true"
+
+ url = f"{self.base_url}/api/v1/sentiment/campaigns/{campaign_id}/brand-mentions/export"
+ response = self._session.get(url, params=params, headers=self.headers, timeout=120)
+ response.raise_for_status()
+ return response.content
+
+ # ========================================================================
+ # LLM Verification & Human Feedback APIs
+ # ========================================================================
+
+ def verify_answer(self, answer_id: int, force: bool = False) -> dict:
+ """Request LLM 2์ฐจ ๊ฒ์ฆ for an answer."""
+ return self._post("/api/v1/sentiment/verify", {
+ "answer_id": answer_id,
+ "force": force,
+ })
+
+ def submit_feedback(
+ self,
+ answer_id: int,
+ campaign_id: int,
+ feedback_type: str,
+ corrected_polarity: str | None = None,
+ wrong_reason: str | None = None,
+ comment: str | None = None,
+ ) -> dict:
+ """Submit human feedback for an answer sentiment."""
+ data = {
+ "answer_id": answer_id,
+ "campaign_id": campaign_id,
+ "feedback_type": feedback_type,
+ }
+ if corrected_polarity:
+ data["corrected_polarity"] = corrected_polarity
+ if wrong_reason:
+ data["wrong_reason"] = wrong_reason
+ if comment:
+ data["comment"] = comment
+ return self._post("/api/v1/sentiment/feedback", data)
+
+ def get_feedback_stats(self, campaign_id: int) -> dict:
+ """Get feedback statistics for a campaign."""
+ return self._get(f"/api/v1/sentiment/campaigns/{campaign_id}/feedback/stats")
+
+ def get_full_answers(self, answer_ids: list[int]) -> dict:
+ """Get full answer text for multiple answers."""
+ return self._post("/api/v1/sentiment/answers/full", {"answer_ids": answer_ids})
+
+ def export_nudge_candidates(
+ self,
+ campaign_id: int,
+ include_full_answers: bool = False,
+ include_evidence: bool = True,
+ llm_verified_only: bool = False,
+ platform: str | None = None,
+ llm_is_negative: bool | None = None,
+ ) -> bytes:
+ """Export nudge candidates to Excel."""
+ url = f"{self.base_url}/api/v1/sentiment/campaigns/{campaign_id}/nudge-candidates/export"
+ params = {
+ "include_full_answers": str(include_full_answers).lower(),
+ "include_evidence": str(include_evidence).lower(),
+ "llm_verified_only": str(llm_verified_only).lower(),
+ }
+ if platform:
+ params["platform"] = platform
+ if llm_is_negative is not None:
+ params["llm_is_negative"] = str(llm_is_negative).lower()
+ response = self._session.get(url, headers=self.headers, params=params, timeout=120)
+ response.raise_for_status()
+ return response.content
+
+ # ========================================================================
+ # Keyword Sentiment APIs
+ # ========================================================================
+
+ def start_keyword_analysis(self, campaign_id: int, keywords: list[str]) -> dict:
+ """Start a keyword sentiment analysis job."""
+ return self._post(
+ f"/api/v1/sentiment/campaigns/{campaign_id}/keyword-analysis",
+ {"keywords": keywords},
+ )
+
+ def get_keyword_summary(
+ self,
+ campaign_id: int,
+ keyword: str | None = None,
+ page: int = 1,
+ page_size: int = 50,
+ ) -> dict:
+ """Get keyword sentiment summary with optional filter and pagination."""
+ params: dict[str, Any] = {"page": page, "page_size": page_size}
+ if keyword:
+ params["keyword"] = keyword
+ return self._get(f"/api/v1/sentiment/campaigns/{campaign_id}/keyword-summary", params)
+
+ def get_keyword_results(
+ self,
+ campaign_id: int,
+ keyword: str | None = None,
+ sentiment: str | None = None,
+ brand_name: str | None = None,
+ competitor: bool = False,
+ llm_verified: str | None = None,
+ llm_is_negative: bool | None = None,
+ platform: str | None = None,
+ brand_only: bool = False,
+ no_brand: bool = False,
+ page: int = 1,
+ page_size: int = 50,
+ ) -> dict:
+ """Get keyword results with filters."""
+ params: dict[str, Any] = {"page": page, "page_size": page_size}
+ if keyword:
+ params["keyword"] = keyword
+ if sentiment:
+ params["sentiment"] = sentiment
+ if brand_name:
+ params["brand_name"] = brand_name
+ if competitor:
+ params["competitor"] = "true"
+ if llm_verified:
+ params["llm_verified"] = llm_verified
+ if llm_is_negative is not None:
+ params["llm_is_negative"] = str(llm_is_negative).lower()
+ if platform:
+ params["platform"] = platform
+ if brand_only:
+ params["brand_only"] = "true"
+ if no_brand:
+ params["no_brand"] = "true"
+ return self._get(
+ f"/api/v1/sentiment/campaigns/{campaign_id}/keyword-results", params
+ )
+
+ def get_keyword_brand_analysis(
+ self,
+ campaign_id: int,
+ keyword: str | None = None,
+ brand_name: str | None = None,
+ competitor: bool = False,
+ ) -> dict:
+ """Get keyword x brand cross analysis."""
+ params: dict[str, Any] = {}
+ if keyword:
+ params["keyword"] = keyword
+ if brand_name:
+ params["brand_name"] = brand_name
+ if competitor:
+ params["competitor"] = "true"
+ return self._get(
+ f"/api/v1/sentiment/campaigns/{campaign_id}/keyword-brand-analysis", params
+ )
+
+ def export_keyword_results(
+ self,
+ campaign_id: int,
+ keyword: str | None = None,
+ sentiment: str | None = None,
+ brand_name: str | None = None,
+ competitor: bool = False,
+ llm_verified: str | None = None,
+ llm_is_negative: bool | None = None,
+ platform: str | None = None,
+ brand_only: bool = False,
+ no_brand: bool = False,
+ format: str = "xlsx",
+ ) -> bytes:
+ """Export keyword results to Excel or CSV with same filters as list endpoint."""
+ params: dict[str, Any] = {"format": format}
+ if keyword:
+ params["keyword"] = keyword
+ if sentiment:
+ params["sentiment"] = sentiment
+ if brand_name:
+ params["brand_name"] = brand_name
+ if competitor:
+ params["competitor"] = "true"
+ if llm_verified:
+ params["llm_verified"] = llm_verified
+ if llm_is_negative is not None:
+ params["llm_is_negative"] = str(llm_is_negative).lower()
+ if platform:
+ params["platform"] = platform
+ if brand_only:
+ params["brand_only"] = "true"
+ if no_brand:
+ params["no_brand"] = "true"
+ url = f"{self.base_url}/api/v1/sentiment/campaigns/{campaign_id}/keyword-results/export"
+ response = self._session.get(url, params=params, headers=self.headers, timeout=120)
+ response.raise_for_status()
+ return response.content
diff --git a/core/athena_client.py b/core/athena_client.py
new file mode 100644
index 0000000000000000000000000000000000000000..e8a3f4a271914efb57f85397d1fd6ee9a3edb76c
--- /dev/null
+++ b/core/athena_client.py
@@ -0,0 +1,331 @@
+"""Standalone Athena client for dashboard.
+
+Uses requests + AWS SigV4 to query Athena directly. No dependency on
+app/ modules โ compatible with HuggingFace Space deployment.
+
+Public API:
+ - fetch_full_answer(answer_id) -> str | None
+ - fetch_full_answers_batch(answer_ids) -> dict[int, str]
+ - query_athena(sql, params) -> list[dict]
+ - is_athena_configured() -> bool
+"""
+
+import hashlib
+import hmac
+import json
+import os
+import re
+import time
+import uuid
+from datetime import datetime, timezone, date as date_type
+from pathlib import Path
+
+import requests
+from dotenv import load_dotenv
+
+# Load environment variables (project root, APP_ENV-aware)
+_project_root = Path(__file__).parent.parent.parent.parent
+_app_env = os.environ.get("APP_ENV")
+if _app_env:
+ _candidates = [f".env.{_app_env}", ".env.dev", ".env.prod"]
+else:
+ _candidates = [".env.dev", ".env.prod"]
+for _env_name in _candidates:
+ _env_path = _project_root / _env_name
+ if _env_path.exists():
+ load_dotenv(_env_path, override=True)
+ break
+
+# ---------------------------------------------------------------------------
+# Config from env vars (HuggingFace Secrets compatible)
+# ---------------------------------------------------------------------------
+
+_ACCESS_KEY_ID = os.environ.get("ATHENA_ACCESS_KEY_ID", "")
+_SECRET_ACCESS_KEY = os.environ.get("ATHENA_SECRET_ACCESS_KEY", "")
+_REGION = os.environ.get("ATHENA_REGION", "ap-northeast-2")
+_DATABASE = os.environ.get("ATHENA_DATABASE", "fde-chainshift-prod")
+_S3_OUTPUT = os.environ.get("ATHENA_S3_OUTPUT", "s3://chainshift-prod-rds-snapshots/athena-results/")
+
+# ---------------------------------------------------------------------------
+# AWS SigV4 signing
+# ---------------------------------------------------------------------------
+
+_ALGORITHM = "AWS4-HMAC-SHA256"
+
+
+def _sign(key: bytes, msg: str) -> bytes:
+ return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
+
+
+def _get_signature_key(secret: str, date_stamp: str, region: str, service: str) -> bytes:
+ k_date = _sign(("AWS4" + secret).encode("utf-8"), date_stamp)
+ k_region = _sign(k_date, region)
+ k_service = _sign(k_region, service)
+ return _sign(k_service, "aws4_request")
+
+
+def _sigv4_headers(action: str, body: str) -> dict[str, str]:
+ """Build SigV4-signed headers for an Athena API call."""
+ now = datetime.now(timezone.utc)
+ amz_date = now.strftime("%Y%m%dT%H%M%SZ")
+ date_stamp = now.strftime("%Y%m%d")
+ region = _REGION
+ service = "athena"
+ host = f"athena.{region}.amazonaws.com"
+
+ payload_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
+ canonical_headers = (
+ f"content-type:application/x-amz-json-1.1\n"
+ f"host:{host}\n"
+ f"x-amz-date:{amz_date}\n"
+ f"x-amz-target:AmazonAthena.{action}\n"
+ )
+ signed_headers = "content-type;host;x-amz-date;x-amz-target"
+ canonical_request = (
+ f"POST\n/\n\n"
+ f"{canonical_headers}\n{signed_headers}\n{payload_hash}"
+ )
+
+ credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
+ string_to_sign = (
+ f"{_ALGORITHM}\n{amz_date}\n{credential_scope}\n"
+ f"{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
+ )
+
+ signing_key = _get_signature_key(_SECRET_ACCESS_KEY, date_stamp, region, service)
+ signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
+
+ authorization = (
+ f"{_ALGORITHM} Credential={_ACCESS_KEY_ID}/{credential_scope}, "
+ f"SignedHeaders={signed_headers}, Signature={signature}"
+ )
+
+ return {
+ "Content-Type": "application/x-amz-json-1.1",
+ "Host": host,
+ "X-Amz-Date": amz_date,
+ "X-Amz-Target": f"AmazonAthena.{action}",
+ "Authorization": authorization,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Athena API helpers
+# ---------------------------------------------------------------------------
+
+_POLL_INTERVAL = 1.0
+_POLL_TIMEOUT = 120.0
+
+
+def _athena_api_call(action: str, body: dict) -> dict:
+ """POST to the Athena JSON API and return parsed response."""
+ payload = json.dumps(body)
+ url = f"https://athena.{_REGION}.amazonaws.com/"
+ headers = _sigv4_headers(action, payload)
+
+ resp = requests.post(url, data=payload, headers=headers, timeout=30)
+ if resp.status_code != 200:
+ raise RuntimeError(
+ f"Athena {action} failed ({resp.status_code}): {resp.text}"
+ )
+ return resp.json()
+
+
+def _start_query(sql: str) -> str:
+ """Submit a query and return the QueryExecutionId."""
+ body: dict = {
+ "QueryString": sql,
+ "ClientRequestToken": str(uuid.uuid4()),
+ "QueryExecutionContext": {"Database": _DATABASE},
+ "ResultConfiguration": {"OutputLocation": _S3_OUTPUT},
+ }
+ result = _athena_api_call("StartQueryExecution", body)
+ return result["QueryExecutionId"]
+
+
+def _wait_for_query(query_id: str, timeout: float | None = None) -> None:
+ """Poll until the query completes or times out."""
+ deadline = time.monotonic() + (timeout or _POLL_TIMEOUT)
+ while time.monotonic() < deadline:
+ result = _athena_api_call(
+ "GetQueryExecution", {"QueryExecutionId": query_id},
+ )
+ state = result["QueryExecution"]["Status"]["State"]
+ if state == "SUCCEEDED":
+ return
+ if state in ("FAILED", "CANCELLED"):
+ reason = result["QueryExecution"]["Status"].get(
+ "StateChangeReason", "unknown"
+ )
+ raise RuntimeError(f"Athena query {state}: {reason}")
+ time.sleep(_POLL_INTERVAL)
+
+ raise TimeoutError(f"Athena query {query_id} timed out after {_POLL_TIMEOUT}s")
+
+
+def _convert_value(raw: str | None, athena_type: str) -> object:
+ """Convert a string value from Athena to the appropriate Python type."""
+ if raw is None:
+ return None
+ athena_type = athena_type.lower()
+ if athena_type in ("integer", "int", "bigint", "smallint", "tinyint"):
+ return int(raw)
+ if athena_type in ("double", "float", "decimal", "real"):
+ return float(raw)
+ if athena_type == "boolean":
+ return raw.lower() == "true"
+ if athena_type == "date":
+ return date_type.fromisoformat(raw)
+ return raw
+
+
+def _get_results(query_id: str) -> list[dict]:
+ """Fetch all result pages and return as list[dict]."""
+ rows: list[dict] = []
+ next_token: str | None = None
+
+ while True:
+ body: dict = {"QueryExecutionId": query_id, "MaxResults": 1000}
+ if next_token:
+ body["NextToken"] = next_token
+
+ result = _athena_api_call("GetQueryResults", body)
+ result_set = result["ResultSet"]
+
+ columns = result_set["ResultSetMetadata"]["ColumnInfo"]
+ col_names = [c["Name"] for c in columns]
+ col_types = [c["Type"] for c in columns]
+
+ data_rows = result_set.get("Rows", [])
+ start = 1 if not next_token and data_rows else 0
+
+ for row in data_rows[start:]:
+ values = row.get("Data", [])
+ record: dict = {}
+ for i, col_name in enumerate(col_names):
+ if i < len(values):
+ cell = values[i]
+ raw = cell.get("VarCharValue")
+ record[col_name] = _convert_value(raw, col_types[i])
+ else:
+ record[col_name] = None
+ rows.append(record)
+
+ next_token = result.get("NextToken")
+ if not next_token:
+ break
+
+ return rows
+
+
+# ---------------------------------------------------------------------------
+# Parameter substitution
+# ---------------------------------------------------------------------------
+
+def _substitute_params(sql: str, params: dict) -> str:
+ """Replace %(name)s placeholders with escaped values."""
+ def _replace(match: re.Match) -> str:
+ name = match.group(1)
+ if name not in params:
+ raise KeyError(f"Parameter '{name}' not found in params dict")
+ val = params[name]
+ if val is None:
+ return "NULL"
+ if isinstance(val, (int, float)):
+ return str(val)
+ escaped = str(val).replace("'", "''")
+ return f"'{escaped}'"
+
+ return re.sub(r"%\((\w+)\)s", _replace, sql)
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+def is_athena_configured() -> bool:
+ """Check if Athena credentials are configured."""
+ return bool(_ACCESS_KEY_ID and _SECRET_ACCESS_KEY and _S3_OUTPUT)
+
+
+def query_athena(
+ sql: str,
+ params: dict | None = None,
+ timeout: float | None = None,
+) -> list[dict]:
+ """Execute Athena SQL and return results as list of dicts.
+
+ Args:
+ sql: SQL query string. Use %(name)s for parameter placeholders.
+ params: Optional dict of parameters for the query.
+ timeout: Max seconds to wait for query completion.
+
+ Returns:
+ List of dicts (one per row), column names as keys.
+ """
+ if not is_athena_configured():
+ raise ValueError("Athena credentials not configured")
+
+ if params:
+ sql = _substitute_params(sql, params)
+
+ query_id = _start_query(sql)
+ _wait_for_query(query_id, timeout=timeout)
+ return _get_results(query_id)
+
+
+# ---------------------------------------------------------------------------
+# Dashboard-specific functions
+# ---------------------------------------------------------------------------
+
+def fetch_full_answer(answer_id: int) -> str | None:
+ """Fetch full answer content from Athena answers table.
+
+ Args:
+ answer_id: The answer ID to fetch
+
+ Returns:
+ Full answer content or None if not found
+ """
+ if not is_athena_configured():
+ print(f"[Athena] Not configured, cannot fetch answer {answer_id}")
+ return None
+
+ try:
+ rows = query_athena(
+ "SELECT content FROM answers WHERE id = %(answer_id)s AND deleted_at IS NULL",
+ {"answer_id": answer_id},
+ )
+ return rows[0]["content"] if rows else None
+ except Exception as e:
+ print(f"[Athena Error] Failed to fetch answer {answer_id}: {e}")
+ return None
+
+
+def fetch_full_answers_batch(answer_ids: list[int]) -> dict[int, str]:
+ """Fetch multiple full answers from Athena in a single query.
+
+ Args:
+ answer_ids: List of answer IDs to fetch
+
+ Returns:
+ Dict mapping answer_id to content
+ """
+ if not answer_ids:
+ return {}
+
+ if not is_athena_configured():
+ print("[Athena] Not configured, cannot fetch answers batch")
+ return {}
+
+ try:
+ # Athena doesn't support array params like PostgreSQL's ANY(%s).
+ # Use IN clause with comma-separated IDs (all integers, safe).
+ ids_str = ", ".join(str(int(aid)) for aid in answer_ids)
+ rows = query_athena(
+ f"SELECT id, content FROM answers WHERE id IN ({ids_str}) AND deleted_at IS NULL",
+ )
+ return {row["id"]: row["content"] for row in rows if row.get("content")}
+ except Exception as e:
+ print(f"[Athena Error] Failed to fetch answers batch: {e}")
+ return {}
diff --git a/core/charts.py b/core/charts.py
new file mode 100644
index 0000000000000000000000000000000000000000..0fa554334f6b7db38271bfbc2a7b585039e86912
--- /dev/null
+++ b/core/charts.py
@@ -0,0 +1,361 @@
+"""Chart components for Gen3 Nudge Detection Dashboard."""
+
+import plotly.graph_objects as go
+import pandas as pd
+
+
+# =============================================================================
+# Color Constants
+# =============================================================================
+
+CONFIDENCE_TIER_COLORS = {
+ "HIGH": "#EF4444", # Red
+ "MEDIUM": "#F59E0B", # Amber
+ "LOW": "#10B981", # Green
+}
+
+PLATFORM_COLORS = {
+ "CHATGPT": "#10A37F",
+ "GOOGLE_AI": "#4285F4",
+ "GOOGLE_OVERVIEW": "#34A853",
+ "PERPLEXITY": "#6366F1",
+ "GEMINI": "#8B5CF6",
+ "BING": "#00A4EF",
+ "CLAUDE": "#D97706",
+}
+
+POLARITY_COLORS = {
+ "positive": "#10B981",
+ "negative": "#EF4444",
+ "neutral": "#6B7280",
+}
+
+# English to Korean emotion mapping
+EMOTION_KO = {
+ "admiration": "๊ฐํ",
+ "amusement": "์ฌ๋ฏธ",
+ "anger": "๋ถ๋
ธ",
+ "annoyance": "์ง์ฆ",
+ "approval": "์ธ์ ",
+ "caring": "๋ฐฐ๋ ค",
+ "confusion": "ํผ๋",
+ "curiosity": "ํธ๊ธฐ์ฌ",
+ "desire": "์๊ตฌ",
+ "disappointment": "์ค๋ง",
+ "disapproval": "๋ฐ๋",
+ "disgust": "ํ์ค",
+ "embarrassment": "๋นํน",
+ "excitement": "ํฅ๋ถ",
+ "fear": "๋๋ ค์",
+ "gratitude": "๊ฐ์ฌ",
+ "grief": "์ฌํ",
+ "joy": "๊ธฐ์จ",
+ "love": "์ฌ๋",
+ "nervousness": "๋ถ์",
+ "optimism": "๋๊ด",
+ "pride": "์๋ถ์ฌ",
+ "realization": "๊นจ๋ฌ์",
+ "relief": "์๋",
+ "remorse": "ํํ",
+ "sadness": "์ฌํ",
+ "surprise": "๋๋ผ์",
+ "neutral": "์ค๋ฆฝ",
+ "trust": "์ ๋ขฐ",
+ "anticipation": "๊ธฐ๋",
+ "interest": "๊ด์ฌ",
+ "satisfaction": "๋ง์กฑ",
+ "frustration": "์ข์ ",
+ "hope": "ํฌ๋ง",
+ "worry": "๊ฑฑ์ ",
+}
+
+# CEJ Korean labels
+CEJ_LABELS = {
+ "VERIFICATION": "๊ฒ์ฆ ์ง๋ฌธ",
+ "INFORMATION_DISCOVERY": "์ ๋ณด ํ์",
+ "HOW_TO": "์ฌ์ฉ๋ฒ",
+ "WHERE_TO_BUY": "๊ตฌ๋งค์ฒ",
+ "RECOMMENDATION": "์ถ์ฒ ์์ฒญ",
+ "SIDE_EFFECT": "๋ถ์์ฉ",
+ "MARKET_TRENDS": "์์ฅ ๋ํฅ",
+ "RESULT_EFFECTIVENESS": "ํจ๊ณผ/๊ฒฐ๊ณผ",
+ "COMPARISON": "๋น๊ต",
+ "INGREDIENT": "์ฑ๋ถ",
+ "AWARENESS_COMPARISON": "์ธ์ง/๋น๊ต",
+ "PURCHASE": "๊ตฌ๋งค",
+ "POST_PURCHASE": "๊ตฌ๋งค ํ",
+}
+
+
+# =============================================================================
+# Gen3 Nudge Charts
+# =============================================================================
+
+def create_confidence_tier_pie_chart(tier_stats: dict) -> go.Figure:
+ """Create pie chart for confidence tier distribution (HIGH/MEDIUM/LOW)."""
+ if not tier_stats:
+ return go.Figure()
+
+ labels = list(tier_stats.keys())
+ values = list(tier_stats.values())
+ colors = [CONFIDENCE_TIER_COLORS.get(k, "#6B7280") for k in labels]
+
+ label_map = {
+ "HIGH": "๐ด HIGH",
+ "MEDIUM": "๐ก MEDIUM",
+ "LOW": "๐ข LOW",
+ }
+ display_labels = [label_map.get(k, k) for k in labels]
+
+ fig = go.Figure(data=[go.Pie(
+ labels=display_labels,
+ values=values,
+ marker=dict(colors=colors),
+ hole=0.4,
+ textinfo="percent+value",
+ textposition="outside",
+ )])
+
+ fig.update_layout(
+ title="",
+ showlegend=True,
+ legend=dict(orientation="h", yanchor="bottom", y=-0.2, xanchor="center", x=0.5),
+ margin=dict(t=20, b=60, l=20, r=20),
+ height=280,
+ )
+
+ return fig
+
+
+def create_platform_bar_chart(platform_stats: dict) -> go.Figure:
+ """Create horizontal bar chart for platform distribution."""
+ if not platform_stats:
+ return go.Figure()
+
+ sorted_items = sorted(platform_stats.items(), key=lambda x: x[1], reverse=True)
+ platforms = [item[0] for item in sorted_items]
+ counts = [item[1] for item in sorted_items]
+ colors = [PLATFORM_COLORS.get(p, "#6B7280") for p in platforms]
+
+ fig = go.Figure(data=[
+ go.Bar(
+ y=platforms,
+ x=counts,
+ orientation="h",
+ marker_color=colors,
+ text=[f"{c}๊ฑด" for c in counts],
+ textposition="auto",
+ )
+ ])
+
+ fig.update_layout(
+ title="",
+ xaxis_title="๋์ง ํ๋ณด ์",
+ yaxis=dict(autorange="reversed"),
+ margin=dict(t=20, b=40, l=100, r=20),
+ height=max(200, len(platforms) * 35),
+ )
+
+ return fig
+
+
+def create_nudge_by_cej_bar_chart(cej_counts: dict) -> go.Figure:
+ """Create horizontal bar chart for nudge distribution by CEJ stage."""
+ if not cej_counts:
+ return go.Figure()
+
+ sorted_items = sorted(cej_counts.items(), key=lambda x: x[1], reverse=True)
+ cej_stages = [item[0] for item in sorted_items]
+ counts = [item[1] for item in sorted_items]
+
+ display_labels = [CEJ_LABELS.get(cej, cej) for cej in cej_stages]
+
+ fig = go.Figure(data=[
+ go.Bar(
+ y=display_labels,
+ x=counts,
+ orientation="h",
+ marker_color="#6366F1",
+ text=[f"{c}๊ฑด" for c in counts],
+ textposition="auto",
+ )
+ ])
+
+ fig.update_layout(
+ title="",
+ xaxis_title="๋์ง ํ๋ณด ์",
+ yaxis=dict(autorange="reversed"),
+ margin=dict(t=20, b=40, l=100, r=20),
+ height=max(200, len(cej_stages) * 35),
+ )
+
+ return fig
+
+
+def create_brand_sentiment_chart(brands: list[dict]) -> go.Figure:
+ """Create horizontal stacked bar chart for brand sentiment comparison."""
+ if not brands:
+ return go.Figure()
+
+ # Sort by total mentions
+ sorted_brands = sorted(brands, key=lambda x: x.get("total_mentions", 0), reverse=True)[:10]
+
+ brand_names = []
+ for b in sorted_brands:
+ name = b.get("brand_name", "Unknown")
+ brand_type = b.get("brand_type", "")
+ prefix = "๐ " if brand_type == "IN_HOUSE" else "๐ข "
+ brand_names.append(f"{prefix}{name}")
+
+ positive_rates = [b.get("positive_rate", 0) for b in sorted_brands]
+ negative_rates = [b.get("negative_rate", 0) for b in sorted_brands]
+ neutral_rates = [100 - p - n for p, n in zip(positive_rates, negative_rates)]
+
+ fig = go.Figure()
+
+ fig.add_trace(go.Bar(
+ y=brand_names,
+ x=positive_rates,
+ name="๊ธ์ ",
+ orientation="h",
+ marker_color="#10B981",
+ text=[f"{v:.1f}%" for v in positive_rates],
+ textposition="inside",
+ ))
+
+ fig.add_trace(go.Bar(
+ y=brand_names,
+ x=neutral_rates,
+ name="์ค๋ฆฝ",
+ orientation="h",
+ marker_color="#E5E7EB",
+ text=[f"{v:.1f}%" for v in neutral_rates],
+ textposition="inside",
+ ))
+
+ fig.add_trace(go.Bar(
+ y=brand_names,
+ x=negative_rates,
+ name="๋ถ์ ",
+ orientation="h",
+ marker_color="#EF4444",
+ text=[f"{v:.1f}%" for v in negative_rates],
+ textposition="inside",
+ ))
+
+ fig.update_layout(
+ title="",
+ barmode="stack",
+ xaxis_title="๋น์จ (%)",
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
+ margin=dict(t=50, b=50, l=150, r=20),
+ height=max(300, len(sorted_brands) * 40),
+ yaxis=dict(autorange="reversed"),
+ )
+
+ return fig
+
+
+def create_domain_bar_chart(domain_counts: dict) -> go.Figure:
+ """Create horizontal bar chart for citation domain distribution."""
+ if not domain_counts:
+ return go.Figure()
+
+ # Take top 15 domains
+ sorted_items = list(domain_counts.items())[:15]
+ domains = [item[0] for item in sorted_items]
+ counts = [item[1] for item in sorted_items]
+
+ # Truncate long domain names
+ display_domains = [d[:40] + "..." if len(d) > 40 else d for d in domains]
+
+ fig = go.Figure(data=[
+ go.Bar(
+ y=display_domains,
+ x=counts,
+ orientation="h",
+ marker_color="#3B82F6",
+ text=[f"{c}ํ" for c in counts],
+ textposition="auto",
+ )
+ ])
+
+ fig.update_layout(
+ title="",
+ xaxis_title="์ธ์ฉ ํ์",
+ yaxis=dict(autorange="reversed"),
+ margin=dict(t=20, b=40, l=200, r=20),
+ height=max(300, len(sorted_items) * 30),
+ )
+
+ return fig
+
+
+def create_bit_quadrant_chart(bit_stats: dict) -> go.Figure:
+ """Create bar chart for BIT quadrant distribution."""
+ if not bit_stats:
+ return go.Figure()
+
+ BIT_LABELS = {
+ "neutral": "์ค๋ฆฝ",
+ "product_satisfaction": "์ ํ ๋ง์กฑ",
+ "unmet_expectations": "๊ธฐ๋ ๋ฏธ์ถฉ์กฑ",
+ "brand_trust": "๋ธ๋๋ ์ ๋ขฐ",
+ }
+
+ sorted_items = sorted(bit_stats.items(), key=lambda x: x[1], reverse=True)
+ quadrants = [BIT_LABELS.get(item[0], item[0]) for item in sorted_items]
+ counts = [item[1] for item in sorted_items]
+
+ colors = ["#6366F1", "#8B5CF6", "#A855F7", "#D946EF"]
+
+ fig = go.Figure(data=[
+ go.Bar(
+ x=quadrants,
+ y=counts,
+ marker_color=colors[:len(quadrants)],
+ text=[f"{c}๊ฑด" for c in counts],
+ textposition="outside",
+ )
+ ])
+
+ fig.update_layout(
+ title="",
+ yaxis_title="๋์ง ํ๋ณด ์",
+ margin=dict(t=20, b=50, l=50, r=20),
+ height=280,
+ )
+
+ return fig
+
+
+def create_emotion_distribution_chart(emotion_stats: dict) -> go.Figure:
+ """Create bar chart for emotion distribution in nudge candidates."""
+ if not emotion_stats:
+ return go.Figure()
+
+ sorted_items = sorted(emotion_stats.items(), key=lambda x: x[1], reverse=True)[:8]
+ emotions = [EMOTION_KO.get(item[0], item[0]) for item in sorted_items]
+ counts = [item[1] for item in sorted_items]
+
+ colors = ["#6366F1", "#8B5CF6", "#A855F7", "#D946EF", "#EC4899", "#F43F5E", "#F97316", "#FBBF24"]
+
+ fig = go.Figure(data=[
+ go.Bar(
+ x=emotions,
+ y=counts,
+ marker_color=colors[:len(emotions)],
+ text=[f"{c}๊ฑด" for c in counts],
+ textposition="outside",
+ )
+ ])
+
+ fig.update_layout(
+ title="",
+ yaxis_title="๋์ง ํ๋ณด ์",
+ xaxis_tickangle=-30,
+ margin=dict(t=20, b=80, l=50, r=20),
+ height=280,
+ )
+
+ return fig
diff --git a/core/data_fetchers.py b/core/data_fetchers.py
new file mode 100644
index 0000000000000000000000000000000000000000..45aa059919c79f100786246e088ca2507feddb6a
--- /dev/null
+++ b/core/data_fetchers.py
@@ -0,0 +1,71 @@
+"""์บ์๋ API ๋ฐ์ดํฐ ํ์ฒ.
+
+Streamlit cache๋ฅผ ํ์ฉํ API ํธ์ถ ํจ์๋ค.
+"""
+import streamlit as st
+
+from .api_client import ChainShiftClient
+
+
+@st.cache_data(ttl=300)
+def get_campaigns(api_key: str = "", access_token: str = ""):
+ """Fetch all campaigns with pagination and caching."""
+ client = ChainShiftClient(api_key=api_key or None, access_token=access_token or None)
+ all_items: list[dict] = []
+ page = 1
+ while True:
+ resp = client.get_campaigns(page=page, page_size=100)
+ items = resp.get("data", {}).get("items", [])
+ all_items.extend(items)
+ total = resp.get("data", {}).get("total", 0)
+ if len(all_items) >= total or not items:
+ break
+ page += 1
+ return {"data": {"items": all_items, "total": len(all_items)}}
+
+
+@st.cache_data(ttl=60)
+def get_nudge_candidates(
+ api_key: str = "",
+ campaign_id: int = 0,
+ page: int = 1,
+ page_size: int = 50,
+ platform: str | None = None,
+ confidence_tier: str | None = None,
+ access_token: str = "",
+):
+ """Fetch nudge candidates (in-house brand negative mentions)."""
+ client = ChainShiftClient(api_key=api_key or None, access_token=access_token or None)
+ return client.get_nudge_candidates(
+ campaign_id,
+ page=page,
+ page_size=page_size,
+ platform=platform if platform and platform != "์ ์ฒด" else None,
+ )
+
+
+@st.cache_data(ttl=60)
+def get_brand_mentions(
+ api_key: str = "",
+ campaign_id: int = 0,
+ brand_type: str | None = None,
+ polarity: str | None = None,
+ access_token: str = "",
+):
+ """Fetch brand mention analysis."""
+ client = ChainShiftClient(api_key=api_key or None, access_token=access_token or None)
+ return client.get_brand_mentions(
+ campaign_id,
+ brand_type=brand_type if brand_type and brand_type != "์ ์ฒด" else None,
+ polarity=polarity if polarity and polarity != "์ ์ฒด" else None,
+ )
+
+
+@st.cache_data(ttl=60)
+def get_feedback_stats(api_key: str = "", campaign_id: int = 0, access_token: str = ""):
+ """Fetch feedback statistics for a campaign."""
+ try:
+ client = ChainShiftClient(api_key=api_key or None, access_token=access_token or None)
+ return client.get_feedback_stats(campaign_id)
+ except Exception:
+ return None
diff --git a/core/export_utils.py b/core/export_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..d53548b3d5b5549eac9e89d893dbfef4e1b3c029
--- /dev/null
+++ b/core/export_utils.py
@@ -0,0 +1,216 @@
+"""
+Export Utilities - ํตํฉ ๋ด๋ณด๋ด๊ธฐ ์ปดํฌ๋ํธ
+
+๊ณตํต ๋ด๋ณด๋ด๊ธฐ ๊ธฐ๋ฅ์ ์ ๊ณตํฉ๋๋ค:
+- CSV/Excel ๋ณํ
+- ํํฐ๋ง ์ต์
+- ์ ์ฒด ๋ต๋ณ ํฌํจ ์ต์
+"""
+
+import pandas as pd
+import streamlit as st
+from io import BytesIO
+from typing import Callable
+
+from .supabase_client import get_sentiment_data_for_export
+from .athena_client import fetch_full_answers_batch
+
+# openpyxl ์ค์น ์ฌ๋ถ ํ์ธ (Excel export์ฉ)
+try:
+ import openpyxl
+ EXCEL_AVAILABLE = True
+except ImportError:
+ EXCEL_AVAILABLE = False
+
+
+# LLM ๊ฒ์ฆ ์ํ ๋ผ๋ฒจ (์ฉ์ด ํต์ผ)
+LLM_STATUS_LABELS = {
+ "all": "์ ์ฒด",
+ "verified": "๊ฒ์ฆ์๋ฃ",
+ "false_positive": "์คํ (๋ถ์ โ๋น๋ถ์ )", # ๋ถ์ ์๋
+ "true_negative": "์ ํ (๋ถ์ ํ์ )", # ๋ถ์ ํ์
+ "unverified": "๋ฏธ๊ฒ์ฆ",
+}
+
+POLARITY_LABELS = {
+ "all": "์ ์ฒด",
+ "negative": "๋ถ์ ",
+ "positive": "๊ธ์ ",
+ "neutral": "์ค๋ฆฝ",
+}
+
+
+def prepare_dataframe_for_export(
+ data: list[dict],
+ include_full_answers: bool = False,
+) -> pd.DataFrame:
+ """๋ฐ์ดํฐ๋ฅผ DataFrame์ผ๋ก ๋ณํํ๊ณ ๋ด๋ณด๋ด๊ธฐ์ฉ์ผ๋ก ์ ๋ฆฌํฉ๋๋ค.
+
+ Args:
+ data: ๋ด๋ณด๋ผ ๋ฐ์ดํฐ ๋ฆฌ์คํธ
+ include_full_answers: ์ ์ฒด ๋ต๋ณ ํฌํจ ์ฌ๋ถ
+
+ Returns:
+ ์ ๋ฆฌ๋ DataFrame
+ """
+ if not data:
+ return pd.DataFrame()
+
+ df = pd.DataFrame(data)
+
+ # ๋ฆฌ์คํธ ์ปฌ๋ผ์ ๋ฌธ์์ด๋ก ๋ณํ
+ list_columns = ['in_house_brands', 'mentioned_brands', 'llm_evidence_spans']
+ for col in list_columns:
+ if col in df.columns:
+ df[col] = df[col].apply(
+ lambda x: ', '.join(x) if isinstance(x, list) else str(x) if x else ''
+ )
+
+ # ์ปฌ๋ผ ์์ ์ ๋ฆฌ - answer_full์ answer_preview ๋ค์์ ๋ฐฐ์น
+ if 'answer_full' in df.columns and 'answer_preview' in df.columns:
+ cols = list(df.columns)
+ cols.remove('answer_full')
+ idx = cols.index('answer_preview') + 1
+ cols.insert(idx, 'answer_full')
+ df = df[cols]
+
+ return df
+
+
+def export_to_csv(df: pd.DataFrame) -> bytes:
+ """DataFrame์ CSV ๋ฐ์ดํธ๋ก ๋ณํํฉ๋๋ค."""
+ return df.to_csv(index=False).encode('utf-8-sig')
+
+
+def export_to_excel(df: pd.DataFrame) -> bytes | None:
+ """DataFrame์ Excel ๋ฐ์ดํธ๋ก ๋ณํํฉ๋๋ค.
+
+ Returns:
+ Excel ๋ฐ์ดํธ ๋ฐ์ดํฐ, ๋๋ openpyxl์ด ์์ผ๋ฉด None
+ """
+ if not EXCEL_AVAILABLE:
+ return None
+ output = BytesIO()
+ with pd.ExcelWriter(output, engine='openpyxl') as writer:
+ df.to_excel(writer, index=False, sheet_name='Data')
+ return output.getvalue()
+
+
+def render_export_component(
+ campaign_id: int,
+ key_prefix: str,
+ title: str = "๐ฅ ๋ฐ์ดํฐ ๋ด๋ณด๋ด๊ธฐ",
+ show_polarity_filter: bool = True,
+ show_llm_filter: bool = True,
+ default_polarity: str = "negative",
+ default_llm_status: str = "all",
+ in_house_only: bool = True,
+):
+ """ํตํฉ ๋ด๋ณด๋ด๊ธฐ ์ปดํฌ๋ํธ๋ฅผ ๋ ๋๋งํฉ๋๋ค.
+
+ Args:
+ campaign_id: ์บ ํ์ธ ID
+ key_prefix: Streamlit ์์ ฏ ํค ์ ๋์ฌ (์ค๋ณต ๋ฐฉ์ง)
+ title: ์น์
์ ๋ชฉ
+ show_polarity_filter: ๊ฐ์ ํํฐ ํ์ ์ฌ๋ถ
+ show_llm_filter: LLM ์ํ ํํฐ ํ์ ์ฌ๋ถ
+ default_polarity: ๊ธฐ๋ณธ ๊ฐ์ ํํฐ ๊ฐ
+ default_llm_status: ๊ธฐ๋ณธ LLM ์ํ ํํฐ ๊ฐ
+ in_house_only: ์์ฌ ๋ธ๋๋๋ง ํํฐ๋ง
+ """
+ with st.expander(title, expanded=False):
+ # ํํฐ ์ต์
+ filter_col1, filter_col2 = st.columns(2)
+
+ with filter_col1:
+ if show_polarity_filter:
+ polarity_options = list(POLARITY_LABELS.keys())
+ polarity_labels = list(POLARITY_LABELS.values())
+ default_idx = polarity_options.index(default_polarity) if default_polarity in polarity_options else 0
+ selected_polarity = st.selectbox(
+ "๊ฐ์ ํํฐ",
+ options=polarity_options,
+ format_func=lambda x: POLARITY_LABELS[x],
+ index=default_idx,
+ key=f"{key_prefix}_polarity"
+ )
+ else:
+ selected_polarity = default_polarity
+
+ with filter_col2:
+ if show_llm_filter:
+ llm_options = list(LLM_STATUS_LABELS.keys())
+ default_idx = llm_options.index(default_llm_status) if default_llm_status in llm_options else 0
+ selected_llm_status = st.selectbox(
+ "LLM ๊ฒ์ฆ ์ํ",
+ options=llm_options,
+ format_func=lambda x: LLM_STATUS_LABELS[x],
+ index=default_idx,
+ key=f"{key_prefix}_llm_status"
+ )
+ else:
+ selected_llm_status = default_llm_status
+
+ # ๋ด๋ณด๋ด๊ธฐ ์ต์
+ opt_col1, opt_col2 = st.columns(2)
+ with opt_col1:
+ include_full_answers = st.checkbox(
+ "์ ์ฒด ๋ต๋ณ ํฌํจ",
+ value=False,
+ help="Athena์์ ์ ์ฒด ๋ต๋ณ์ ๊ฐ์ ธ์ต๋๋ค (ํ์ผ ํฌ๊ธฐ ์ฆ๊ฐ)",
+ key=f"{key_prefix}_full_answers"
+ )
+ with opt_col2:
+ include_evidence = st.checkbox(
+ "LLM ๊ทผ๊ฑฐ ํฌํจ",
+ value=False,
+ help="LLM ํ๋จ ๊ทผ๊ฑฐ(reasoning, evidence_spans)๋ฅผ ํฌํจํฉ๋๋ค",
+ key=f"{key_prefix}_evidence"
+ )
+
+ st.markdown("---")
+
+ # ๋ค์ด๋ก๋ ๋ฒํผ
+ btn_col1, btn_col2, btn_col3 = st.columns([1, 1, 2])
+
+ # ๋ฐ์ดํฐ ๊ฐ์ ธ์ค๊ธฐ
+ data = get_sentiment_data_for_export(
+ campaign_id=campaign_id,
+ polarity=selected_polarity if selected_polarity != "all" else None,
+ llm_status=selected_llm_status if selected_llm_status != "all" else None,
+ in_house_only=in_house_only,
+ include_full_answers=include_full_answers,
+ include_evidence=include_evidence,
+ )
+
+ if data:
+ df = prepare_dataframe_for_export(data, include_full_answers)
+ count = len(df)
+
+ with btn_col1:
+ csv_data = export_to_csv(df)
+ st.download_button(
+ label=f"๐ฅ CSV ({count}๊ฑด)",
+ data=csv_data,
+ file_name=f"campaign_{campaign_id}_export_{count}๊ฑด.csv",
+ mime="text/csv",
+ key=f"{key_prefix}_csv_download"
+ )
+
+ with btn_col2:
+ if EXCEL_AVAILABLE:
+ excel_data = export_to_excel(df)
+ st.download_button(
+ label=f"๐ฅ Excel ({count}๊ฑด)",
+ data=excel_data,
+ file_name=f"campaign_{campaign_id}_export_{count}๊ฑด.xlsx",
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ key=f"{key_prefix}_excel_download"
+ )
+ else:
+ st.caption("Excel: openpyxl ํ์")
+
+ with btn_col3:
+ st.caption(f"์ด {count}๊ฑด | ํํฐ: {POLARITY_LABELS.get(selected_polarity, '์ ์ฒด')} / {LLM_STATUS_LABELS.get(selected_llm_status, '์ ์ฒด')}")
+ else:
+ st.info("๋ด๋ณด๋ผ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.")
diff --git a/core/job_realtime.py b/core/job_realtime.py
new file mode 100644
index 0000000000000000000000000000000000000000..54619467217f0e45414bbe0bc9da172bb136ee1b
--- /dev/null
+++ b/core/job_realtime.py
@@ -0,0 +1,264 @@
+"""Sentiment Analysis Job ์ค์๊ฐ ๋ชจ๋ํฐ๋ง (Phase 4.1)
+
+Usage:
+ from job_realtime import get_active_jobs, get_job_by_id
+
+ # ์คํ ์ค์ธ Job ๋ชฉ๋ก
+ jobs = get_active_jobs(campaign_id=27)
+
+ # ํน์ Job ์์ธ
+ job = get_job_by_id("abc-123")
+
+Note:
+ Streamlit์ WebSocket์ ์ง์ ์ง์ํ์ง ์์ผ๋ฏ๋ก,
+ st.rerun() ๋๋ st.cache_data(ttl=5)๋ก ํด๋ง ๋ฐฉ์ ์ฌ์ฉ
+"""
+
+import os
+from datetime import datetime, timezone
+from functools import lru_cache
+from pathlib import Path
+from typing import Literal
+
+import streamlit as st
+from supabase import create_client, Client
+from dotenv import load_dotenv
+
+# Load environment variables (project root)
+_project_root = Path(__file__).parent.parent.parent.parent
+for _env_name in (".env.dev", ".env.prod"):
+ _env_path = _project_root / _env_name
+ if _env_path.exists():
+ load_dotenv(_env_path, override=True)
+ break
+
+
+@lru_cache()
+def _get_client() -> Client:
+ """Supabase ํด๋ผ์ด์ธํธ (์บ์๋จ)"""
+ url = os.environ.get("SUPABASE_URL", "")
+ key = os.environ.get("SUPABASE_SERVICE_KEY", "")
+ if not url or not key:
+ raise ValueError("Missing SUPABASE_URL or SUPABASE_SERVICE_KEY")
+ return create_client(url, key)
+
+
+JobStatus = Literal["queued", "running", "completed", "failed", "cancelled"]
+
+
+@st.cache_data(ttl=5)
+def get_active_jobs(campaign_id: int | None = None, limit: int = 10) -> list[dict]:
+ """์คํ ์ค์ด๊ฑฐ๋ ๋๊ธฐ ์ค์ธ Job ๋ชฉ๋ก ์กฐํ
+
+ Args:
+ campaign_id: ํน์ ์บ ํ์ธ๋ง ํํฐ๋ง (None์ด๋ฉด ์ ์ฒด)
+ limit: ์ต๋ ๊ฒฐ๊ณผ ์
+
+ Returns:
+ Job ๋ชฉ๋ก (์ต์ ์ ์ ๋ ฌ)
+ """
+ client = _get_client()
+ query = client.table("sentiment_analysis_jobs").select(
+ "id, campaign_id, status, progress, message, "
+ "total_answers, processed_answers, nudge_candidates, "
+ "created_at, started_at, completed_at, error_message"
+ ).in_("status", ["queued", "running"])
+
+ if campaign_id:
+ query = query.eq("campaign_id", campaign_id)
+
+ query = query.order("created_at", desc=True).limit(limit)
+ result = query.execute()
+ return result.data or []
+
+
+@st.cache_data(ttl=5)
+def get_recent_jobs(
+ campaign_id: int | None = None,
+ status: JobStatus | None = None,
+ limit: int = 20,
+) -> list[dict]:
+ """์ต๊ทผ Job ๋ชฉ๋ก ์กฐํ (ํ์คํ ๋ฆฌ์ฉ)
+
+ Args:
+ campaign_id: ํน์ ์บ ํ์ธ๋ง ํํฐ๋ง
+ status: ํน์ ์ํ๋ง ํํฐ๋ง
+ limit: ์ต๋ ๊ฒฐ๊ณผ ์
+
+ Returns:
+ Job ๋ชฉ๋ก (์ต์ ์ ์ ๋ ฌ)
+ """
+ client = _get_client()
+ query = client.table("sentiment_analysis_jobs").select(
+ "id, campaign_id, status, progress, message, "
+ "total_answers, processed_answers, nudge_candidates, "
+ "created_at, started_at, completed_at, error_message"
+ )
+
+ if campaign_id:
+ query = query.eq("campaign_id", campaign_id)
+ if status:
+ query = query.eq("status", status)
+
+ query = query.order("created_at", desc=True).limit(limit)
+ result = query.execute()
+ return result.data or []
+
+
+def get_job_by_id(job_id: str) -> dict | None:
+ """ํน์ Job ์์ธ ์กฐํ
+
+ Args:
+ job_id: Job ID
+
+ Returns:
+ Job ์ ๋ณด ๋๋ None
+ """
+ client = _get_client()
+ result = client.table("sentiment_analysis_jobs").select(
+ "id, campaign_id, status, progress, message, "
+ "total_answers, processed_answers, nudge_candidates, "
+ "created_at, started_at, completed_at, error_message"
+ ).eq("id", job_id).execute()
+ return result.data[0] if result.data else None
+
+
+def format_job_duration(job: dict) -> str:
+ """Job ์์ ์๊ฐ ํฌ๋งทํ
+
+ Args:
+ job: Job ์ ๋ณด dict
+
+ Returns:
+ "2์๊ฐ 15๋ถ" ํํ์ ๋ฌธ์์ด
+ """
+ started_at = job.get("started_at")
+ completed_at = job.get("completed_at")
+
+ if not started_at:
+ return "-"
+
+ try:
+ # ISO ๋ฌธ์์ด ํ์ฑ
+ if isinstance(started_at, str):
+ start = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
+ else:
+ start = started_at
+
+ if completed_at:
+ if isinstance(completed_at, str):
+ end = datetime.fromisoformat(completed_at.replace("Z", "+00:00"))
+ else:
+ end = completed_at
+ else:
+ end = datetime.now(timezone.utc)
+
+ seconds = int((end - start).total_seconds())
+
+ if seconds < 60:
+ return f"{seconds}์ด"
+ elif seconds < 3600:
+ return f"{seconds // 60}๋ถ"
+ else:
+ hours = seconds // 3600
+ minutes = (seconds % 3600) // 60
+ if minutes > 0:
+ return f"{hours}์๊ฐ {minutes}๋ถ"
+ return f"{hours}์๊ฐ"
+ except Exception:
+ return "-"
+
+
+def get_status_emoji(status: str) -> str:
+ """Job ์ํ ์ด๋ชจ์ง ๋ฐํ"""
+ return {
+ "queued": "๐ก",
+ "running": "๐ต",
+ "completed": "๐ข",
+ "failed": "๐ด",
+ "cancelled": "โช",
+ }.get(status, "โช")
+
+
+def get_status_label(status: str) -> str:
+ """Job ์ํ ํ๊ธ ๋ผ๋ฒจ ๋ฐํ"""
+ return {
+ "queued": "๋๊ธฐ ์ค",
+ "running": "์คํ ์ค",
+ "processing": "์ฒ๋ฆฌ ์ค",
+ "completed": "์๋ฃ",
+ "failed": "์คํจ",
+ "cancelled": "์ทจ์๋จ",
+ }.get(status, status)
+
+
+# ============================================================================
+# Hierarchy Analysis Jobs (hierarchy_analysis_jobs table)
+# ============================================================================
+
+_HIERARCHY_FIELDS = (
+ "id, user_id, prompt, title, status, progress, current_step, "
+ "created_at, started_at, completed_at, error_message"
+)
+
+_HIERARCHY_STEP_LABELS: dict[str, str] = {
+ "prompt_enhancement": "ํ๋กฌํํธ ๋ถ์",
+ "query_generation": "์ฟผ๋ฆฌ ์์ฑ",
+ "keyword_data": "ํค์๋ ์์ง",
+ "hierarchy_generation": "๊ณ์ธต ๊ตฌ์กฐ ์์ฑ",
+ "search_volume": "๊ฒ์๋ ์กฐํ",
+ "question_generation": "์ง๋ฌธ ์์ฑ",
+ "ratio_supplement": "๋น์จ ๋ณด์ ",
+ "finalization": "์ต์ข
์ ์ฅ",
+ "done": "์๋ฃ",
+}
+
+
+def get_hierarchy_step_label(step: str | None) -> str:
+ """Hierarchy ํ์ดํ๋ผ์ธ ์คํ
ํ๊ธ ๋ผ๋ฒจ ๋ฐํ"""
+ if not step:
+ return "๋๊ธฐ ์ค"
+ return _HIERARCHY_STEP_LABELS.get(step, step)
+
+
+def get_active_hierarchy_jobs(user_id: str | None = None, limit: int = 5) -> list[dict]:
+ """์คํ ์ค์ด๊ฑฐ๋ ๋๊ธฐ ์ค์ธ Hierarchy Job ๋ชฉ๋ก (Supabase ์ง์ ์ฟผ๋ฆฌ)
+
+ Args:
+ user_id: ์ฌ์ฉ์ ID๋ก ํํฐ๋ง (None์ด๋ฉด ์ ์ฒด)
+ limit: ์ต๋ ๊ฒฐ๊ณผ ์
+ """
+ client = _get_client()
+ query = (
+ client.table("hierarchy_analysis_jobs")
+ .select(_HIERARCHY_FIELDS)
+ .in_("status", ["queued", "processing"])
+ )
+ if user_id:
+ query = query.eq("user_id", user_id)
+ query = query.order("created_at", desc=True).limit(limit)
+ result = query.execute()
+ return result.data or []
+
+
+def get_recent_hierarchy_jobs(
+ user_id: str | None = None,
+ status: str | None = None,
+ limit: int = 20,
+) -> list[dict]:
+ """์ต๊ทผ Hierarchy Job ์ด๋ ฅ ์กฐํ
+
+ Args:
+ user_id: ์ฌ์ฉ์ ID๋ก ํํฐ๋ง
+ status: ํน์ ์ํ๋ง ํํฐ๋ง
+ limit: ์ต๋ ๊ฒฐ๊ณผ ์
+ """
+ client = _get_client()
+ query = client.table("hierarchy_analysis_jobs").select(_HIERARCHY_FIELDS)
+ if user_id:
+ query = query.eq("user_id", user_id)
+ if status:
+ query = query.eq("status", status)
+ query = query.order("created_at", desc=True).limit(limit)
+ result = query.execute()
+ return result.data or []
diff --git a/core/styles.py b/core/styles.py
new file mode 100644
index 0000000000000000000000000000000000000000..54bb2e33cebe1b4fdd1f0ad2d515f4eae5d436ea
--- /dev/null
+++ b/core/styles.py
@@ -0,0 +1,93 @@
+"""CSS Styles for Streamlit Dashboard.
+
+Uses ChainShift Brand Colors (2026 Design System).
+Primary: Electric Indigo (#5041FF)
+Accent: Neo Aqua (#00D5B5)
+"""
+
+# ============================================================================
+# Brand Colors (synced with app/core/brand.py)
+# ============================================================================
+INDIGO_700 = "#5041FF" # Primary
+INDIGO_800 = "#3E32CC" # Primary Dark
+INDIGO_100 = "#E5E2FF" # Primary Light BG
+AQUA_600 = "#00D5B5" # Accent
+AQUA_100 = "#B8FFF6" # Accent Light BG
+POSITIVE = "#10B981" # Green
+NEGATIVE = "#EF4444" # Red
+NEUTRAL = "#6B7280" # Gray
+WARNING = "#F59E0B" # Amber
+
+DASHBOARD_CSS = f"""
+
+"""
+
+# Tier color mapping for nudge candidates
+TIER_BORDER_COLORS = {
+ "HIGH": NEGATIVE,
+ "MEDIUM": WARNING,
+ "LOW": POSITIVE,
+}
+
+# Chart colors for Streamlit charts (plotly, altair)
+CHART_COLORS = [
+ INDIGO_700, # Primary
+ AQUA_600, # Accent
+ "#9585FF", # Indigo 500
+ "#3DF1E7", # Aqua 400
+ "#3E32CC", # Indigo 800
+ "#06A68D", # Aqua 700
+]
diff --git a/core/supabase_action_items.py b/core/supabase_action_items.py
new file mode 100644
index 0000000000000000000000000000000000000000..443559c92595d3dd08088c4756e3de266d386320
--- /dev/null
+++ b/core/supabase_action_items.py
@@ -0,0 +1,206 @@
+"""Supabase action items & reports queries.
+
+Module-level functions extracted from supabase_client.py.
+All functions use get_supabase_client() from the parent module.
+"""
+import streamlit as st
+
+
+def get_action_items(
+ campaign_id: int,
+ status: str | None = None,
+ category: str | None = None,
+ page: int = 1,
+ page_size: int = 50,
+ order_by: str = "created_at",
+ desc: bool = True,
+) -> tuple[list[dict], int]:
+ """Get action items for a campaign with filters and sorting.
+
+ Args:
+ order_by: Column to sort by (created_at, priority, category, status)
+ desc: True for descending, False for ascending
+
+ Returns (items, total_count).
+ """
+ from core.supabase_client import get_supabase_client
+
+ try:
+ client = get_supabase_client()
+ query = (
+ client.table("action_items")
+ .select(
+ "id, campaign_id, trigger_rule_id, category, priority, label, "
+ "evidence, llm_recommendation, status, assignee_email, "
+ "created_at, completed_at",
+ count="planned",
+ )
+ .eq("campaign_id", campaign_id)
+ )
+ if status:
+ query = query.eq("status", status)
+ if category:
+ query = query.eq("category", category)
+
+ offset = (page - 1) * page_size
+ query = (
+ query.order(order_by, desc=desc)
+ .range(offset, offset + page_size - 1)
+ )
+ result = query.execute()
+ return result.data or [], result.count or 0
+ except Exception:
+ return [], 0
+
+
+@st.cache_data(ttl=60)
+def get_action_item_stats(campaign_id: int) -> dict:
+ """Get action item stats via RPC (COUNT FILTER pattern)."""
+ _empty = {"pending": 0, "in_progress": 0, "completed": 0, "archived": 0, "total": 0}
+ try:
+ from core.supabase_client import get_supabase_client
+
+ client = get_supabase_client()
+ result = client.rpc(
+ "get_action_item_stats", {"p_campaign_id": campaign_id}
+ ).execute()
+ data = result.data
+ if isinstance(data, list) and data:
+ data = data[0]
+ if isinstance(data, str):
+ import json
+ data = json.loads(data)
+ return data if isinstance(data, dict) else _empty
+ except Exception:
+ return _empty
+
+
+def update_action_item_status(item_id: str, new_status: str) -> bool:
+ """Update action item status. Returns True on success."""
+ try:
+ from core.supabase_client import get_supabase_client
+
+ client = get_supabase_client()
+ result = (
+ client.table("action_items")
+ .update({"status": new_status})
+ .eq("id", item_id)
+ .execute()
+ )
+ return bool(result.data)
+ except Exception:
+ return False
+
+
+def delete_action_item(item_id: str) -> bool:
+ """Delete action item. Returns True on success."""
+ try:
+ from core.supabase_client import get_supabase_client
+
+ client = get_supabase_client()
+ result = (
+ client.table("action_items")
+ .delete()
+ .eq("id", item_id)
+ .execute()
+ )
+ return bool(result.data)
+ except Exception:
+ return False
+
+
+def save_action_items_batch(
+ campaign_id: int,
+ user_id: str,
+ items: list[dict],
+ report_id: str | None = None,
+) -> dict:
+ """Save action items with dedup (check existing active items).
+
+ Args:
+ campaign_id: Campaign ID
+ user_id: User UUID
+ items: List of dicts with trigger_rule_id, category, priority, label, evidence
+ report_id: Optional report UUID
+
+ Returns:
+ {"created": N, "skipped": N}
+ """
+ try:
+ from core.supabase_client import get_supabase_client
+
+ client = get_supabase_client()
+
+ # Batch dedup check: single query instead of N queries
+ trigger_ids = [item["trigger_rule_id"] for item in items]
+ existing_result = (
+ client.table("action_items")
+ .select("trigger_rule_id")
+ .eq("campaign_id", campaign_id)
+ .in_("trigger_rule_id", trigger_ids)
+ .in_("status", ["pending", "in_progress"])
+ .execute()
+ )
+ existing_set = {r["trigger_rule_id"] for r in (existing_result.data or [])}
+
+ rows_to_insert = []
+ skipped = 0
+ for item in items:
+ if item["trigger_rule_id"] in existing_set:
+ skipped += 1
+ continue
+ rows_to_insert.append({
+ "campaign_id": campaign_id,
+ "user_id": user_id,
+ "report_id": report_id,
+ "trigger_rule_id": item["trigger_rule_id"],
+ "category": item["category"],
+ "priority": item["priority"],
+ "label": item["label"],
+ "evidence": item.get("evidence"),
+ "llm_recommendation": item.get("llm_recommendation"),
+ "status": "pending",
+ })
+
+ created = 0
+ if rows_to_insert:
+ client.table("action_items").insert(rows_to_insert).execute()
+ created = len(rows_to_insert)
+
+ return {"created": created, "skipped": skipped}
+ except Exception as e:
+ return {"created": 0, "skipped": 0, "error": str(e)}
+
+
+def get_campaign_date_range(campaign_id: int) -> tuple[str, str] | None:
+ """Get first and last data dates for a campaign via RPC (single query).
+
+ Returns:
+ Tuple of (first_date, last_date) as strings, or None if no data
+ """
+ from core.supabase_client import get_supabase_client
+
+ client = get_supabase_client()
+ result = client.rpc("get_campaign_date_range_agg", {"p_campaign_id": campaign_id}).execute()
+
+ data = result.data
+ # PostgREST may wrap json return as [dict] or dict
+ if isinstance(data, list) and data:
+ data = data[0]
+ if isinstance(data, dict) and data.get("first_date") and data.get("last_date"):
+ return (data["first_date"], data["last_date"])
+ return None
+
+
+def get_report_history_count(campaign_id: int) -> int:
+ """Get total count of generated HTML reports for a campaign."""
+ from core.supabase_client import get_supabase_client
+
+ client = get_supabase_client()
+ result = (
+ client.table("html_reports")
+ .select("id", count="planned")
+ .eq("campaign_id", campaign_id)
+ .execute()
+ )
+ return result.count or 0
diff --git a/core/supabase_client.py b/core/supabase_client.py
new file mode 100644
index 0000000000000000000000000000000000000000..7b2b6747cba238280a7fcc0da6353a767c17a96b
--- /dev/null
+++ b/core/supabase_client.py
@@ -0,0 +1,76 @@
+"""Supabase client for dashboard - direct queries for LLM verification data.
+
+Domain queries are in separate modules:
+- supabase_sentiment.py: Overview, LLM verification, polarity
+- supabase_research.py: Topic clusters, cross-model analysis
+- supabase_action_items.py: Action items CRUD, reports, date range
+"""
+
+import os
+from functools import lru_cache
+from pathlib import Path
+
+from supabase import create_client, Client
+from dotenv import load_dotenv
+
+# Load environment variables (project root)
+_project_root = Path(__file__).parent.parent.parent.parent
+for _env_name in (".env.dev", ".env.prod"):
+ _env_path = _project_root / _env_name
+ if _env_path.exists():
+ load_dotenv(_env_path, override=True)
+ break
+
+
+@lru_cache()
+def get_supabase_client() -> Client:
+ """Create Supabase client for unified database.
+
+ Uses service_role key (bypasses RLS) because the dashboard is an
+ internal admin tool that needs cross-campaign analytics.
+ DO NOT use this client in user-facing API routes.
+ """
+ url = os.environ.get("SUPABASE_URL", "")
+ key = os.environ.get("SUPABASE_SERVICE_KEY", "")
+ if not url or not key:
+ raise ValueError("Missing SUPABASE_URL or SUPABASE_SERVICE_KEY")
+ return create_client(url, key)
+
+
+# ============================================================================
+# Re-exports for backward compatibility
+# All consumers import from core.supabase_client โ these re-exports
+# ensure existing imports continue to work unchanged.
+# ============================================================================
+
+# Sentiment / Overview / LLM Verification
+from core.supabase_sentiment import ( # noqa: E402, F401
+ get_campaign_overview,
+ get_llm_verification_stats,
+ get_false_positives,
+ get_true_negatives,
+ get_llm_verified_for_export,
+ get_sentiment_data_for_export,
+ get_answers_by_polarity,
+ get_polarity_stats,
+)
+
+# Research / Topics
+from core.supabase_research import ( # noqa: E402, F401
+ get_topic_clusters,
+ get_topic_map_snapshot,
+ get_cross_model_analysis,
+ get_gap_scores,
+ find_cross_model_pair,
+)
+
+# Action Items / Reports
+from core.supabase_action_items import ( # noqa: E402, F401
+ get_action_items,
+ get_action_item_stats,
+ update_action_item_status,
+ delete_action_item,
+ save_action_items_batch,
+ get_campaign_date_range,
+ get_report_history_count,
+)
diff --git a/core/supabase_research.py b/core/supabase_research.py
new file mode 100644
index 0000000000000000000000000000000000000000..e5bb16aaf4bc163d341de08f6f29f1aec066b0c4
--- /dev/null
+++ b/core/supabase_research.py
@@ -0,0 +1,153 @@
+"""Supabase research queries โ topic clusters, cross-model analysis.
+
+Module-level functions extracted from supabase_client.py.
+All functions use get_supabase_client() from the parent module.
+"""
+
+
+def get_topic_clusters(campaign_id: int, source: str | None = None) -> list[dict]:
+ """Fetch topic clusters with scores for a campaign, optionally filtered by source.
+
+ Returns:
+ List of cluster dicts sorted by opportunity_score DESC.
+ """
+ from core.supabase_client import get_supabase_client
+
+ client = get_supabase_client()
+ query = (
+ client.table("topic_clusters")
+ .select(
+ "id, cluster_label, fanout_count, unique_questions, "
+ "attention_score, citation_density, opportunity_score, "
+ "sample_fanouts, top_sources, source"
+ )
+ .eq("campaign_id", campaign_id)
+ )
+ if source:
+ query = query.eq("source", source)
+ result = query.order("opportunity_score", desc=True).execute()
+ return result.data or []
+
+
+def get_topic_map_snapshot(campaign_id: int, source: str | None = None) -> dict | None:
+ """Fetch latest UMAP 2D coordinates for visualization.
+
+ Returns:
+ Dict with coordinates and algorithm_params, or None.
+ """
+ from core.supabase_client import get_supabase_client
+
+ client = get_supabase_client()
+ result = (
+ client.table("topic_map_snapshots")
+ .select("coordinates, algorithm_params")
+ .eq("campaign_id", campaign_id)
+ .order("created_at", desc=True)
+ .execute()
+ )
+ if not result.data:
+ return None
+ if source:
+ for snap in result.data:
+ params = snap.get("algorithm_params") or {}
+ if params.get("source") == source:
+ return snap
+ return None
+ return result.data[0]
+
+
+def get_cross_model_analysis(
+ campaign_chatgpt: int,
+ campaign_gemini: int,
+) -> dict | None:
+ """Fetch cross-model analysis summary (NMI, match count).
+
+ Returns:
+ Dict with nmi_score, total_matched_topics, algorithm_params, or None.
+ """
+ from core.supabase_client import get_supabase_client
+
+ client = get_supabase_client()
+ result = (
+ client.table("cross_model_analysis")
+ .select("nmi_score, total_matched_topics, algorithm_params, created_at")
+ .eq("campaign_chatgpt", campaign_chatgpt)
+ .eq("campaign_gemini", campaign_gemini)
+ .limit(1)
+ .execute()
+ )
+ return result.data[0] if result.data else None
+
+
+def get_gap_scores(
+ campaign_chatgpt: int,
+ campaign_gemini: int,
+) -> list[dict]:
+ """Fetch cross-model topic matches with GapScore and quadrant.
+
+ Returns:
+ List of match dicts with cluster labels, sorted by gap_score DESC.
+ """
+ from core.supabase_client import get_supabase_client
+
+ client = get_supabase_client()
+ result = (
+ client.table("cross_model_topic_matches")
+ .select(
+ "id, chatgpt_cluster_id, gemini_cluster_id, "
+ "match_score, label_similarity, centroid_similarity, "
+ "demand_percentile, supply_percentile, gap_score, quadrant"
+ )
+ .eq("campaign_chatgpt", campaign_chatgpt)
+ .eq("campaign_gemini", campaign_gemini)
+ .not_.is_("gap_score", "null")
+ .order("gap_score", desc=True)
+ .execute()
+ )
+ matches = result.data or []
+
+ # Enrich with cluster labels (batch query instead of N+1)
+ if matches:
+ chatgpt_ids = [m["chatgpt_cluster_id"] for m in matches]
+ gemini_ids = [m["gemini_cluster_id"] for m in matches]
+ all_ids = list(set(chatgpt_ids + gemini_ids))
+
+ label_map = {}
+ label_result = (
+ client.table("topic_clusters")
+ .select("id, cluster_label")
+ .in_("id", all_ids)
+ .execute()
+ )
+ for row in (label_result.data or []):
+ label_map[row["id"]] = row.get("cluster_label", "")
+
+ for m in matches:
+ m["chatgpt_label"] = label_map.get(m["chatgpt_cluster_id"], "")
+ m["gemini_label"] = label_map.get(m["gemini_cluster_id"], "")
+
+ return matches
+
+
+def find_cross_model_pair(campaign_id: int) -> dict | None:
+ """Find cross-model analysis pair containing this campaign_id.
+
+ Checks both chatgpt and gemini sides so the sidebar only needs one ID.
+
+ Returns:
+ Dict with campaign_chatgpt, campaign_gemini, nmi_score,
+ total_matched_topics, or None if no pair exists.
+ """
+ from core.supabase_client import get_supabase_client
+
+ client = get_supabase_client()
+ resp = (
+ client.table("cross_model_analysis")
+ .select("campaign_chatgpt, campaign_gemini, nmi_score, total_matched_topics")
+ .or_(f"campaign_chatgpt.eq.{campaign_id},campaign_gemini.eq.{campaign_id}")
+ .limit(1)
+ .execute()
+ )
+ if resp.data:
+ return resp.data[0]
+ return None
diff --git a/core/supabase_sentiment.py b/core/supabase_sentiment.py
new file mode 100644
index 0000000000000000000000000000000000000000..f5fa0559d4e144a4875bfbb9274d10fa949b354b
--- /dev/null
+++ b/core/supabase_sentiment.py
@@ -0,0 +1,373 @@
+"""Supabase sentiment queries โ overview, LLM verification, polarity.
+
+Module-level functions extracted from supabase_client.py.
+All functions use get_supabase_client() from the parent module.
+"""
+import logging
+import streamlit as st
+
+logger = logging.getLogger(__name__)
+
+
+def get_campaign_overview(campaign_id: int) -> dict:
+ """Get comprehensive overview stats for a campaign via single RPC call.
+
+ Uses get_campaign_overview_agg() RPC which performs COUNT(*) FILTER
+ in a single table scan instead of 6 separate count queries.
+
+ Returns:
+ Dict with total_answers, in_house_negative_count, llm_verified_total,
+ llm_verified_in_house, llm_pending, llm_confirmed_negative.
+ Returns zeroed dict on DB error for graceful degradation.
+ """
+ from core.supabase_client import get_supabase_client
+
+ _empty = {
+ "total_answers": 0,
+ "in_house_negative_count": 0,
+ "llm_verified_total": 0,
+ "llm_verified_in_house": 0,
+ "llm_pending": 0,
+ "llm_confirmed_negative": 0,
+ }
+ try:
+ client = get_supabase_client()
+ result = client.rpc(
+ "get_campaign_overview_agg", {"p_campaign_id": campaign_id}
+ ).execute()
+ data = result.data
+ # RPC may return list-wrapped or JSON string depending on PostgREST
+ if isinstance(data, list) and data:
+ data = data[0]
+ if isinstance(data, str):
+ import json
+ data = json.loads(data)
+ return data if (isinstance(data, dict) and data) else _empty
+ except Exception:
+ return _empty
+
+
+def get_llm_verification_stats(
+ campaign_id: int,
+ in_house_only: bool = True,
+) -> dict:
+ """Get LLM verification statistics via single RPC (avoids VIEW timeout).
+
+ Uses get_campaign_sentiment_stats RPC โ one table scan with COUNT FILTER.
+
+ Args:
+ campaign_id: Campaign ID
+ in_house_only: If True, only count in-house brand negatives (default)
+
+ Returns:
+ Dict with total_verified, false_positives, true_negatives counts
+ """
+ from core.supabase_client import get_supabase_client
+
+ _empty = {"total_verified": 0, "false_positives": 0, "true_negatives": 0}
+ try:
+ client = get_supabase_client()
+ result = client.rpc(
+ "get_campaign_sentiment_stats", {"p_campaign_id": campaign_id}
+ ).execute()
+ stats = result.data or {}
+ if isinstance(stats, list) and stats:
+ stats = stats[0]
+ if isinstance(stats, str):
+ import json
+ stats = json.loads(stats)
+ if not isinstance(stats, dict):
+ return _empty
+
+ if in_house_only:
+ return {
+ "total_verified": stats.get("in_house_llm_verified", 0),
+ "false_positives": stats.get("in_house_llm_false_pos", 0),
+ "true_negatives": stats.get("in_house_llm_true_neg", 0),
+ }
+ return {
+ "total_verified": stats.get("llm_verified", 0),
+ "false_positives": stats.get("llm_false_positive", 0),
+ "true_negatives": stats.get("llm_true_negative", 0),
+ }
+ except Exception:
+ return _empty
+
+
+@st.cache_data(ttl=60)
+def get_false_positives(
+ campaign_id: int,
+ page: int = 1,
+ page_size: int = 50,
+ in_house_only: bool = True,
+) -> tuple[list[dict], int]:
+ """Get false positive items via RPC (avoids VIEW timeout).
+
+ Returns:
+ Tuple of (items list, total count)
+ """
+ from core.supabase_client import get_supabase_client
+
+ try:
+ client = get_supabase_client()
+ result = client.rpc("get_nudge_export_data", {
+ "p_campaign_id": campaign_id,
+ "p_in_house_only": in_house_only,
+ "p_llm_verified_only": True,
+ "p_llm_is_negative": False,
+ "p_limit": 50000,
+ }).execute()
+ data = result.data or {}
+ rows = data.get("rows", []) if isinstance(data, dict) else []
+ total = data.get("total", len(rows)) if isinstance(data, dict) else len(rows)
+
+ # Sort by llm_verified_at DESC (RPC sorts by analyzed_at)
+ rows.sort(key=lambda r: r.get("llm_verified_at") or "", reverse=True)
+
+ # Client-side pagination
+ offset = (page - 1) * page_size
+ return rows[offset:offset + page_size], total
+ except Exception as e:
+ logger.warning("get_false_positives failed for campaign %s: %s", campaign_id, e)
+ return [], 0
+
+
+@st.cache_data(ttl=60)
+def get_true_negatives(
+ campaign_id: int,
+ page: int = 1,
+ page_size: int = 50,
+ in_house_only: bool = True,
+) -> tuple[list[dict], int]:
+ """Get true negative items via RPC (avoids VIEW timeout).
+
+ Returns:
+ Tuple of (items list, total count)
+ """
+ from core.supabase_client import get_supabase_client
+
+ try:
+ client = get_supabase_client()
+ result = client.rpc("get_nudge_export_data", {
+ "p_campaign_id": campaign_id,
+ "p_in_house_only": in_house_only,
+ "p_llm_verified_only": True,
+ "p_llm_is_negative": True,
+ "p_limit": 50000,
+ }).execute()
+ data = result.data or {}
+ rows = data.get("rows", []) if isinstance(data, dict) else []
+ total = data.get("total", len(rows)) if isinstance(data, dict) else len(rows)
+
+ # Sort by llm_verified_at DESC (RPC sorts by analyzed_at)
+ rows.sort(key=lambda r: r.get("llm_verified_at") or "", reverse=True)
+
+ # Client-side pagination
+ offset = (page - 1) * page_size
+ return rows[offset:offset + page_size], total
+ except Exception as e:
+ logger.warning("get_true_negatives failed for campaign %s: %s", campaign_id, e)
+ return [], 0
+
+
+def get_llm_verified_for_export(
+ campaign_id: int,
+ is_negative: bool,
+ in_house_only: bool = True,
+ include_full_answers: bool = False,
+) -> list[dict]:
+ """Get all LLM verified items for export via single RPC (avoids VIEW timeout).
+
+ Returns:
+ List of items with optional full answer text
+ """
+ from core.supabase_client import get_supabase_client
+
+ try:
+ client = get_supabase_client()
+ result = client.rpc("get_nudge_export_data", {
+ "p_campaign_id": campaign_id,
+ "p_in_house_only": in_house_only,
+ "p_llm_verified_only": True,
+ "p_llm_is_negative": is_negative,
+ "p_limit": 50000,
+ }).execute()
+ data = result.data or {}
+ items = data.get("rows", []) if isinstance(data, dict) else []
+
+ # Fetch full answers from Athena if requested
+ if include_full_answers and items:
+ from .athena_client import fetch_full_answers_batch
+ answer_ids = [item["answer_id"] for item in items if item.get("answer_id")]
+ if answer_ids:
+ full_answers = fetch_full_answers_batch(answer_ids)
+ for item in items:
+ aid = item.get("answer_id")
+ if aid and aid in full_answers:
+ item["answer_full"] = full_answers[aid]
+ else:
+ item["answer_full"] = item.get("answer_preview", "")
+
+ return items
+ except Exception as e:
+ logger.warning("get_llm_verified_for_export failed for campaign %s: %s", campaign_id, e)
+ return []
+
+
+def get_sentiment_data_for_export(
+ campaign_id: int,
+ polarity: str | None = None,
+ llm_status: str | None = None,
+ in_house_only: bool = True,
+ include_full_answers: bool = False,
+ include_evidence: bool = False,
+) -> list[dict]:
+ """Export sentiment data via RPC (avoids VIEW timeout on large campaigns).
+
+ Uses get_nudge_export_data RPC with inline CTE โ WHERE campaign_id
+ is applied before DISTINCT ON, enabling index scan instead of full
+ VIEW materialization.
+
+ Args:
+ campaign_id: Campaign ID
+ polarity: Polarity filter ('negative', 'positive', 'neutral', None=all)
+ llm_status: LLM status filter ('verified', 'false_positive', 'true_negative', 'unverified', None=all)
+ in_house_only: In-house brand filter
+ include_full_answers: Include full answer text from Athena
+ include_evidence: Include LLM evidence fields
+
+ Returns:
+ Filtered data list
+ """
+ from core.supabase_client import get_supabase_client
+
+ try:
+ client = get_supabase_client()
+
+ # Map llm_status to RPC parameters
+ llm_verified_only = llm_status in ("verified", "false_positive", "true_negative")
+ llm_is_negative = None
+ if llm_status == "true_negative":
+ llm_is_negative = True
+ elif llm_status == "false_positive":
+ llm_is_negative = False
+
+ params = {
+ "p_campaign_id": campaign_id,
+ "p_in_house_only": in_house_only,
+ "p_llm_verified_only": llm_verified_only,
+ "p_llm_is_negative": llm_is_negative,
+ "p_limit": 50000,
+ }
+
+ result = client.rpc("get_nudge_export_data", params).execute()
+ data = result.data or {}
+ if isinstance(data, dict):
+ items = data.get("rows", []) or []
+ else:
+ items = data if isinstance(data, list) else []
+
+ # Apply polarity filter (kept client-side for simplicity)
+ if polarity:
+ items = [item for item in items if item.get("overall_polarity") == polarity]
+
+ # Apply unverified filter (RPC only supports verified=True)
+ if llm_status == "unverified":
+ items = [item for item in items if not item.get("llm_verified")]
+
+ if include_full_answers and items:
+ from .athena_client import fetch_full_answers_batch
+ answer_ids = [item["answer_id"] for item in items if item.get("answer_id")]
+ if answer_ids:
+ full_answers = fetch_full_answers_batch(answer_ids)
+ for item in items:
+ aid = item.get("answer_id")
+ if aid and aid in full_answers:
+ item["answer_full"] = full_answers[aid]
+ else:
+ item["answer_full"] = item.get("answer_preview", "")
+
+ return items
+ except Exception as e:
+ logger.warning("get_sentiment_data_for_export failed for campaign %s: %s", campaign_id, e)
+ return []
+
+
+@st.cache_data(ttl=60)
+def get_answers_by_polarity(
+ campaign_id: int,
+ polarity: str,
+ page: int = 1,
+ page_size: int = 50,
+ in_house_only: bool = False,
+) -> tuple[list[dict], int]:
+ """Get answers filtered by polarity via RPC (avoids VIEW timeout).
+
+ Returns:
+ Tuple of (items list, total count)
+ """
+ from core.supabase_client import get_supabase_client
+
+ try:
+ client = get_supabase_client()
+ offset = (page - 1) * page_size
+
+ params: dict = {
+ "p_campaign_id": campaign_id,
+ "p_in_house_only": False,
+ "p_polarity": polarity,
+ "p_offset": offset,
+ "p_limit": page_size,
+ }
+ if in_house_only:
+ params["p_has_in_house_brands"] = True
+
+ result = client.rpc("get_nudge_export_data", params).execute()
+ data = result.data or {}
+ rows = data.get("rows", []) if isinstance(data, dict) else []
+ total = data.get("total", len(rows)) if isinstance(data, dict) else len(rows)
+
+ return rows, total
+ except Exception as e:
+ logger.warning("get_answers_by_polarity failed for campaign %s: %s", campaign_id, e)
+ return [], 0
+
+
+def get_polarity_stats(campaign_id: int, in_house_only: bool = False) -> dict:
+ """Get polarity distribution via single RPC (avoids VIEW timeout).
+
+ Uses get_campaign_sentiment_stats RPC โ one table scan with COUNT FILTER.
+
+ Returns:
+ Dict with positive, neutral, negative counts
+ """
+ from core.supabase_client import get_supabase_client
+
+ _empty = {"positive": 0, "neutral": 0, "negative": 0}
+ try:
+ client = get_supabase_client()
+ result = client.rpc(
+ "get_campaign_sentiment_stats", {"p_campaign_id": campaign_id}
+ ).execute()
+ stats = result.data or {}
+ if isinstance(stats, list) and stats:
+ stats = stats[0]
+ if isinstance(stats, str):
+ import json
+ stats = json.loads(stats)
+ if not isinstance(stats, dict):
+ return _empty
+
+ if in_house_only:
+ return {
+ "positive": stats.get("in_house_positive", 0),
+ "neutral": stats.get("in_house_neutral", 0),
+ "negative": stats.get("in_house_negative_polarity", 0),
+ }
+ return {
+ "positive": stats.get("positive", 0),
+ "neutral": stats.get("neutral", 0),
+ "negative": stats.get("negative", 0),
+ }
+ except Exception:
+ return _empty
diff --git a/core/utils.py b/core/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..6a43af315b972f2b75ea4f03d35607c0c4926b1f
--- /dev/null
+++ b/core/utils.py
@@ -0,0 +1,143 @@
+"""Utility functions for Gen3 Nudge Detection Dashboard."""
+
+# Confidence tier thresholds
+CONFIDENCE_HIGH_THRESHOLD = 0.85
+CONFIDENCE_MEDIUM_THRESHOLD = 0.70
+
+
+def get_confidence_tier(confidence: float | None) -> tuple[str, str, str]:
+ """Get confidence tier info for nudge candidates.
+
+ Returns:
+ Tuple of (tier, emoji, description)
+ """
+ if confidence is None:
+ return "LOW", "๐ข", "์คํ ๊ฐ๋ฅ์ฑ"
+ if confidence >= CONFIDENCE_HIGH_THRESHOLD:
+ return "HIGH", "๐ด", "ํ์คํ ๋์ง ๋์"
+ elif confidence >= CONFIDENCE_MEDIUM_THRESHOLD:
+ return "MEDIUM", "๐ก", "๊ฒํ ํ์"
+ return "LOW", "๐ข", "์คํ ๊ฐ๋ฅ์ฑ"
+
+
+def truncate_text(text: str | None, max_length: int, suffix: str = "...") -> str:
+ """Truncate text to max_length with suffix."""
+ if not text:
+ return "N/A"
+ if len(text) <= max_length:
+ return text
+ return text[:max_length] + suffix
+
+
+def format_brands_list(brands: list[str] | None) -> str:
+ """Format list of brands for display."""
+ if not brands:
+ return "N/A"
+ return ", ".join(brands)
+
+
+def highlight_evidence_spans(text: str, evidence_spans: list[dict] | None) -> str:
+ """Highlight evidence spans in text using HTML.
+
+ Args:
+ text: Full answer text
+ evidence_spans: List of evidence span dicts with text, start, end, type
+
+ Returns:
+ HTML string with highlighted spans
+ """
+ if not evidence_spans or not text:
+ return text or ""
+
+ # Sort spans by start position (descending) to avoid index shifting
+ sorted_spans = sorted(
+ [s for s in evidence_spans if s.get("text")],
+ key=lambda s: s.get("start", 0) if s.get("start") is not None else -1,
+ reverse=True,
+ )
+
+ result = text
+ for span in sorted_spans:
+ span_text = span.get("text", "")
+ span_type = span.get("type", "negative")
+ start = span.get("start")
+ end = span.get("end")
+
+ # Color by type
+ color_map = {
+ "negative": "#FF6B6B",
+ "positive": "#51CF66",
+ "neutral": "#748FFC",
+ "comparison": "#FAB005",
+ "hallucination": "#ADB5BD",
+ "category_general": "#9775FA",
+ }
+ color = color_map.get(span_type, "#ADB5BD")
+
+ mark_style = f'background-color: {color}; padding: 2px 4px; border-radius: 3px;'
+
+ mark_style = f'background-color: {color}; padding: 2px 4px; border-radius: 3px;'
+
+ if start is not None and end is not None and 0 <= start < end <= len(result):
+ # Use exact positions
+ before = result[:start]
+ highlighted = f'{result[start:end]}'
+ after = result[end:]
+ result = before + highlighted + after
+ elif span_text and span_text in result:
+ # Fallback: find text in result
+ highlighted = f'{span_text}'
+ result = result.replace(span_text, highlighted, 1)
+ elif span_text and "..." in span_text:
+ # Ellipsis fallback: LLM truncated the evidence with "..."
+ # Split into fragments and highlight each one found in the text
+ fragments = [f.strip() for f in span_text.split("...") if f.strip()]
+ for frag in fragments:
+ if frag in result:
+ highlighted = f'{frag}'
+ result = result.replace(frag, highlighted, 1)
+
+ return result
+
+
+def get_llm_tier_badge(adjusted_tier: str | None, is_negative: bool | None) -> tuple[str, str]:
+ """Get badge info for LLM verification result.
+
+ Returns:
+ Tuple of (badge_text, badge_color)
+ """
+ if adjusted_tier is None:
+ return "๋ฏธ๊ฒ์ฆ", "gray"
+
+ if adjusted_tier == "NONE" or is_negative is False:
+ return "โ
์คํ (False Positive)", "green"
+
+ tier_map = {
+ "HIGH": ("๐ด ๋ถ์ ํ์ธ (HIGH)", "red"),
+ "MEDIUM": ("๐ก ๋ถ์ ํ์ธ (MEDIUM)", "orange"),
+ "LOW": ("๐ข ๋ถ์ ํ์ธ (LOW)", "blue"),
+ }
+ return tier_map.get(adjusted_tier, ("ํ์ธ๋จ", "gray"))
+
+
+def get_feedback_reason_label(reason: str | None) -> str:
+ """Get human-readable label for feedback wrong_reason."""
+ reason_labels = {
+ "actually_positive": "์ค์ ๋ก๋ ๊ธ์ ์ ์ธ ๋ด์ฉ์
๋๋ค",
+ "actually_neutral": "์ค์ ๋ก๋ ์ค๋ฆฝ์ ์ธ ๋ด์ฉ์
๋๋ค",
+ "wrong_evidence": "๊ทผ๊ฑฐ ๋ฌธ์ฅ์ด ์๋ชป ์ถ์ถ๋์์ต๋๋ค",
+ "context_missing": "๋งฅ๋ฝ์ด ๋น ์ ธ์ ์คํด๊ฐ ์์ต๋๋ค",
+ "wrong_brand": "๋ธ๋๋๊ฐ ์๋ชป ์ธ์๋์์ต๋๋ค",
+ "other": "๊ธฐํ",
+ }
+ return reason_labels.get(reason, reason or "")
+
+
+def get_feedback_type_emoji(feedback_type: str | None) -> str:
+ """Get emoji for feedback type."""
+ emoji_map = {
+ "correct": "๐",
+ "wrong": "๐",
+ "ambiguous": "๐ค",
+ }
+ return emoji_map.get(feedback_type, "")
diff --git a/features/__init__.py b/features/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..aac1bddba6a651afdc4f6d72679696ef91163fd0
--- /dev/null
+++ b/features/__init__.py
@@ -0,0 +1,4 @@
+"""Feature plugins package.
+
+๊ฐ ํ์ ๋๋ ํ ๋ฆฌ์ FEATURE_CONFIG + render(base_ctx)๊ฐ ์์ผ๋ฉด ์๋ ๋ฑ๋ก๋จ.
+"""
diff --git a/features/action_items/__init__.py b/features/action_items/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..ec2031e6abc46cd76f22ed54e7cd281b436b81ac
--- /dev/null
+++ b/features/action_items/__init__.py
@@ -0,0 +1,24 @@
+"""์ก์
์์ดํ
Feature Plugin.
+
+API: /api/v1/action-items
+ADR-018 Phase 2: DB ์ํ๊ด๋ฆฌ
+๋
๋ฆฝ ํธ๋ฆฌ๊ฑฐ ๋ถ์: ๋ฆฌํฌํธ ์์ด ์ง์ ํธ๋ฆฌ๊ฑฐ ํ๊ฐ โ ์ ์ฅ
+"""
+import streamlit as st
+
+from . import overview
+
+FEATURE_CONFIG = {
+ "key": "action_items",
+ "name": "์ก์
์์ดํ
",
+ "icon": "โ
",
+ "description": "ํธ๋ฆฌ๊ฑฐ ๋ถ์ + ์ก์
์์ดํ
๊ด๋ฆฌ (์ํ ์ถ์ , ๋ด๋น์ ๋ฐฐ์ )",
+ "api_base": "/api/v1/action-items",
+ "order": 4,
+}
+
+
+def render(base_ctx):
+ """์ก์
์์ดํ
feature ๋ ๋๋ง."""
+ st.caption("14๊ฐ ํธ๋ฆฌ๊ฑฐ ๊ท์น์ผ๋ก ์ ํธ๋ฅผ ํ์งํ๊ณ ์ก์
์์ดํ
์ผ๋ก ๊ด๋ฆฌํฉ๋๋ค")
+ overview.render(base_ctx)
diff --git a/features/action_items/analysis.py b/features/action_items/analysis.py
new file mode 100644
index 0000000000000000000000000000000000000000..db6c676a454fd1517868da35d401aece5379ef41
--- /dev/null
+++ b/features/action_items/analysis.py
@@ -0,0 +1,131 @@
+"""Trigger analysis section โ date picker, run, preview, save.
+
+Separated from overview.py so that CRUD operations work even when
+trigger_rules module is unavailable (e.g., standalone HuggingFace Space).
+"""
+from datetime import date, timedelta
+
+import streamlit as st
+
+from core.supabase_client import (
+ get_campaign_date_range,
+ save_action_items_batch,
+)
+from .triggers import TRIGGERS_AVAILABLE
+from .utils import priority_level, CATEGORY_CONFIG
+
+
+def render_analysis_section(campaign_id: int, base_ctx: dict):
+ """Render trigger analysis: date picker, run button, preview, save."""
+ with st.expander("๐ ํธ๋ฆฌ๊ฑฐ ๋ถ์ ์คํ", expanded=True):
+ if not TRIGGERS_AVAILABLE:
+ st.info(
+ "ํธ๋ฆฌ๊ฑฐ ๋ถ์ ๊ธฐ๋ฅ์ ์ฌ์ฉํ ์ ์์ต๋๋ค. "
+ "์ ์ฒด ํ๋ก์ ํธ ํ๊ฒฝ์์๋ง ์ง์๋ฉ๋๋ค."
+ )
+ return
+
+ # Date range
+ date_range = get_campaign_date_range(campaign_id)
+ if not date_range:
+ st.info("์บ ํ์ธ ๋ฐ์ดํฐ๊ฐ ์์ง ๋๊ธฐํ๋์ง ์์์ต๋๋ค.")
+ return
+
+ _, last_date_str = date_range
+ last_date = date.fromisoformat(last_date_str)
+ default_start = last_date - timedelta(days=6)
+
+ date_cols = st.columns(2)
+ with date_cols[0]:
+ start_date = st.date_input(
+ "์์์ผ",
+ value=default_start,
+ key="ai_trigger_start",
+ )
+ with date_cols[1]:
+ end_date = st.date_input(
+ "์ข
๋ฃ์ผ",
+ value=last_date,
+ key="ai_trigger_end",
+ )
+
+ if st.button("๐ ๋ถ์ ์คํ", key="ai_run_triggers", type="primary", use_container_width=True):
+ with st.spinner("14๊ฐ ํธ๋ฆฌ๊ฑฐ ๊ท์น์ ํ๊ฐ ์ค..."):
+ try:
+ from .triggers import evaluate_triggers
+
+ results = evaluate_triggers(
+ campaign_id,
+ start_date.isoformat(),
+ end_date.isoformat(),
+ )
+ st.session_state["ai_trigger_results"] = results
+ except Exception as e:
+ st.error(f"๋ถ์ ์ค๋ฅ: {e}")
+ return
+
+ # Show results if available
+ results = st.session_state.get("ai_trigger_results")
+ if results is None:
+ return
+
+ if not results:
+ st.success("๋ชจ๋ ์งํ๊ฐ ์ ์ ๋ฒ์์
๋๋ค. ํ์ง๋ ์ ํธ๊ฐ ์์ต๋๋ค.")
+ return
+
+ st.markdown(f"**{len(results)}๊ฐ ์ ํธ ํ์ง๋จ**")
+
+ # Preview cards
+ for item in results:
+ p_label, p_color, p_bg = priority_level(item["priority"])
+ cat_cfg = CATEGORY_CONFIG.get(item["category"], {"color": "#666", "icon": "๐"})
+ st.markdown(f"""
+
+
+ {p_label}
+ {cat_cfg['icon']} {item['category']}
+
+
{item['label']}
+
{item.get('evidence', '')}
+
+ """, unsafe_allow_html=True)
+
+ # Save button
+ if st.button("๐พ ์ก์
์์ดํ
์ผ๋ก ์ ์ฅ", key="ai_save_triggers", use_container_width=True):
+ _save_trigger_results(campaign_id, results, base_ctx)
+
+
+def _save_trigger_results(campaign_id: int, results: list[dict], base_ctx: dict):
+ """Save trigger analysis results as action items."""
+ try:
+ from core.supabase_client import get_supabase_client
+
+ user_client = get_supabase_client()
+ key_result = (
+ user_client.table("api_keys")
+ .select("user_id")
+ .limit(1)
+ .execute()
+ )
+ user_id = str(key_result.data[0]["user_id"]) if key_result.data else "unknown"
+
+ result = save_action_items_batch(
+ campaign_id=campaign_id,
+ user_id=user_id,
+ items=results,
+ )
+
+ created = result.get("created", 0)
+ skipped = result.get("skipped", 0)
+ if result.get("error"):
+ st.error(f"์ ์ฅ ์ค๋ฅ: {result['error']}")
+ elif created > 0:
+ st.success(f"โ
{created}๊ฐ ์ ์ฅ ์๋ฃ (์ค๋ณต {skipped}๊ฐ ์คํต)")
+ st.session_state.pop("ai_trigger_results", None)
+ st.rerun()
+ else:
+ st.info(f"๋ชจ๋ ํญ๋ชฉ์ด ์ด๋ฏธ ์กด์ฌํฉ๋๋ค ({skipped}๊ฐ ์คํต)")
+ except Exception as e:
+ st.error(f"์ ์ฅ ์คํจ: {e}")
diff --git a/features/action_items/overview.py b/features/action_items/overview.py
new file mode 100644
index 0000000000000000000000000000000000000000..5cf9edf90e6bc10f8b61d7996c6ca1754ed83a78
--- /dev/null
+++ b/features/action_items/overview.py
@@ -0,0 +1,339 @@
+"""Action Items overview โ card-based UI with stats, filters, and inline editing."""
+import re
+from datetime import date, timedelta
+
+import streamlit as st
+
+from core.supabase_client import (
+ get_action_items,
+ get_action_item_stats,
+ update_action_item_status,
+ delete_action_item,
+ get_campaign_date_range,
+)
+from .analysis import render_analysis_section
+from .trend_charts import (
+ render_visibility_trend,
+ render_citation_type_trend,
+ render_negative_rate_trend,
+ render_action_items_history,
+)
+from .utils import (
+ STATUS_CONFIG,
+ STATUS_OPTIONS,
+ STATUS_LABELS,
+ CATEGORY_CONFIG,
+ priority_level,
+)
+
+
+def _render_stats(stats: dict):
+ """Render stats summary bar."""
+ total = stats.get("total", 0)
+ pending = stats.get("pending", 0)
+ in_progress = stats.get("in_progress", 0)
+ completed = stats.get("completed", 0)
+
+ if total == 0:
+ return
+
+ cols = st.columns(4)
+ items = [
+ ("pending", "๋๊ธฐ", pending),
+ ("in_progress", "์งํ ์ค", in_progress),
+ ("completed", "์๋ฃ", completed),
+ ("archived", "๋ณด๊ด", stats.get("archived", 0)),
+ ]
+ for col, (key, label, count) in zip(cols, items):
+ cfg = STATUS_CONFIG[key]
+ col.markdown(f"""
+
+
{count}
+
{cfg['emoji']} {label}
+
+ """, unsafe_allow_html=True)
+
+ # Progress bar
+ if total > 0:
+ done_pct = completed / total * 100
+ active_pct = in_progress / total * 100
+ st.markdown(f"""
+
+
+
+ ์๋ฃ {done_pct:.0f}%
+ ์ ์ฒด {total}๊ฑด
+
+
+ """, unsafe_allow_html=True)
+
+
+def _render_empty_state():
+ """Render friendly empty state."""
+ st.markdown("""
+
+
๐
+
+ ์ก์
์์ดํ
์ด ์์ต๋๋ค
+
+
+ ์์ ๋ถ์ ์คํ ๋ฒํผ์ผ๋ก ํธ๋ฆฌ๊ฑฐ๋ฅผ ํ๊ฐํ๊ณ ์ ์ฅํด๋ณด์ธ์
+
+
+ """, unsafe_allow_html=True)
+
+
+def _render_card(item: dict, idx: int, base_ctx: dict | None = None):
+ """Render a single action item card."""
+ item_id = item["id"]
+ current_status = item.get("status", "pending")
+ priority = item.get("priority", 50)
+ category = item.get("category", "")
+ label = item.get("label", "")
+ evidence = item.get("evidence") or ""
+ llm_rec = item.get("llm_recommendation") or ""
+ created = (item.get("created_at") or "")[:10]
+
+ p_label, p_color, p_bg = priority_level(priority)
+ cat_cfg = CATEGORY_CONFIG.get(category, {"color": "#666", "icon": "๐"})
+
+ # Card header HTML
+ st.markdown(f"""
+
+
+
+ {p_label} {priority}
+ {cat_cfg['icon']} {category}
+
+
{created}
+
+
+ {label}
+
+ {"
" + evidence[:200] + ("..." if len(evidence) > 200 else "") + "
" if evidence else ""}
+ {"
๐ก " + llm_rec + "
" if llm_rec else ""}
+
+ """, unsafe_allow_html=True)
+
+ # Interactive controls (Streamlit widgets, can't be inside HTML)
+ ctrl_cols = st.columns([2, 1, 1, 1])
+ with ctrl_cols[0]:
+ new_status = st.selectbox(
+ "์ํ",
+ options=STATUS_OPTIONS,
+ index=STATUS_OPTIONS.index(current_status),
+ format_func=lambda s: STATUS_LABELS.get(s, s),
+ key=f"ai_st_{item_id}",
+ label_visibility="collapsed",
+ )
+ if new_status != current_status:
+ if update_action_item_status(item_id, new_status):
+ st.rerun()
+
+ with ctrl_cols[1]:
+ assignee = item.get("assignee_email") or ""
+ if assignee:
+ st.caption(f"๐ค {assignee.split('@')[0]}")
+
+ with ctrl_cols[2]:
+ export_key = f"ai_export_html_{item_id}"
+ cached = st.session_state.get(export_key)
+ if cached:
+ st.download_button(
+ "๐ฅ ๋ค์ด๋ก๋",
+ data=b'\xef\xbb\xbf' + cached["content"].encode("utf-8"),
+ file_name=cached["file_name"],
+ mime="text/html; charset=utf-8",
+ key=f"ai_dl_{item_id}",
+ use_container_width=True,
+ )
+ else:
+ if st.button("๐ HTML", key=f"ai_export_{item_id}", type="secondary"):
+ with st.spinner("HTML ์์ฑ ์ค..."):
+ try:
+ from core.api_client import ChainShiftClient
+ ctx = base_ctx or {}
+ client = ChainShiftClient(
+ api_key=ctx.get("api_key"),
+ access_token=ctx.get("access_token"),
+ )
+ resp = client.export_action_item_html(item_id)
+ data = resp.get("data", {})
+ html_content = data.get("html_content", "") if isinstance(data, dict) else ""
+ if html_content:
+ safe_label = re.sub(r'[^\w\-]', '_', (label or "action_item")[:30])
+ st.session_state[export_key] = {
+ "content": html_content,
+ "file_name": f"{safe_label}_{item_id[:8]}.html",
+ }
+ st.rerun()
+ else:
+ st.error("HTML ์์ฑ ๊ฒฐ๊ณผ๊ฐ ๋น์ด์์ต๋๋ค.")
+ except Exception as e:
+ st.error(f"HTML ์์ฑ ์คํจ: {e}")
+
+ with ctrl_cols[3]:
+ if st.button("๐๏ธ ์ญ์ ", key=f"ai_del_{item_id}", type="secondary"):
+ if delete_action_item(item_id):
+ st.rerun()
+
+
+def _render_trend_section(campaign_id: int):
+ """Render trigger metric trend charts in an expander."""
+ with st.expander("๐ ์งํ ์ถ์ด", expanded=False):
+ # Reuse existing date range from session state
+ start_date = st.session_state.get("ai_trigger_start")
+ end_date = st.session_state.get("ai_trigger_end")
+
+ if not start_date or not end_date:
+ date_range = get_campaign_date_range(campaign_id)
+ if not date_range:
+ st.info("์บ ํ์ธ ๋ฐ์ดํฐ๊ฐ ์์ง ๋๊ธฐํ๋์ง ์์์ต๋๋ค.")
+ return
+ _, last_date_str = date_range
+ last_date = date.fromisoformat(last_date_str)
+ end_date = last_date
+ start_date = last_date - timedelta(days=13)
+
+ s = str(start_date)
+ e = str(end_date)
+
+ col1, col2 = st.columns(2)
+ with col1:
+ render_visibility_trend(campaign_id, s, e)
+ with col2:
+ render_citation_type_trend(campaign_id, s, e)
+
+ col3, col4 = st.columns(2)
+ with col3:
+ render_negative_rate_trend(campaign_id, s, e)
+ with col4:
+ render_action_items_history(campaign_id)
+
+
+PAGE_SIZE = 10
+
+
+def render(base_ctx):
+ """Render action items overview with card-based UI."""
+ campaign_id = base_ctx.get("campaign_id")
+ if not campaign_id:
+ st.warning("์บ ํ์ธ์ ์ ํํด์ฃผ์ธ์.")
+ return
+
+ # โโ Stats โโ
+ stats = get_action_item_stats(campaign_id)
+ _render_stats(stats)
+
+ st.markdown("") # spacer
+
+ # โโ Trigger Analysis โโ
+ render_analysis_section(campaign_id, base_ctx)
+
+ # โโ Trend Charts โโ
+ _render_trend_section(campaign_id)
+
+ st.markdown("---")
+
+ # โโ Filters โโ
+ filter_cols = st.columns([1, 1, 1])
+ with filter_cols[0]:
+ status_filter = st.selectbox(
+ "์ํ ํํฐ",
+ options=["์ ์ฒด"] + [STATUS_LABELS[s] for s in STATUS_OPTIONS],
+ index=0,
+ key="ai_status_filter",
+ )
+ with filter_cols[1]:
+ cat_options = ["์ ์ฒด"] + list(CATEGORY_CONFIG.keys())
+ category_filter = st.selectbox(
+ "์นดํ
๊ณ ๋ฆฌ ํํฐ",
+ options=cat_options,
+ index=0,
+ key="ai_category_filter",
+ )
+ with filter_cols[2]:
+ SORT_OPTIONS = {
+ "์ต์ ์": ("created_at", True),
+ "์ค๋๋์": ("created_at", False),
+ "์ฐ์ ์์ ๋์์": ("priority", True),
+ "์ฐ์ ์์ ๋ฎ์์": ("priority", False),
+ }
+ sort_choice = st.selectbox(
+ "์ ๋ ฌ",
+ options=list(SORT_OPTIONS.keys()),
+ index=0,
+ key="ai_sort",
+ )
+ sort_col, sort_desc = SORT_OPTIONS[sort_choice]
+
+ # Resolve filter values
+ selected_status = None
+ if status_filter != "์ ์ฒด":
+ for k, v in STATUS_LABELS.items():
+ if v == status_filter:
+ selected_status = k
+ break
+
+ selected_category = None if category_filter == "์ ์ฒด" else category_filter
+
+ # โโ Pagination state โโ
+ page_key = "ai_page"
+ if page_key not in st.session_state:
+ st.session_state[page_key] = 1
+ current_page = st.session_state[page_key]
+
+ # โโ Fetch items โโ
+ items, total = get_action_items(
+ campaign_id,
+ status=selected_status,
+ category=selected_category,
+ page=current_page,
+ page_size=PAGE_SIZE,
+ order_by=sort_col,
+ desc=sort_desc,
+ )
+
+ if not items and current_page == 1:
+ _render_empty_state()
+ return
+
+ total_pages = max(1, -(-total // PAGE_SIZE)) # ceil division
+ st.caption(f"์ด **{total}**๊ฑด ยท ํ์ด์ง {current_page}/{total_pages}")
+
+ # โโ Card grid (2 columns) โโ
+ for i in range(0, len(items), 2):
+ cols = st.columns(2)
+ for col_idx, col in enumerate(cols):
+ item_idx = i + col_idx
+ if item_idx < len(items):
+ with col:
+ _render_card(items[item_idx], item_idx, base_ctx)
+
+ # โโ Pagination controls โโ
+ if total_pages > 1:
+ st.markdown("")
+ nav_cols = st.columns([1, 2, 1])
+ with nav_cols[0]:
+ if current_page > 1:
+ if st.button("โ ์ด์ ", key="ai_prev", use_container_width=True):
+ st.session_state[page_key] = current_page - 1
+ st.rerun()
+ with nav_cols[1]:
+ st.markdown(
+ f""
+ f"{current_page} / {total_pages}
",
+ unsafe_allow_html=True,
+ )
+ with nav_cols[2]:
+ if current_page < total_pages:
+ if st.button("๋ค์ โ", key="ai_next", use_container_width=True):
+ st.session_state[page_key] = current_page + 1
+ st.rerun()
diff --git a/features/action_items/trend_charts.py b/features/action_items/trend_charts.py
new file mode 100644
index 0000000000000000000000000000000000000000..ed2c16ce79df222f1cb24235878f54bbc7efc798
--- /dev/null
+++ b/features/action_items/trend_charts.py
@@ -0,0 +1,329 @@
+"""Trigger metric trend charts โ Plotly mini charts for action items dashboard."""
+from __future__ import annotations
+
+from collections import defaultdict
+from datetime import date, timedelta
+
+import plotly.graph_objects as go
+import streamlit as st
+
+CHART_HEIGHT = 260
+MINI_LAYOUT = dict(
+ margin=dict(t=30, b=40, l=50, r=20),
+ height=CHART_HEIGHT,
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
+ xaxis=dict(tickformat="%m/%d"),
+)
+
+
+def _get_client():
+ from core.supabase_client import get_supabase_client
+ return get_supabase_client()
+
+
+@st.cache_data(ttl=120)
+def _fetch_visibility_data(campaign_id: int, start_date: str, end_date: str) -> list[dict]:
+ """Cached fetch for visibility trend data."""
+ client = _get_client()
+ try:
+ result = (
+ client.table("report_visibility_daily")
+ .select("task_date, brand_name, brand_type, visibility_pct")
+ .eq("campaign_id", campaign_id)
+ .gte("task_date", start_date)
+ .lte("task_date", end_date)
+ .order("task_date")
+ .execute()
+ )
+ return result.data or []
+ except Exception:
+ return []
+
+
+@st.cache_data(ttl=120)
+def _fetch_citation_type_data(campaign_id: int, start_date: str, end_date: str) -> list[dict]:
+ """Cached fetch for citation type trend data."""
+ client = _get_client()
+ try:
+ result = (
+ client.table("report_source_daily")
+ .select("task_date, source_host_type, citation_count")
+ .eq("campaign_id", campaign_id)
+ .eq("agg_level", "host")
+ .gte("task_date", start_date)
+ .lte("task_date", end_date)
+ .order("task_date")
+ .execute()
+ )
+ return result.data or []
+ except Exception:
+ return []
+
+
+@st.cache_data(ttl=120)
+def _fetch_negative_rate_data(campaign_id: int, start_date: str, end_date: str) -> list[dict]:
+ """Cached fetch for negative sentiment rate data."""
+ client = _get_client()
+ try:
+ result = client.rpc("get_nudge_export_data", {
+ "p_campaign_id": campaign_id,
+ "p_in_house_only": False,
+ "p_date_from": f"{start_date}T00:00:00+00:00",
+ "p_date_to": f"{_next_day(end_date)}T00:00:00+00:00",
+ "p_limit": 10000,
+ }).execute()
+ data = result.data or {}
+ return data.get("rows", []) if isinstance(data, dict) else []
+ except Exception:
+ return []
+
+
+@st.cache_data(ttl=120)
+def _fetch_action_items_history(campaign_id: int) -> list[dict]:
+ """Cached fetch for action items history data."""
+ client = _get_client()
+ try:
+ result = (
+ client.table("action_items")
+ .select("created_at, completed_at, status")
+ .eq("campaign_id", campaign_id)
+ .order("created_at")
+ .limit(5000)
+ .execute()
+ )
+ return result.data or []
+ except Exception:
+ return []
+
+
+# ============================================================================
+# Chart 1: Visibility trend (own brand vs competitor average)
+# ============================================================================
+
+def render_visibility_trend(campaign_id: int, start_date: str, end_date: str):
+ """Show daily own-brand vs competitor average visibility."""
+ rows = _fetch_visibility_data(campaign_id, start_date, end_date)
+
+ if not rows:
+ st.info("๊ฐ์์ฑ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.")
+ return
+
+ # Group by date: own avg, competitor avg
+ own_by_date: dict[str, list[float]] = defaultdict(list)
+ comp_by_date: dict[str, list[float]] = defaultdict(list)
+
+ for r in rows:
+ d = r["task_date"]
+ pct = r.get("visibility_pct", 0)
+ if r.get("brand_type") == "PRIMARY":
+ own_by_date[d].append(pct)
+ else:
+ comp_by_date[d].append(pct)
+
+ dates = sorted(set(list(own_by_date.keys()) + list(comp_by_date.keys())))
+ own_avgs = [
+ sum(own_by_date[d]) / len(own_by_date[d]) if own_by_date.get(d) else None
+ for d in dates
+ ]
+ comp_avgs = [
+ sum(comp_by_date[d]) / len(comp_by_date[d]) if comp_by_date.get(d) else None
+ for d in dates
+ ]
+
+ fig = go.Figure()
+ fig.add_trace(go.Scatter(
+ x=dates, y=own_avgs,
+ mode="lines+markers", name="์์ฌ",
+ line=dict(color="#3B82F6", width=2),
+ marker=dict(size=5),
+ ))
+ fig.add_trace(go.Scatter(
+ x=dates, y=comp_avgs,
+ mode="lines+markers", name="๊ฒฝ์์ฌ ํ๊ท ",
+ line=dict(color="#EF4444", width=2, dash="dash"),
+ marker=dict(size=5),
+ ))
+ fig.update_layout(
+ title=dict(text="์์ฌ vs ๊ฒฝ์์ฌ ๊ฐ์์ฑ", font=dict(size=13)),
+ yaxis_title="Visibility %",
+ **MINI_LAYOUT,
+ )
+ st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
+
+
+# ============================================================================
+# Chart 2: Citation type trend (OFFICIAL % + top channel concentration)
+# ============================================================================
+
+def render_citation_type_trend(campaign_id: int, start_date: str, end_date: str):
+ """Show daily OFFICIAL citation % and top channel concentration."""
+ rows = _fetch_citation_type_data(campaign_id, start_date, end_date)
+
+ if not rows:
+ st.info("์ธ์ฉ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.")
+ return
+
+ # Group by date
+ by_date: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
+ for r in rows:
+ d = r["task_date"]
+ ht = r.get("source_host_type") or "UNKNOWN"
+ by_date[d][ht] += r.get("citation_count", 0)
+
+ dates = sorted(by_date.keys())
+ official_pcts = []
+ top_channel_pcts = []
+
+ for d in dates:
+ type_counts = by_date[d]
+ total = sum(type_counts.values())
+ if total > 0:
+ official_pcts.append(round(type_counts.get("OFFICIAL", 0) / total * 100, 1))
+ max_count = max(type_counts.values())
+ top_channel_pcts.append(round(max_count / total * 100, 1))
+ else:
+ official_pcts.append(0)
+ top_channel_pcts.append(0)
+
+ fig = go.Figure()
+ fig.add_trace(go.Scatter(
+ x=dates, y=official_pcts,
+ mode="lines+markers", name="OFFICIAL %",
+ line=dict(color="#7C3AED", width=2),
+ marker=dict(size=5),
+ ))
+ fig.add_trace(go.Scatter(
+ x=dates, y=top_channel_pcts,
+ mode="lines+markers", name="Top ์ฑ๋ %",
+ line=dict(color="#F59E0B", width=2, dash="dot"),
+ marker=dict(size=5),
+ ))
+ # Threshold lines
+ fig.add_hline(y=10, line_dash="dash", line_color="#EF4444", opacity=0.5,
+ annotation_text="OFFICIAL 10%", annotation_position="bottom right")
+ fig.add_hline(y=50, line_dash="dash", line_color="#F97316", opacity=0.5,
+ annotation_text="์ง์ค 50%", annotation_position="top right")
+ fig.update_layout(
+ title=dict(text="์ธ์ฉ ์ ํ ์ถ์ด", font=dict(size=13)),
+ yaxis_title="%",
+ **MINI_LAYOUT,
+ )
+ st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
+
+
+# ============================================================================
+# Chart 3: Negative sentiment rate trend
+# ============================================================================
+
+def render_negative_rate_trend(campaign_id: int, start_date: str, end_date: str):
+ """Show daily in-house brand negative sentiment rate."""
+ rows = _fetch_negative_rate_data(campaign_id, start_date, end_date)
+
+ if not rows:
+ st.info("๊ฐ์ฑ ๋ถ์ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.")
+ return
+
+ if len(rows) >= 10000:
+ st.warning("๊ฐ์ฑ ๋ฐ์ดํฐ๊ฐ 10,000๊ฑด์ ์ด๊ณผํ์ฌ ์ผ๋ถ๋ง ํ์๋ฉ๋๋ค. ๊ธฐ๊ฐ์ ์ขํ๋ณด์ธ์.")
+
+ # Group by date
+ total_by_date: dict[str, int] = defaultdict(int)
+ neg_by_date: dict[str, int] = defaultdict(int)
+
+ for r in rows:
+ d = (r.get("analyzed_at") or "")[:10]
+ if not d:
+ continue
+ total_by_date[d] += 1
+ if r.get("overall_polarity") == "negative":
+ neg_by_date[d] += 1
+
+ dates = sorted(total_by_date.keys())
+ neg_rates = [
+ round(neg_by_date.get(d, 0) / total_by_date[d] * 100, 1) if total_by_date[d] > 0 else 0
+ for d in dates
+ ]
+
+ fig = go.Figure()
+ fig.add_trace(go.Scatter(
+ x=dates, y=neg_rates,
+ mode="lines+markers", name="๋ถ์ ๋น์จ",
+ line=dict(color="#EF4444", width=2),
+ marker=dict(size=5),
+ fill="tozeroy",
+ fillcolor="rgba(239,68,68,0.1)",
+ ))
+ fig.add_hline(y=30, line_dash="dash", line_color="#F97316", opacity=0.5,
+ annotation_text="๊ฒฝ๊ณ 30%", annotation_position="top right")
+ fig.update_layout(
+ title=dict(text="๋ถ์ ๊ฐ์ฑ ๋น์จ ์ถ์ด", font=dict(size=13)),
+ yaxis_title="๋ถ์ %",
+ **MINI_LAYOUT,
+ )
+ st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
+
+
+# ============================================================================
+# Chart 4: Action items history (created vs completed per week)
+# ============================================================================
+
+def render_action_items_history(campaign_id: int):
+ """Show weekly created vs completed action items."""
+ rows = _fetch_action_items_history(campaign_id)
+
+ if not rows:
+ st.info("์ก์
์์ดํ
์ด๋ ฅ์ด ์์ต๋๋ค.")
+ return
+
+ # Group by ISO week
+ created_by_week: dict[str, int] = defaultdict(int)
+ completed_by_week: dict[str, int] = defaultdict(int)
+
+ for r in rows:
+ c_date = (r.get("created_at") or "")[:10]
+ if c_date:
+ week = _iso_week_label(c_date)
+ created_by_week[week] += 1
+
+ if r.get("status") == "completed" and r.get("completed_at"):
+ d_date = r["completed_at"][:10]
+ week = _iso_week_label(d_date)
+ completed_by_week[week] += 1
+
+ weeks = sorted(set(list(created_by_week.keys()) + list(completed_by_week.keys())))
+ created_vals = [created_by_week.get(w, 0) for w in weeks]
+ completed_vals = [completed_by_week.get(w, 0) for w in weeks]
+
+ fig = go.Figure()
+ fig.add_trace(go.Bar(
+ x=weeks, y=created_vals, name="์์ฑ",
+ marker_color="#6366F1",
+ ))
+ fig.add_trace(go.Bar(
+ x=weeks, y=completed_vals, name="์๋ฃ",
+ marker_color="#10B981",
+ ))
+ fig.update_layout(
+ title=dict(text="์ฃผ๋ณ ์ก์
์์ดํ
์์ฑ/์๋ฃ", font=dict(size=13)),
+ barmode="group",
+ yaxis_title="๊ฑด์",
+ **MINI_LAYOUT,
+ )
+ st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
+
+
+# ============================================================================
+# Helpers
+# ============================================================================
+
+def _next_day(date_str: str) -> str:
+ """Return next day as ISO string (for half-open TIMESTAMPTZ filter)."""
+ d = date.fromisoformat(date_str)
+ return (d + timedelta(days=1)).isoformat()
+
+
+def _iso_week_label(date_str: str) -> str:
+ """Convert date string to 'MM/DD' label of the week's Monday."""
+ d = date.fromisoformat(date_str)
+ monday = d - timedelta(days=d.weekday())
+ return monday.strftime("%m/%d")
diff --git a/features/action_items/triggers.py b/features/action_items/triggers.py
new file mode 100644
index 0000000000000000000000000000000000000000..c3624a63a2358e0bc3ea2e072c022d0db442284e
--- /dev/null
+++ b/features/action_items/triggers.py
@@ -0,0 +1,158 @@
+"""Independent trigger evaluation module โ queries Supabase directly.
+
+Evaluates 14 trigger rules against cached report data:
+- Rules 1-2: report_visibility_daily (visibility gap, decline)
+- Rules 3, 5: report_source_daily (official citation, channel concentration)
+- Rule 4: answer_sentiment_latest VIEW (negative spike)
+- Rules 6-9: report_source_daily channel groups (editorial/ugc/reference/owned)
+- Rules 10-12: report_source_brand_daily (coverage gap, format gap, share gap)
+- Rules 13-14: answer_sentiment_latest VIEW question types (trust/risk, decision)
+
+No API dependency โ all data comes from Supabase tables that
+sync-worker syncs daily at 08:00 KST.
+"""
+from __future__ import annotations
+
+import logging
+import sys
+from datetime import date, timedelta
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+
+def _find_project_root() -> str | None:
+ """Find project root by locating scripts/shared/trigger_rules.py.
+
+ Traverses parent directories instead of hardcoding parents[N]
+ to handle varying deployment paths (local dev, HuggingFace Space, Docker).
+ """
+ current = Path(__file__).resolve().parent
+ for parent in current.parents:
+ if (parent / "scripts" / "shared" / "trigger_rules.py").exists():
+ return str(parent)
+ return None
+
+
+_project_root = _find_project_root()
+if _project_root and _project_root not in sys.path:
+ sys.path.insert(0, _project_root)
+
+# Graceful import: trigger rules may not be available in standalone
+# dashboard deployments (e.g., HuggingFace Space without full repo).
+_TRIGGERS_AVAILABLE = False
+try:
+ from scripts.shared.trigger_rules import ( # noqa: E402
+ TRIGGER_RULES,
+ CHANNEL_GROUPS,
+ _avg_visibility_by_brand,
+ _is_primary,
+ _rule_visibility_gap,
+ _rule_visibility_decline,
+ _rule_official_low_citation,
+ _rule_negative_spike,
+ _rule_channel_concentration,
+ _rule_channel_type_low,
+ _rule_gap_coverage,
+ _rule_gap_format,
+ _rule_gap_share,
+ _rule_question_trust_risk,
+ _rule_question_decision,
+ _fetch_visibility,
+ _fetch_source_types,
+ _fetch_brand_sentiments,
+ _fetch_source_content_types,
+ _fetch_source_brand_mix,
+ _fetch_question_type_stats,
+ )
+ _TRIGGERS_AVAILABLE = True
+except (ImportError, ModuleNotFoundError) as e:
+ logger.warning("trigger_rules not available (standalone dashboard?): %s", e)
+ TRIGGER_RULES = {}
+ CHANNEL_GROUPS = {}
+
+# Re-export all shared symbols so existing imports keep working
+__all__ = [
+ "TRIGGER_RULES",
+ "CHANNEL_GROUPS",
+ "TRIGGERS_AVAILABLE",
+ "evaluate_triggers",
+]
+
+TRIGGERS_AVAILABLE = _TRIGGERS_AVAILABLE
+
+
+def evaluate_triggers(
+ campaign_id: int,
+ start_date: str,
+ end_date: str,
+) -> list[dict]:
+ """Evaluate all trigger rules and return action items (max 14).
+
+ Args:
+ campaign_id: Campaign ID
+ start_date: Start date (YYYY-MM-DD)
+ end_date: End date (YYYY-MM-DD)
+
+ Returns:
+ List of action item dicts sorted by priority descending.
+
+ Raises:
+ RuntimeError: If trigger rules module is not available.
+ """
+ if not _TRIGGERS_AVAILABLE:
+ raise RuntimeError(
+ "ํธ๋ฆฌ๊ฑฐ ๋ถ์์ ์ฌ์ฉํ ์ ์์ต๋๋ค. "
+ "scripts/shared/trigger_rules.py๊ฐ ํ์ํฉ๋๋ค."
+ )
+
+ from core.supabase_client import get_supabase_client
+
+ client = get_supabase_client()
+ items: list[dict] = []
+
+ # โโ Data collection โโ
+ visibility = _fetch_visibility(client, campaign_id, start_date, end_date)
+ source_types = _fetch_source_types(client, campaign_id, start_date, end_date)
+
+ # โโ Rule 1: visibility_gap โ own brand < competitor average โโ
+ items.extend(_rule_visibility_gap(visibility))
+
+ # โโ Rule 2: visibility_decline โ delta <= -3pp vs prior period โโ
+ period_days = (date.fromisoformat(end_date) - date.fromisoformat(start_date)).days + 1
+ prior_end = date.fromisoformat(start_date) - timedelta(days=1)
+ prior_start = prior_end - timedelta(days=period_days - 1)
+ prior_visibility = _fetch_visibility(
+ client, campaign_id, prior_start.isoformat(), prior_end.isoformat(),
+ )
+ items.extend(_rule_visibility_decline(visibility, prior_visibility))
+
+ # โโ Rule 3: official_low_citation โ OFFICIAL < 10% โโ
+ items.extend(_rule_official_low_citation(source_types))
+
+ # โโ Rule 4: negative_spike โ in-house negative > 30% โโ
+ brand_sentiments = _fetch_brand_sentiments(client, campaign_id, visibility)
+ items.extend(_rule_negative_spike(brand_sentiments))
+
+ # โโ Rule 5: channel_concentration โ single channel > 50% โโ
+ items.extend(_rule_channel_concentration(source_types))
+
+ # โโ Rules 6-9: channel type rules โโ
+ for channel_name in CHANNEL_GROUPS:
+ items.extend(_rule_channel_type_low(source_types, channel_name))
+
+ # โโ Rules 10-12: gap rules โโ
+ source_brand_mix = _fetch_source_brand_mix(client, campaign_id, start_date, end_date)
+ content_types = _fetch_source_content_types(client, campaign_id, start_date, end_date)
+
+ items.extend(_rule_gap_coverage(source_brand_mix))
+ items.extend(_rule_gap_format(content_types))
+ items.extend(_rule_gap_share(source_brand_mix))
+
+ # โโ Rules 13-14: question type rules โโ
+ question_stats = _fetch_question_type_stats(client, campaign_id)
+ items.extend(_rule_question_trust_risk(question_stats))
+ items.extend(_rule_question_decision(question_stats, source_types))
+
+ items.sort(key=lambda x: x["priority"], reverse=True)
+ return items[:14]
diff --git a/features/action_items/utils.py b/features/action_items/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..502c62bd303c984694a01a61f57b576a2f0b8adb
--- /dev/null
+++ b/features/action_items/utils.py
@@ -0,0 +1,44 @@
+"""Action Items utility functions โ status/priority/category styling."""
+
+STATUS_CONFIG = {
+ "pending": {"emoji": "๐ด", "label": "๋๊ธฐ", "color": "#ff6b6b", "bg": "#FEE2E2"},
+ "in_progress": {"emoji": "๐ก", "label": "์งํ", "color": "#f59e0b", "bg": "#FEF3C7"},
+ "completed": {"emoji": "๐ข", "label": "์๋ฃ", "color": "#10b981", "bg": "#D1FAE5"},
+ "archived": {"emoji": "โช", "label": "๋ณด๊ด", "color": "#9ca3af", "bg": "#F3F4F6"},
+}
+
+STATUS_OPTIONS = ["pending", "in_progress", "completed", "archived"]
+STATUS_LABELS = {k: f"{v['emoji']} {v['label']}" for k, v in STATUS_CONFIG.items()}
+
+CATEGORY_CONFIG = {
+ "ํ๋ซํผ์ต์ ํ": {"color": "#4361ee", "icon": "๐"},
+ "์ฑ๋์ ๋ต": {"color": "#3a0ca3", "icon": "๐ก"},
+ "SEO๊ฐํ": {"color": "#7209b7", "icon": "๐"},
+ "๊ฐ์ฑ๊ด๋ฆฌ": {"color": "#f72585", "icon": "๐ฌ"},
+ "์ฝํ
์ธ ๊ฐ์ ": {"color": "#4cc9f0", "icon": "๐"},
+ "๋ถ์ ๊ฐ์ฑ๋์": {"color": "#e63946", "icon": "๐ก๏ธ"},
+}
+
+# Legacy alias
+CATEGORY_COLORS = {k: v["color"] for k, v in CATEGORY_CONFIG.items()}
+
+
+def priority_level(priority: int) -> tuple[str, str, str]:
+ """Return (label, color, bg) based on priority score."""
+ if priority >= 80:
+ return "๊ธด๊ธ", "#dc2626", "#FEE2E2"
+ if priority >= 60:
+ return "๋์", "#f59e0b", "#FEF3C7"
+ if priority >= 40:
+ return "๋ณดํต", "#3b82f6", "#DBEAFE"
+ return "๋ฎ์", "#9ca3af", "#F3F4F6"
+
+
+def status_emoji(status: str) -> str:
+ """Return emoji for status."""
+ return STATUS_CONFIG.get(status, {}).get("emoji", "โ")
+
+
+def status_label(status: str) -> str:
+ """Return localized label for status."""
+ return STATUS_CONFIG.get(status, {}).get("label", status)
diff --git a/features/hierarchy/__init__.py b/features/hierarchy/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..b2e5bfae03bb246b4532ecaa223d1ec00fc80413
--- /dev/null
+++ b/features/hierarchy/__init__.py
@@ -0,0 +1,41 @@
+"""๊ณ์ธต ๋ถ์ Feature Plugin.
+
+API: /api/v1/hierarchy
+ADR-017: Hierarchy Generator Python Migration
+"""
+import streamlit as st
+
+from . import request, monitor
+
+FEATURE_CONFIG = {
+ "key": "hierarchy",
+ "name": "๊ณ์ธต ๋ถ์",
+ "icon": "๐",
+ "description": "ํค์๋ ๊ธฐ๋ฐ ์๋น์ ์ฌ์ ๊ณ์ธต ๋ถ์ ๋ฐ ์ง๋ฌธ ์์ฑ",
+ "api_base": "/api/v1/hierarchy",
+ "order": 5,
+}
+
+
+def render(base_ctx):
+ """๊ณ์ธต ๋ถ์ feature ๋ ๋๋ง."""
+ st.caption("ํค์๋๋ฅผ ์
๋ ฅํ๋ฉด ์๋น์ ์ฌ์ (CEJ) ๊ธฐ๋ฐ์ผ๋ก ๊ณ์ธต ๊ตฌ์กฐ๋ฅผ ๋ถ์ํ๊ณ ์ง๋ฌธ์ ์์ฑํฉ๋๋ค")
+
+ tabs = st.tabs([
+ "๐ ๋ถ์ ์์ฒญ",
+ "โณ ์งํ ํํฉ",
+ "๐ ๋ถ์ ๊ฒฐ๊ณผ",
+ ])
+
+ tab_renderers = [
+ ("๋ถ์ ์์ฒญ", request.render),
+ ("์งํ ํํฉ", monitor.render_active),
+ ("๋ถ์ ๊ฒฐ๊ณผ", monitor.render_history),
+ ]
+
+ for tab, (label, renderer) in zip(tabs, tab_renderers):
+ with tab:
+ try:
+ renderer(base_ctx)
+ except Exception as e:
+ st.error(f"{label} ๋ก๋ฉ ์คํจ: {e}")
diff --git a/features/hierarchy/monitor.py b/features/hierarchy/monitor.py
new file mode 100644
index 0000000000000000000000000000000000000000..5d8832b9f8094f2e47e75138b9fdee2ef30182e8
--- /dev/null
+++ b/features/hierarchy/monitor.py
@@ -0,0 +1,208 @@
+"""๊ณ์ธต ๋ถ์ ์งํ ํํฉ + ๋ถ์ ๊ฒฐ๊ณผ ํญ.
+
+์งํ ํํฉ: Supabase ์ง์ ์ฟผ๋ฆฌ (Vercel timeout ํํผ)
+๋ถ์ ๊ฒฐ๊ณผ: API ํธ์ถ (์ง๋ฌธ ๋ฐ์ดํฐ)
+"""
+import streamlit as st
+import pandas as pd
+
+from core.api_client import ChainShiftClient
+from core.job_realtime import (
+ get_active_hierarchy_jobs,
+ get_recent_hierarchy_jobs,
+ get_hierarchy_step_label,
+ format_job_duration,
+ get_status_emoji,
+ get_status_label,
+)
+
+
+# ============================================================================
+# ์งํ ํํฉ ํญ
+# ============================================================================
+
+def render_active(base_ctx: dict):
+ """์งํ ํํฉ ํญ ๋ ๋๋ง."""
+ st.markdown("##### โณ ์งํ ํํฉ")
+ st.caption("๋๊ธฐ ์ค์ด๊ฑฐ๋ ์ฒ๋ฆฌ ์ค์ธ ๊ณ์ธต ๋ถ์ Job์ ํ์ธํฉ๋๋ค")
+
+ if st.button("๐ ์๋ก๊ณ ์นจ", key="hier:refresh_active"):
+ st.rerun()
+
+ try:
+ active_jobs = get_active_hierarchy_jobs(limit=5)
+ except Exception as e:
+ st.warning(f"Job ๋ชฉ๋ก ๋ก๋ ์คํจ: {e}")
+ active_jobs = []
+
+ if not active_jobs:
+ st.info("ํ์ฌ ์งํ ์ค์ธ ๊ณ์ธต ๋ถ์ Job์ด ์์ต๋๋ค.")
+ return
+
+ for job in active_jobs:
+ _render_active_card(job)
+
+
+def _render_active_card(job: dict):
+ """ํ์ฑ Job ์นด๋ ๋ ๋๋ง."""
+ progress = job.get("progress", 0)
+ status = job.get("status", "")
+ step = job.get("current_step")
+ step_label = get_hierarchy_step_label(step)
+ status_emoji = get_status_emoji(status)
+ status_label = get_status_label(status)
+ duration = format_job_duration(job)
+ prompt = job.get("prompt", "")
+ title = job.get("title") or prompt[:30]
+
+ st.markdown(f"""
+
+
+
+ {status_emoji}
+ {title}
+
+
+
{progress}%
+
์์์๊ฐ: {duration}
+
+
+
+ {status_label} · {step_label}
+
+
+ ํค์๋: {prompt}
+
+
+ """, unsafe_allow_html=True)
+
+ st.progress(progress / 100)
+
+
+# ============================================================================
+# ๋ถ์ ๊ฒฐ๊ณผ ํญ
+# ============================================================================
+
+def render_history(base_ctx: dict):
+ """๋ถ์ ๊ฒฐ๊ณผ ํญ ๋ ๋๋ง."""
+ st.markdown("##### ๐ ๋ถ์ ๊ฒฐ๊ณผ")
+ st.caption("์๋ฃ๋ ๊ณ์ธต ๋ถ์ Job ์ด๋ ฅ๊ณผ ๊ฒฐ๊ณผ๋ฅผ ํ์ธํฉ๋๋ค")
+
+ if st.button("๐ ์๋ก๊ณ ์นจ", key="hier:refresh_history"):
+ st.rerun()
+
+ try:
+ jobs = get_recent_hierarchy_jobs(limit=20)
+ except Exception as e:
+ st.warning(f"Job ์ด๋ ฅ ๋ก๋ ์คํจ: {e}")
+ jobs = []
+
+ if not jobs:
+ st.info("์์ง ๊ณ์ธต ๋ถ์ ์ด๋ ฅ์ด ์์ต๋๋ค.")
+ return
+
+ # Job ์ด๋ ฅ ํ
์ด๋ธ
+ rows = []
+ for j in jobs:
+ status = j.get("status", "")
+ emoji = get_status_emoji(status)
+ label = get_status_label(status)
+ duration = format_job_duration(j)
+ rows.append({
+ "์ํ": f"{emoji} {label}",
+ "์ ๋ชฉ": j.get("title") or "-",
+ "ํค์๋": j.get("prompt", "")[:30],
+ "์งํ๋ฅ ": f"{j.get('progress', 0)}%",
+ "์์์๊ฐ": 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)
+
+ # ์๋ฃ๋ Job expander: ์ง๋ฌธ ํต๊ณ + ์ํ
+ completed_jobs = [j for j in jobs if j.get("status") == "completed"]
+ if not completed_jobs:
+ return
+
+ st.markdown("---")
+ st.markdown("###### ์๋ฃ๋ ๋ถ์ ์์ธ")
+
+ if not base_ctx.get("api_key") and not base_ctx.get("access_token"):
+ st.caption("์ง๋ฌธ ์์ธ ๋ณด๊ธฐ๋ ์ธ์ฆ์ด ํ์ํฉ๋๋ค.")
+ return
+
+ client = ChainShiftClient(
+ api_key=base_ctx.get("api_key"),
+ access_token=base_ctx.get("access_token"),
+ )
+
+ for job in completed_jobs[:5]:
+ job_id = job["id"]
+ title = job.get("title") or job.get("prompt", "")[:30]
+ created = (job.get("created_at") or "")[:10]
+
+ with st.expander(f"{title} ({created})", expanded=False):
+ _render_job_detail(client, job_id)
+
+
+def _render_job_detail(client: ChainShiftClient, job_id: str):
+ """์๋ฃ๋ Job ์์ธ: ์ง๋ฌธ ํต๊ณ + ์ํ ์ง๋ฌธ."""
+ # ์ง๋ฌธ ํต๊ณ
+ try:
+ stats_resp = client.get_hierarchy_question_stats(job_id)
+ stats = (stats_resp or {}).get("data") or {}
+ except Exception as e:
+ st.warning(f"ํต๊ณ ๋ก๋ ์คํจ: {e}")
+ stats = {}
+
+ if stats:
+ total = stats.get("total_questions", 0)
+ brand_count = stats.get("brand_mention_count", 0)
+ persona_count = stats.get("persona_included_count", 0)
+
+ col1, col2, col3 = st.columns(3)
+ with col1:
+ st.metric("์ด ์ง๋ฌธ", f"{total:,}๊ฐ")
+ with col2:
+ st.metric("๋ธ๋๋ ํฌํจ", f"{brand_count:,}๊ฐ")
+ with col3:
+ st.metric("ํ๋ฅด์๋ ์ ์ฉ", f"{persona_count:,}๊ฐ")
+
+ # ์ฌ์ ๋ณ ๋ถํฌ
+ by_depth1 = stats.get("by_journey_depth1") or {}
+ if by_depth1:
+ st.markdown("**์ฌ์ ์ ํ๋ณ ๋ถํฌ**")
+ depth1_labels = {
+ "awareness_comparison": "์ธ์ง/๋น๊ต",
+ "purchase": "๊ตฌ๋งค",
+ "post_purchase": "๊ตฌ๋งค ํ",
+ }
+ depth1_rows = [
+ {"์ฌ์ ": depth1_labels.get(k, k), "์ง๋ฌธ ์": v}
+ for k, v in by_depth1.items()
+ ]
+ st.dataframe(
+ pd.DataFrame(depth1_rows),
+ use_container_width=True,
+ hide_index=True,
+ )
+
+ # ์ํ ์ง๋ฌธ 10๊ฐ
+ try:
+ q_resp = client.get_hierarchy_questions(job_id, page=1, page_size=10)
+ questions = ((q_resp or {}).get("data") or {}).get("items") or []
+ except Exception:
+ questions = []
+
+ if questions:
+ st.markdown("**์ํ ์ง๋ฌธ (์ต๋ 10๊ฐ)**")
+ for i, q in enumerate(questions, 1):
+ journey = q.get("journey_depth2", "")
+ question_text = q.get("question", "")
+ brand = " ๐ท๏ธ" if q.get("brand_mention") else ""
+ st.markdown(f"{i}. [{journey}] {question_text}{brand}")
+ elif stats:
+ st.caption("์ง๋ฌธ ๋ฐ์ดํฐ๊ฐ ์์ง ์์ต๋๋ค.")
diff --git a/features/hierarchy/request.py b/features/hierarchy/request.py
new file mode 100644
index 0000000000000000000000000000000000000000..b95158cd8008affd1c0631f9a9901a4f514d7763
--- /dev/null
+++ b/features/hierarchy/request.py
@@ -0,0 +1,221 @@
+"""๊ณ์ธต ๋ถ์ ์์ฒญ ํญ.
+
+5๋จ๊ณ ์ค์ ํผ โ ProcessorConfig ๋งคํ โ API ํธ์ถ.
+"""
+import streamlit as st
+
+from core.api_client import ChainShiftClient
+
+# 17 Journey Types across 3 depth1 groups
+_JOURNEY_GROUPS: dict[str, list[tuple[str, str]]] = {
+ "์ธ์ง/๋น๊ต (Awareness & Comparison)": [
+ ("verification", "์ฌ์คํ์ธ"),
+ ("market_trends", "์ต์ ํธ๋ ๋"),
+ ("preparation", "์ค๋น/ํ์"),
+ ("timing", "์๊ธฐ/ํ์ด๋ฐ"),
+ ("review_experience", "๋ฆฌ๋ทฐ/๊ฒฝํ"),
+ ("information_discovery", "์ ๋ณดํ์/๊ฐ๋
"),
+ ("result_effectiveness", "ํจ๊ณผ/๊ฒฐ๊ณผ"),
+ ("recommendation", "๊ตฌ๋งค์ถ์ฒ"),
+ ("comparison", "๊ตฌ๋งค์ถ์ฒ(๋น๊ต)"),
+ ("problem_solving", "๋ฌธ์ ํด๊ฒฐ"),
+ ("difference_pros_cons", "์ฐจ์ด์ /์ฅ๋จ์ "),
+ ],
+ "๊ตฌ๋งค (Purchase)": [
+ ("pricing", "๋น์ฉ/๊ฐ๊ฒฉ"),
+ ("promotion_benefits", "ํ๋ก๋ชจ์
/ํ ์ธ/ํํ"),
+ ("where_to_buy", "๊ตฌ๋งค์ฒ"),
+ ],
+ "๊ตฌ๋งค ํ (Post-Purchase)": [
+ ("howto", "์ ํ/์๋น์ค how-to"),
+ ("refund_customer_service", "ํ๋ถ A/S"),
+ ("side_effect", "๋ถ์์ฉ"),
+ ],
+}
+
+_PRODUCT_TYPES = ["product", "service", "brand"]
+_MODELS = ["gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-2.5-pro", "gemini-2.5-flash"]
+_AGE_OPTIONS = ["10๋", "20๋", "30๋", "40๋", "50๋", "60๋ ์ด์"]
+_GENDER_OPTIONS = ["์ฌ์ฑ", "๋จ์ฑ"]
+_TRAIT_OPTIONS = ["๊ฐ์ฑ๋น์ค์", "ํ๋ฆฌ๋ฏธ์์ ํธ", "ํธ๋ ๋๋ฏผ๊ฐ", "์ค์ฉ์ฃผ์"]
+
+
+def render(base_ctx: dict):
+ """๋ถ์ ์์ฒญ ํผ ๋ ๋๋ง."""
+ st.markdown("##### ๐ ๊ณ์ธต ๋ถ์ ์์ฒญ")
+ st.caption("ํค์๋์ ๋ถ์ ์กฐ๊ฑด์ ์ค์ ํ๊ณ ๋ถ์์ ์์ํฉ๋๋ค")
+
+ if not base_ctx.get("api_key") and not base_ctx.get("access_token"):
+ st.warning("์ธ์ฆ ์ ๋ณด๊ฐ ์ค์ ๋์ง ์์์ต๋๋ค.")
+ return
+
+ client = ChainShiftClient(
+ api_key=base_ctx.get("api_key"),
+ access_token=base_ctx.get("access_token"),
+ )
+
+ # โโ Step 1: ์
๋ ฅ ๋ถ์ โโ
+ st.markdown("###### 1. ์
๋ ฅ ๋ถ์")
+ col1, col2 = st.columns([3, 1])
+ with col1:
+ raw_text = st.text_input(
+ "๋ถ์ ํค์๋",
+ key="hier:raw_text",
+ placeholder="์: ๊ฐ์์ง ์ฌ๋ฃ, ์ฌํ ๊ฐ๋ฐฉ, ์ ๊ธฐ์ฐจ ๋ณดํ",
+ )
+ with col2:
+ product_type = st.selectbox(
+ "์ ํ ์ ํ",
+ _PRODUCT_TYPES,
+ key="hier:product_type",
+ )
+
+ title = st.text_input(
+ "๋ถ์ ์ ๋ชฉ (์ ํ)",
+ key="hier:title",
+ placeholder="๋ถ์ ๊ฒฐ๊ณผ ๊ตฌ๋ถ์ฉ ์ ๋ชฉ",
+ )
+
+ # โโ Step 2: ์ฌ์ ์ ํ โโ
+ st.markdown("---")
+ st.markdown("###### 2. ์๋น์ ์ฌ์ ์ ํ")
+ st.caption("๋ถ์์ ํฌํจํ ์ฌ์ ์ ํ์ ์ ํํ์ธ์ (๊ธฐ๋ณธ: ๊ตฌ๋งค์ถ์ฒ)")
+
+ selected_journeys: list[str] = []
+ for group_label, types in _JOURNEY_GROUPS.items():
+ with st.expander(group_label, expanded=(group_label.startswith("์ธ์ง"))):
+ for code, label in types:
+ default = code == "recommendation"
+ if st.checkbox(
+ label,
+ value=default,
+ key=f"hier:cej:{code}",
+ ):
+ selected_journeys.append(code)
+
+ # โโ Step 3: ํ๋ฅด์๋ (์ ํ) โโ
+ st.markdown("---")
+ st.markdown("###### 3. ํ๋ฅด์๋ ์ค์ (์ ํ)")
+
+ use_persona = st.checkbox("ํ๋ฅด์๋ ์ ์ฉ", key="hier:use_persona")
+ persona_ages: list[str] = []
+ persona_gender: str | None = None
+ persona_trait: str | None = None
+
+ if use_persona:
+ col1, col2, col3 = st.columns(3)
+ with col1:
+ persona_ages = st.multiselect("์ฐ๋ น๋", _AGE_OPTIONS, key="hier:ages")
+ with col2:
+ gender_sel = st.selectbox(
+ "์ฑ๋ณ", ["์ ํ ์ํจ"] + _GENDER_OPTIONS, key="hier:gender",
+ )
+ persona_gender = gender_sel if gender_sel != "์ ํ ์ํจ" else None
+ with col3:
+ trait_sel = st.selectbox(
+ "์๋น ์ฑํฅ", ["์ ํ ์ํจ"] + _TRAIT_OPTIONS, key="hier:trait",
+ )
+ persona_trait = trait_sel if trait_sel != "์ ํ ์ํจ" else None
+
+ # โโ Step 4: ๋ธ๋๋ ์ปจํ
์คํธ (์ ํ) โโ
+ st.markdown("---")
+ st.markdown("###### 4. ๋ธ๋๋ ์ปจํ
์คํธ (์ ํ)")
+
+ brand_mention = st.checkbox(
+ "์ง๋ฌธ์ ๋ธ๋๋ ํฌํจ",
+ key="hier:brand_mention",
+ help="ํ์ฑํํ๋ฉด ์์ฑ๋ ์ง๋ฌธ์ ๋ธ๋๋๋ช
์ด ํฌํจ๋ฉ๋๋ค",
+ )
+ own_brands_input = ""
+ if brand_mention:
+ own_brands_input = st.text_input(
+ "์์ฌ ๋ธ๋๋ (์ผํ ๊ตฌ๋ถ)",
+ key="hier:own_brands",
+ placeholder="๋ธ๋๋A, ๋ธ๋๋B",
+ )
+
+ # โโ Step 5: ๋ถ์ ์ค์ โโ
+ st.markdown("---")
+ st.markdown("###### 5. ๋ถ์ ์ค์ ")
+
+ col1, col2, col3 = st.columns(3)
+ with col1:
+ model = st.selectbox("AI ๋ชจ๋ธ", _MODELS, key="hier:model")
+ with col2:
+ questions_per_kw = st.number_input(
+ "ํค์๋๋น ์ง๋ฌธ ์",
+ min_value=5,
+ max_value=100,
+ value=25,
+ step=5,
+ key="hier:qpk",
+ )
+ with col3:
+ max_nodes = st.number_input(
+ "์ต๋ ๋
ธ๋ ์",
+ min_value=10,
+ max_value=500,
+ value=100,
+ step=10,
+ key="hier:max_nodes",
+ )
+
+ # โโ Submit โโ
+ st.markdown("---")
+
+ can_submit = bool(raw_text and raw_text.strip() and selected_journeys)
+ if not raw_text or not raw_text.strip():
+ st.info("๋ถ์ ํค์๋๋ฅผ ์
๋ ฅํ์ธ์.")
+ elif not selected_journeys:
+ st.info("์ต์ 1๊ฐ์ ์ฌ์ ์ ํ์ ์ ํํ์ธ์.")
+
+ if st.button(
+ "โถ๏ธ ๋ถ์ ์์",
+ type="primary",
+ key="hier:submit",
+ disabled=not can_submit,
+ ):
+ keyword = raw_text.strip()
+ own_brands = [b.strip() for b in own_brands_input.split(",") if b.strip()] if own_brands_input else []
+
+ processor_config = {
+ "version": "1.0",
+ "inputAnalysis": {
+ "rawText": keyword,
+ "primaryKeyword": keyword,
+ "productType": product_type,
+ "locationCode": 2410,
+ "languageCode": "ko",
+ },
+ "brandContext": {
+ "brandMention": brand_mention,
+ "ownBrands": own_brands,
+ },
+ "selectedJourneyTypes": selected_journeys,
+ "persona": {
+ "attributes": {
+ "ages": persona_ages,
+ "gender": persona_gender,
+ "trait": persona_trait,
+ },
+ },
+ "modifiers": [],
+ }
+ settings = {
+ "model": model,
+ "questionsPerKeyword": questions_per_kw,
+ "maxNodes": max_nodes,
+ "outputLanguage": "ko",
+ }
+
+ try:
+ client.create_hierarchy_job(
+ prompt=keyword,
+ title=title.strip() if title else None,
+ processor_config=processor_config,
+ settings=settings,
+ )
+ st.success("๋ถ์ Job์ด ์์ฑ๋์์ต๋๋ค! '์งํ ํํฉ' ํญ์์ ํ์ธํ์ธ์.")
+ st.rerun()
+ except Exception as e:
+ st.error(f"๋ถ์ ์์ ์คํจ: {e}")
diff --git a/features/reports/__init__.py b/features/reports/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..978f7f22cb8e94a1c4f9d59144f0ae2aaf1deb63
--- /dev/null
+++ b/features/reports/__init__.py
@@ -0,0 +1,25 @@
+"""๋ฆฌํฌํธ Feature Plugin.
+
+API: /api/v1/reports
+"""
+import streamlit as st
+
+from . import overview, summary
+
+FEATURE_CONFIG = {
+ "key": "reports",
+ "name": "๋ฆฌํฌํธ",
+ "icon": "๐",
+ "description": "Feature๋ณ ๋ฏธ๋ฆฌ๋ณด๊ธฐ, HTML/CSV ๋ค์ด๋ก๋, ์ ์ฒด ๋ฆฌํฌํธ ์์ฑ",
+ "api_base": "/api/v1/reports",
+ "order": 2,
+}
+
+
+def render(base_ctx):
+ """๋ฆฌํฌํธ feature ๋ ๋๋ง."""
+ # Summary card
+ summary.render_summary(base_ctx)
+
+ # Main content - delegates to overview which has all sub-tabs
+ overview.render(base_ctx)
diff --git a/features/reports/full_report.py b/features/reports/full_report.py
new file mode 100644
index 0000000000000000000000000000000000000000..82f6afadb4d3fb6fb6436b35104ac977b0ac5e6c
--- /dev/null
+++ b/features/reports/full_report.py
@@ -0,0 +1,325 @@
+"""์ ์ฒด ๋ฆฌํฌํธ ์์ฑ ํญ."""
+from concurrent.futures import ThreadPoolExecutor, as_completed
+
+import pandas as pd
+import requests
+import streamlit as st
+import streamlit.components.v1 as components
+
+from core.api_client import ChainShiftClient
+
+
+def _build_one(
+ api_key: str,
+ campaign_id: int,
+ feat_key: str,
+ start_date: str,
+ end_date: str,
+ enable_insights: bool,
+ homepage_urls: list[str] | None,
+) -> tuple[str, dict | None, str | None]:
+ """Worker thread โ st.* ํธ์ถ ๊ธ์ง. ๋
๋ฆฝ HTTP ํด๋ผ์ด์ธํธ๋ก feature ๋น๋."""
+ try:
+ thread_client = ChainShiftClient(api_key=api_key)
+ result = thread_client.build_html_feature(
+ campaign_id=campaign_id,
+ feature=feat_key,
+ start_date=start_date,
+ end_date=end_date,
+ enable_insights=enable_insights,
+ enable_action_items=True,
+ homepage_urls=homepage_urls if feat_key == "homepage-citations" else None,
+ )
+ return feat_key, result, None
+ except Exception as e:
+ return feat_key, None, str(e)
+
+
+@st.cache_data(ttl=60)
+def _fetch_report_history(
+ campaign_id: int, page: int = 1, page_size: int = 20,
+ _api_key: str = "", _access_token: str = "",
+) -> dict:
+ """Cached fetch for HTML report history."""
+ client = ChainShiftClient(api_key=_api_key or None, access_token=_access_token or None)
+ return client.get_html_report_history(campaign_id, page=page, page_size=page_size)
+
+
+AVAILABLE_FEATURES = [
+ ("overview", "1. ๊ฐ์์ฑ ๋ถ์ ๊ฐ์"),
+ ("visibility", "2. AI ๊ฒ์ ๊ฐ์์ฑ"),
+ ("citations", "3. ์ธ์ฉ ์ถ์ฒ ๋ถ์"),
+ ("citation-trends", "4. ์ธ์ฉ ์ถ์ฒ ์๊ณ์ด"),
+ ("content-types", "5. ์ฝํ
์ธ ์ ํ"),
+ ("sentiment", "6. ๋ธ๋๋ ๊ฐ์ "),
+ ("homepage-citations", "7. ํํ์ด์ง ์ธ์ฉ๋ฅ "),
+]
+
+
+def render(client: ChainShiftClient, base_ctx: dict, start_date: str, end_date: str):
+ """์ ์ฒด ๋ฆฌํฌํธ ์์ฑ ์น์
."""
+ st.markdown("#### ๐ ์ ์ฒด HTML ๋ฆฌํฌํธ ์์ฑ")
+ st.caption("7๊ฐ Feature๋ฅผ ํฌํจํ ํตํฉ HTML ๋ฆฌํฌํธ๋ฅผ ์์ฑํฉ๋๋ค. LLM ์ธ์ฌ์ดํธ๋ก ์ปจ์คํดํธ ํค์ ๋ถ์์ ์ถ๊ฐํ ์ ์์ต๋๋ค.")
+
+ with st.expander("โ๏ธ ๋ฆฌํฌํธ ์ต์
", expanded=True):
+ st.markdown("**ํฌํจํ Feature ์ ํ**")
+ selected_features = []
+ col1, col2 = st.columns(2)
+ for i, (feat_key, feat_label) in enumerate(AVAILABLE_FEATURES):
+ with col1 if i < 4 else col2:
+ if st.checkbox(feat_label, value=True, key=f"reports:full_feat_{feat_key}"):
+ selected_features.append(feat_key)
+
+ st.markdown("---")
+
+ enable_insights = st.checkbox(
+ "๐ค LLM ์ธ์ฌ์ดํธ ์์ฑ",
+ value=True,
+ help="Gemini API๋ฅผ ์ฌ์ฉํ์ฌ ์ปจ์คํดํธ ํค์ ๋ถ์ ์ธ์ฌ์ดํธ๋ฅผ ์ถ๊ฐํฉ๋๋ค (์์ฑ ์๊ฐ ์ฆ๊ฐ)",
+ )
+
+ st.text_area(
+ "Homepage URLs (์ค๋ฐ๊ฟ ๊ตฌ๋ถ, ์ ํ)",
+ help="ํํ์ด์ง ์ธ์ฉ๋ฅ ๋ถ์์ ์ฌ์ฉํ URL ๋ชฉ๋ก",
+ key="reports:homepage_urls_input",
+ height=80,
+ )
+
+ # Parse homepage URLs from text area
+ homepage_urls_raw = st.session_state.get("reports:homepage_urls_input", "")
+ homepage_urls = [u.strip() for u in homepage_urls_raw.splitlines() if u.strip()] or None
+
+ # Feature display name lookup
+ _feat_display = dict(AVAILABLE_FEATURES)
+
+ if st.button("๐ ์ ์ฒด ๋ฆฌํฌํธ ์์ฑ", key="reports:generate_html_btn", type="primary", disabled=not selected_features):
+ campaign_id = base_ctx["campaign_id"]
+ total = len(selected_features)
+ progress_bar = st.progress(0, text="๋ฆฌํฌํธ ์์ฑ ์ค๋น ์ค...")
+ status_container = st.container()
+
+ built_features: list[dict] = []
+ skipped_features: list[str] = []
+ total_build_ms = 0
+
+ # Phase 1: Build features in parallel (I/O-bound HTTP calls)
+ with ThreadPoolExecutor(max_workers=total) as executor:
+ futures = {
+ executor.submit(
+ _build_one, client.api_key, campaign_id, feat_key,
+ start_date, end_date, enable_insights, homepage_urls,
+ ): feat_key
+ for feat_key in selected_features
+ }
+ completed = 0
+ for future in as_completed(futures):
+ feat_key = futures[future]
+ feat_label = _feat_display.get(feat_key, feat_key)
+ completed += 1
+ fk, result, error = future.result()
+ if error:
+ skipped_features.append(feat_key)
+ with status_container:
+ st.caption(f" {feat_label} ์คํจ: {error}")
+ elif result and result.get("success"):
+ feat_resp = result["data"]
+ built_features.append(feat_resp["feature_data"])
+ build_ms = feat_resp.get("build_time_ms", 0)
+ total_build_ms += build_ms
+ insight_tag = " +์ธ์ฌ์ดํธ" if feat_resp.get("insights_generated") else ""
+ with status_container:
+ st.caption(f" {feat_label} ({build_ms/1000:.1f}s{insight_tag})")
+ else:
+ skipped_features.append(feat_key)
+ with status_container:
+ st.caption(f" {feat_label} ๊ฑด๋๋")
+ progress_bar.progress(
+ completed / (total + 1),
+ text=f"({completed}/{total}) ๋น๋ ์๋ฃ...",
+ )
+
+ # Restore original feature order for rendering
+ feat_order = {k: i for i, k in enumerate(selected_features)}
+ built_features.sort(key=lambda f: feat_order.get(f.get("feature_id", ""), 99))
+
+ if not built_features:
+ progress_bar.empty()
+ st.error("๋ชจ๋ Feature ์์ฑ์ ์คํจํ์ต๋๋ค.")
+ else:
+ # Phase 2: Render final report
+ progress_bar.progress(
+ total / (total + 1),
+ text="HTML ๋ฆฌํฌํธ ์กฐ๋ฆฝ ์ค...",
+ )
+ try:
+ render_result = client.render_html_report(
+ campaign_id=campaign_id,
+ features_data=built_features,
+ start_date=start_date,
+ end_date=end_date,
+ enable_insights=enable_insights,
+ enable_action_items=True,
+ output_mode="url",
+ )
+
+ if render_result.get("success"):
+ data = render_result.get("data") or {}
+ if not isinstance(data, dict):
+ progress_bar.empty()
+ st.error("๋ฆฌํฌํธ ๋ ๋๋ง ์คํจ: ์๋ฒ ์๋ต์ด ๋น์ ์์
๋๋ค.")
+ else:
+ render_ms = data.get("generation_time_ms", 0)
+
+ # Download HTML from Supabase Storage URL directly.
+ # url mode avoids Vercel 4.5MB response body limit.
+ html_content = ""
+ html_url = data.get("html_url") or ""
+ if html_url:
+ try:
+ dl_resp = requests.get(html_url, timeout=30)
+ dl_resp.raise_for_status()
+ dl_resp.encoding = "utf-8"
+ html_content = dl_resp.text
+ except Exception as dl_err:
+ st.warning(f"HTML ๋ค์ด๋ก๋ ์คํจ, URL ๋งํฌ๋ก ๋์ฒด: {dl_err}")
+
+ if not html_url and not html_content:
+ progress_bar.empty()
+ st.error("๋ฆฌํฌํธ ๋ ๋๋ง ์คํจ: ์คํ ๋ฆฌ์ง URL์ด ๋ฐํ๋์ง ์์์ต๋๋ค.")
+ else:
+ # Persist results in session_state for rerun survival
+ st.session_state["full_report_result"] = {
+ "html_content": html_content,
+ "html_url": html_url,
+ "report_id": data.get("report_id", "")[:8],
+ "features_generated": data.get("features_generated", []),
+ "file_size_kb": round(data.get("file_size_bytes", 0) / 1024, 1),
+ "total_sec": round((total_build_ms + render_ms) / 1000, 1),
+ "insights_generated": data.get("insights_generated", False),
+ "skipped_features": skipped_features,
+ "campaign_id": campaign_id,
+ "start_date": start_date,
+ "end_date": end_date,
+ }
+ progress_bar.empty()
+ st.rerun()
+ else:
+ progress_bar.empty()
+ st.error("๋ฆฌํฌํธ ๋ ๋๋ง ์คํจ: " + str(render_result.get("error", "Unknown error")))
+
+ except Exception as e:
+ progress_bar.empty()
+ st.error(f"๋ฆฌํฌํธ ๋ ๋๋ง ์ค๋ฅ: {e}")
+ elif not selected_features:
+ st.warning("์ต์ 1๊ฐ ์ด์์ Feature๋ฅผ ์ ํํ์ธ์.")
+
+ # โโ Results display (persists across reruns via session_state) โโ
+ report_state = st.session_state.get("full_report_result")
+ if report_state:
+ html_content = report_state["html_content"]
+ report_id = report_state["report_id"]
+ features_generated = report_state["features_generated"]
+ file_size_kb = report_state["file_size_kb"]
+ total_sec = report_state["total_sec"]
+ skipped = report_state["skipped_features"]
+ r_campaign_id = report_state["campaign_id"]
+ r_start = report_state["start_date"]
+ r_end = report_state["end_date"]
+
+ if skipped:
+ st.warning(f"์ผ๋ถ Feature๋ฅผ ๊ฑด๋๋ฐ๊ณ ๋ฆฌํฌํธ๋ฅผ ์์ฑํ์ต๋๋ค: {', '.join(skipped)}")
+ st.success("๋ฆฌํฌํธ๊ฐ ์์ฑ๋์์ต๋๋ค!")
+
+ st.markdown(f"""
+
+
๋ฆฌํฌํธ ์์ฑ ์๋ฃ
+
Report ID: {report_id}...
+
Features: {len(features_generated)}๊ฐ ({len(skipped)}๊ฐ ๊ฑด๋๋)
+
ํ์ผ ํฌ๊ธฐ: {file_size_kb} KB
+
์์ฑ ์๊ฐ: {total_sec}์ด
+
LLM ์ธ์ฌ์ดํธ: {'ํฌํจ' if report_state.get('insights_generated') else '๋ฏธํฌํจ'}
+
+ """, unsafe_allow_html=True)
+
+ html_url = report_state.get("html_url", "")
+
+ if html_content:
+ col_open, col_download, col_clear = st.columns(3)
+ with col_open:
+ if html_url:
+ st.link_button("์ ์ฐฝ์์ ๋ณด๊ธฐ", html_url, use_container_width=True)
+ else:
+ st.button("์ ์ฐฝ์์ ๋ณด๊ธฐ", disabled=True, use_container_width=True, key="reports:open_disabled")
+ with col_download:
+ file_name = f"AI_๊ฐ์์ฑ_๋ฆฌํฌํธ_{r_campaign_id}_{r_start}_{r_end}.html"
+ st.download_button(
+ label="HTML ๋ค์ด๋ก๋",
+ data=b'\xef\xbb\xbf' + html_content.lstrip('\ufeff').encode("utf-8"),
+ file_name=file_name,
+ mime="text/html; charset=utf-8",
+ use_container_width=True,
+ key="reports:full_report_download",
+ )
+ with col_clear:
+ if st.button("์ด๊ธฐํ", key="reports:clear_result", use_container_width=True):
+ del st.session_state["full_report_result"]
+ st.rerun()
+
+ with st.expander("๋ฆฌํฌํธ ๋ฏธ๋ฆฌ๋ณด๊ธฐ", expanded=False):
+ components.html(html_content, height=800, scrolling=True)
+ elif html_url:
+ # Fallback: HTML download failed, show direct link
+ col_link, col_clear = st.columns(2)
+ with col_link:
+ st.link_button("๋ฆฌํฌํธ ์ด๊ธฐ (์ธ๋ถ ๋งํฌ)", html_url, use_container_width=True)
+ with col_clear:
+ if st.button("์ด๊ธฐํ", key="reports:clear_result", use_container_width=True):
+ del st.session_state["full_report_result"]
+ st.rerun()
+
+ st.markdown("---")
+
+ # Report History
+ st.markdown("##### ๐ ์์ฑ ์ด๋ ฅ")
+ try:
+ history_result = _fetch_report_history(
+ base_ctx["campaign_id"],
+ _api_key=base_ctx.get("api_key", ""),
+ _access_token=base_ctx.get("access_token", ""),
+ )
+ if history_result.get("success"):
+ history_data = history_result["data"]
+ reports = history_data.get("items", [])
+ total = history_data.get("total", 0)
+
+ if reports:
+ st.markdown(f"์ด **{total}**๊ฑด์ ๋ฆฌํฌํธ๊ฐ ์์ฑ๋์์ต๋๋ค.")
+
+ history_rows = []
+ for r in reports:
+ status_emoji = "โ
" if r.get("status") == "completed" else "โ"
+ features = r.get("features_included", [])
+ file_size_kb = round(r.get("file_size_bytes", 0) / 1024, 1)
+ history_rows.append({
+ "์์ฑ์ผ": r.get("created_at", "")[:16].replace("T", " "),
+ "์ํ": f"{status_emoji}",
+ "Features": f"{len(features)}/{len(AVAILABLE_FEATURES)}",
+ "๊ธฐ๊ฐ": f"{r.get('start_date', '?')} ~ {r.get('end_date', '?')}",
+ "ํฌ๊ธฐ": f"{file_size_kb} KB",
+ "๋งํฌ": r.get("html_url") or "-",
+ })
+
+ df = pd.DataFrame(history_rows)
+ st.dataframe(
+ df,
+ use_container_width=True,
+ hide_index=True,
+ column_config={
+ "๋งํฌ": st.column_config.LinkColumn("๋งํฌ", display_text="์ด๊ธฐ"),
+ },
+ )
+ else:
+ st.info("์์ง ์์ฑ๋ ๋ฆฌํฌํธ๊ฐ ์์ต๋๋ค.")
+ except Exception as e:
+ st.warning(f"์ด๋ ฅ ๋ก๋ ์คํจ: {e}")
diff --git a/features/reports/overview.py b/features/reports/overview.py
new file mode 100644
index 0000000000000000000000000000000000000000..535ae34b48e54ce76fec9af226c39fca5679da0e
--- /dev/null
+++ b/features/reports/overview.py
@@ -0,0 +1,220 @@
+"""๋ฆฌํฌํธ ํญ.
+
+Feature๋ณ ๋ฏธ๋ฆฌ๋ณด๊ธฐ, HTML/CSV ๋ค์ด๋ก๋, ์ ์ฒด ๋ฆฌํฌํธ ์์ฑ.
+"""
+from datetime import datetime, timedelta
+
+import streamlit as st
+
+from core.api_client import ChainShiftClient
+from core.supabase_client import get_campaign_date_range
+
+from .utils import render_feature_section
+from . import full_report
+
+
+# Period presets: (label, days or None for "all")
+PERIOD_PRESETS = [
+ ("์ต๊ทผ 1์ผ", 1),
+ ("์ต๊ทผ 7์ผ", 7),
+ ("์ต๊ทผ 30์ผ", 30),
+ ("์ต๊ทผ 90์ผ", 90),
+ ("์ต๊ทผ 180์ผ", 180),
+ ("์ ์ฒด ๊ธฐ๊ฐ", None),
+ ("์ง์ ์ ํ", -1),
+]
+
+
+def render(base_ctx: dict):
+ """๋ฆฌํฌํธ ํญ ๋ ๋๋ง."""
+ st.markdown("##### ๋ฆฌํฌํธ")
+ st.caption("๊ธฐ๊ฐ๋ณ AI ๊ฐ์์ฑ ๋ถ์ ๋ฆฌํฌํธ๋ฅผ ์์ฑํ๊ณ , HTML๋ก ๋ค์ด๋ก๋ํ ์ ์์ต๋๋ค.")
+
+ # Get campaign date range for presets
+ campaign_date_range = get_campaign_date_range(base_ctx["campaign_id"])
+ if campaign_date_range:
+ first_date_str, last_date_str = campaign_date_range
+ first_date = datetime.strptime(first_date_str, "%Y-%m-%d")
+ last_date = datetime.strptime(last_date_str, "%Y-%m-%d")
+ total_days = (last_date - first_date).days + 1
+ else:
+ first_date = datetime.now() - timedelta(days=30)
+ last_date = datetime.now()
+ first_date_str = first_date.strftime("%Y-%m-%d")
+ last_date_str = last_date.strftime("%Y-%m-%d")
+ total_days = 31
+
+ # Period selection
+ start_date_str, end_date_str = _render_period_selector(
+ first_date, last_date, first_date_str, last_date_str, total_days
+ )
+
+ # Show selected period info
+ selected_days = (datetime.strptime(end_date_str, "%Y-%m-%d") - datetime.strptime(start_date_str, "%Y-%m-%d")).days + 1
+ st.caption(f"๐
์ ํ๋ ๊ธฐ๊ฐ: **{start_date_str} ~ {end_date_str}** ({selected_days}์ผ)")
+
+ st.markdown("---")
+
+ # 4 Sub-tabs for Features + 1 for Full Report
+ report_tab_summary, report_tab_visibility, report_tab_citation, report_tab_full = st.tabs([
+ "๐ Executive Summary",
+ "๐๏ธ Visibility & Content",
+ "๐ Citation Analysis",
+ "๐ ์ ์ฒด ๋ฆฌํฌํธ",
+ ])
+
+ client = ChainShiftClient(api_key=base_ctx.get("api_key"), access_token=base_ctx.get("access_token"))
+
+ # Tab 1: Executive Summary
+ with report_tab_summary:
+ render_feature_section(
+ client=client,
+ campaign_id=base_ctx["campaign_id"],
+ feature_key="overview",
+ title="๊ฐ์์ฑ ๊ฐ์",
+ description="AI ํ๋ซํผ๋ณ ๋ธ๋๋ ๋
ธ์ถ ํํฉ๊ณผ ํต์ฌ ์งํ",
+ start_date=start_date_str,
+ end_date=end_date_str,
+ api_key=base_ctx.get("api_key") or "",
+ access_token=base_ctx.get("access_token") or "",
+ )
+
+ # Tab 2: Visibility & Content
+ with report_tab_visibility:
+ render_feature_section(
+ client=client,
+ campaign_id=base_ctx["campaign_id"],
+ feature_key="visibility",
+ title="ํ๋ซํผ๋ณ ๊ฐ์์ฑ",
+ description="ChatGPT, Gemini ๋ฑ AI ํ๋ซํผ๋ณ ์์ฌ vs ๊ฒฝ์์ฌ ๋
ธ์ถ ๋น๊ต",
+ start_date=start_date_str,
+ end_date=end_date_str,
+ api_key=base_ctx.get("api_key") or "",
+ access_token=base_ctx.get("access_token") or "",
+ )
+
+ render_feature_section(
+ client=client,
+ campaign_id=base_ctx["campaign_id"],
+ feature_key="content-types",
+ title="์ฝํ
์ธ ์ ํ ๋ถํฌ",
+ description="AI๊ฐ ์ธ์ฉํ๋ ์ฝํ
์ธ ์ ํ (๋ธ๋ก๊ทธ, ๋ด์ค, ๊ณต์ ์ฌ์ดํธ ๋ฑ)",
+ start_date=start_date_str,
+ end_date=end_date_str,
+ api_key=base_ctx.get("api_key") or "",
+ access_token=base_ctx.get("access_token") or "",
+ )
+
+ render_feature_section(
+ client=client,
+ campaign_id=base_ctx["campaign_id"],
+ feature_key="sentiment",
+ title="๋ธ๋๋ ๊ฐ์ ๋ถ์",
+ description="๋ธ๋๋๋ณ ๊ธ์ /๋ถ์ /์ค๋ฆฝ ๊ฐ์ ๋ถํฌ",
+ start_date=start_date_str,
+ end_date=end_date_str,
+ api_key=base_ctx.get("api_key") or "",
+ access_token=base_ctx.get("access_token") or "",
+ )
+
+ # Tab 3: Citation Analysis
+ with report_tab_citation:
+
+ render_feature_section(
+ client=client,
+ campaign_id=base_ctx["campaign_id"],
+ feature_key="citations",
+ title="์ธ์ฉ ์ถ์ฒ ์์",
+ description="AI ๋ต๋ณ์์ ๊ฐ์ฅ ๋ง์ด ์ธ์ฉ๋๋ ๋๋ฉ์ธ๊ณผ ์ถ์ฒ",
+ start_date=start_date_str,
+ end_date=end_date_str,
+ api_key=base_ctx.get("api_key") or "",
+ access_token=base_ctx.get("access_token") or "",
+ )
+
+ render_feature_section(
+ client=client,
+ campaign_id=base_ctx["campaign_id"],
+ feature_key="citation-trends",
+ title="์ธ์ฉ ์ถ์ด",
+ description="์๊ฐ์ ๋ฐ๋ฅธ ์ธ์ฉ ์ถ์ฒ ๋ณํ ํธ๋ ๋",
+ start_date=start_date_str,
+ end_date=end_date_str,
+ api_key=base_ctx.get("api_key") or "",
+ access_token=base_ctx.get("access_token") or "",
+ )
+
+ render_feature_section(
+ client=client,
+ campaign_id=base_ctx["campaign_id"],
+ feature_key="homepage-citations",
+ title="ํํ์ด์ง ์ธ์ฉ๋ฅ ",
+ description="์์ฌ ํํ์ด์ง๊ฐ AI ๋ต๋ณ์ ์ง์ ์ธ์ฉ๋๋ ๋น์จ",
+ start_date=start_date_str,
+ end_date=end_date_str,
+ api_key=base_ctx.get("api_key") or "",
+ access_token=base_ctx.get("access_token") or "",
+ )
+
+ # Tab 4: Full Report
+ with report_tab_full:
+ full_report.render(client, base_ctx, start_date_str, end_date_str)
+
+
+def _render_period_selector(
+ first_date: datetime,
+ last_date: datetime,
+ first_date_str: str,
+ last_date_str: str,
+ total_days: int,
+) -> tuple[str, str]:
+ """๊ธฐ๊ฐ ์ ํ UI ๋ ๋๋ง. (start_date, end_date) ๋ฐํ."""
+ col_period, col_date1, col_date2 = st.columns([1.5, 1, 1])
+
+ with col_period:
+ period_options = [label for label, _ in PERIOD_PRESETS]
+ selected_period = st.selectbox(
+ "๋ถ์ ๊ธฐ๊ฐ",
+ options=period_options,
+ index=5, # Default to "์ ์ฒด ๊ธฐ๊ฐ"
+ key="reports:period_select",
+ help=f"์บ ํ์ธ ๋ฐ์ดํฐ: {first_date_str} ~ {last_date_str} (์ด {total_days}์ผ)",
+ )
+
+ period_idx = period_options.index(selected_period)
+ _, period_days = PERIOD_PRESETS[period_idx]
+
+ if period_days == -1: # Custom selection
+ with col_date1:
+ report_start = st.date_input(
+ "์์์ผ",
+ value=first_date,
+ min_value=first_date,
+ max_value=last_date,
+ key="reports:start_date",
+ )
+ with col_date2:
+ report_end = st.date_input(
+ "์ข
๋ฃ์ผ",
+ value=last_date,
+ min_value=first_date,
+ max_value=last_date,
+ key="reports:end_date",
+ )
+ return str(report_start), str(report_end)
+ elif period_days is None: # All data
+ with col_date1:
+ st.text_input("์์์ผ", value=first_date_str, disabled=True, key="reports:start_display")
+ with col_date2:
+ st.text_input("์ข
๋ฃ์ผ", value=last_date_str, disabled=True, key="reports:end_display")
+ return first_date_str, last_date_str
+ else: # Preset days
+ end_date = last_date
+ start_date = max(first_date, end_date - timedelta(days=period_days - 1))
+ start_date_str = start_date.strftime("%Y-%m-%d")
+ end_date_str = end_date.strftime("%Y-%m-%d")
+ with col_date1:
+ st.text_input("์์์ผ", value=start_date_str, disabled=True, key="reports:start_display")
+ with col_date2:
+ st.text_input("์ข
๋ฃ์ผ", value=end_date_str, disabled=True, key="reports:end_display")
+ return start_date_str, end_date_str
diff --git a/features/reports/summary.py b/features/reports/summary.py
new file mode 100644
index 0000000000000000000000000000000000000000..eb1b8f4bb86e51fec401987ab88cb9dba82065f3
--- /dev/null
+++ b/features/reports/summary.py
@@ -0,0 +1,31 @@
+"""๋ฆฌํฌํธ Feature ์์ฝ ์นด๋."""
+import streamlit as st
+
+from core.supabase_client import get_campaign_date_range, get_report_history_count
+
+
+def render_summary(base_ctx: dict):
+ """๋ฆฌํฌํธ ์์ฝ ์นด๋."""
+ campaign_id = base_ctx["campaign_id"]
+ date_range = get_campaign_date_range(campaign_id)
+ report_count = get_report_history_count(campaign_id)
+
+ col1, col2, col3 = st.columns(3)
+ with col1:
+ if date_range:
+ days = _calc_days(date_range[0], date_range[1])
+ st.metric("๋ฐ์ดํฐ ์์ง ๊ธฐ๊ฐ", f"{days}์ผ", help=f"{date_range[0]} ~ {date_range[1]}")
+ else:
+ st.metric("๋ฐ์ดํฐ ์์ง ๊ธฐ๊ฐ", "N/A")
+ with col2:
+ st.metric("์์ฑ๋ ๋ฆฌํฌํธ", f"{report_count}๊ฑด")
+ with col3:
+ st.metric("๋ถ์ ํญ๋ชฉ", "๊ฐ์์ฑ / ์ธ์ฉ / ๊ฐ์ / ์ฝํ
์ธ ")
+
+
+def _calc_days(start: str, end: str) -> int:
+ """๋ ๋ ์ง ๋ฌธ์์ด ๊ฐ ์ผ์ ๊ณ์ฐ."""
+ from datetime import datetime
+ d1 = datetime.strptime(start, "%Y-%m-%d")
+ d2 = datetime.strptime(end, "%Y-%m-%d")
+ return (d2 - d1).days + 1
diff --git a/features/reports/utils.py b/features/reports/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..cc337fb6ca805fe2670dfddcedccf04abe2b391c
--- /dev/null
+++ b/features/reports/utils.py
@@ -0,0 +1,469 @@
+"""๋ฆฌํฌํธ ํญ ๊ณตํต ์ ํธ๋ฆฌํฐ.
+
+Feature๋ณ ๋ฏธ๋ฆฌ๋ณด๊ธฐ, HTML ์์ฑ, CSV ๋ณํ ๋ฑ ๊ณตํต ํจ์.
+"""
+import io
+import csv
+
+import pandas as pd
+import requests
+import streamlit as st
+import streamlit.components.v1 as components
+
+from core.api_client import ChainShiftClient
+
+
+def render_feature_section(
+ client: ChainShiftClient,
+ campaign_id: int,
+ feature_key: str,
+ title: str,
+ description: str,
+ start_date: str,
+ end_date: str,
+ api_key: str = "",
+ access_token: str = "",
+):
+ """๋จ์ผ Feature ์น์
๋ ๋๋ง."""
+ html_state_key = f"html_content_{feature_key}_{campaign_id}"
+ insights_key = f"insights_enabled_{feature_key}_{campaign_id}"
+
+ with st.container(border=True):
+ # Header
+ c1, c2 = st.columns([4, 1])
+ with c1:
+ st.markdown(f"**{title}**")
+ st.caption(description)
+
+ # Preview Section (Lazy loaded)
+ with st.expander(f"๐๏ธ ๋ฏธ๋ฆฌ๋ณด๊ธฐ", expanded=False):
+ try:
+ result = get_report_feature_data(api_key, campaign_id, feature_key, start_date, end_date, access_token=access_token)
+ if result.get("success"):
+ data = result.get("data", {})
+ render_feature_preview(feature_key, data)
+ else:
+ st.warning(f"๋ฐ์ดํฐ ๋ก๋ ์คํจ: {result.get('error', 'Unknown')}")
+ except Exception as e:
+ st.error(f"๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ค๋ฅ: {e}")
+
+ # LLM Insights checkbox
+ enable_insights = st.checkbox(
+ "๐ค LLM ์ธ์ฌ์ดํธ ํฌํจ",
+ value=st.session_state.get(insights_key, True),
+ key=f"insights_cb_{feature_key}",
+ help="์ปจ์คํดํธ ํค์ ๋ถ์ ์ฝ๋ฉํธ๋ฅผ ์ถ๊ฐํฉ๋๋ค",
+ )
+ st.session_state[insights_key] = enable_insights
+
+ # Generated HTML display section
+ if html_state_key in st.session_state:
+ html_data = st.session_state[html_state_key]
+ html_content = html_data.get("content", "")
+ html_url = html_data.get("url", "")
+
+ st.success(f"โ
HTML ๋ฆฌํฌํธ ์์ฑ ์๋ฃ" + (" (LLM ์ธ์ฌ์ดํธ ํฌํจ)" if html_data.get("insights") else ""))
+
+ if html_content:
+ # Action buttons
+ col_open, col_dl, col_csv, col_reset = st.columns(4)
+
+ with col_open:
+ if html_url:
+ st.link_button("๐ ์ ์ฐฝ์์ ๋ณด๊ธฐ", html_url, use_container_width=True)
+ else:
+ st.button("๐ ์ ์ฐฝ์์ ๋ณด๊ธฐ", disabled=True, use_container_width=True, key=f"html_open_{feature_key}_disabled")
+
+ with col_dl:
+ st.download_button(
+ label="๐ฅ HTML ๋ค์ด๋ก๋",
+ data=b'\xef\xbb\xbf' + html_content.lstrip('\ufeff').encode("utf-8"),
+ file_name=f"{feature_key}_{campaign_id}_{start_date}_{end_date}.html",
+ mime="text/html; charset=utf-8",
+ use_container_width=True,
+ key=f"html_dl_{feature_key}",
+ )
+
+ with col_csv:
+ try:
+ result = get_report_feature_data(api_key, campaign_id, feature_key, start_date, end_date, access_token=access_token)
+ if result.get("success"):
+ csv_data = convert_report_data_to_csv(feature_key, result.get("data", {}))
+ st.download_button(
+ label="๐ CSV ๋ค์ด๋ก๋",
+ data=csv_data.encode("utf-8-sig"),
+ file_name=f"{feature_key}_{campaign_id}_{start_date}_{end_date}.csv",
+ mime="text/csv",
+ use_container_width=True,
+ key=f"csv_{feature_key}_post",
+ )
+ else:
+ st.button("๐ CSV ๋ค์ด๋ก๋", disabled=True, use_container_width=True, key=f"csv_{feature_key}_post_disabled")
+ except Exception:
+ st.button("๐ CSV ๋ค์ด๋ก๋", disabled=True, use_container_width=True, key=f"csv_{feature_key}_post_error")
+
+ with col_reset:
+ if st.button("๐ ๋ค์ ์์ฑ", key=f"html_reset_{feature_key}", use_container_width=True):
+ del st.session_state[html_state_key]
+ st.rerun()
+
+ # Inline preview
+ with st.expander("๐๏ธ HTML ๋ฏธ๋ฆฌ๋ณด๊ธฐ", expanded=False):
+ components.html(html_content, height=500, scrolling=True)
+
+ elif html_url:
+ # Fallback: HTML download failed, show direct link
+ col_link, col_csv, col_reset = st.columns(3)
+ with col_link:
+ st.link_button("๐ ๋ฆฌํฌํธ ์ด๊ธฐ (์ธ๋ถ ๋งํฌ)", html_url, use_container_width=True)
+ with col_csv:
+ try:
+ result = get_report_feature_data(api_key, campaign_id, feature_key, start_date, end_date, access_token=access_token)
+ if result.get("success"):
+ csv_data = convert_report_data_to_csv(feature_key, result.get("data", {}))
+ st.download_button(
+ label="๐ CSV ๋ค์ด๋ก๋",
+ data=csv_data.encode("utf-8-sig"),
+ file_name=f"{feature_key}_{campaign_id}_{start_date}_{end_date}.csv",
+ mime="text/csv",
+ use_container_width=True,
+ key=f"csv_{feature_key}_fallback",
+ )
+ else:
+ st.button("๐ CSV ๋ค์ด๋ก๋", disabled=True, use_container_width=True, key=f"csv_{feature_key}_fallback_disabled")
+ except Exception:
+ st.button("๐ CSV ๋ค์ด๋ก๋", disabled=True, use_container_width=True, key=f"csv_{feature_key}_fallback_error")
+ with col_reset:
+ if st.button("๐ ๋ค์ ์์ฑ", key=f"html_reset_{feature_key}", use_container_width=True):
+ del st.session_state[html_state_key]
+ st.rerun()
+
+ else:
+ # Generate button
+ col_html, col_csv = st.columns(2)
+
+ with col_html:
+ if st.button(f"๐ HTML ์์ฑ", key=f"html_{feature_key}", use_container_width=True):
+ spinner_text = "HTML ์์ฑ ์ค..." + (" (LLM ์ธ์ฌ์ดํธ ํฌํจ)" if enable_insights else "")
+ with st.spinner(spinner_text):
+ try:
+ # Use url mode to avoid Vercel 4.5MB response limit.
+ # Download HTML from Supabase Storage directly.
+ result_url = client.generate_html_report(
+ campaign_id=campaign_id,
+ start_date=start_date,
+ end_date=end_date,
+ features=[feature_key],
+ enable_insights=enable_insights,
+ output_mode="url",
+ )
+ if result_url.get("success"):
+ data = result_url.get("data") or {}
+ html_url = data.get("html_url", "") if isinstance(data, dict) else ""
+ html_content = ""
+ if html_url:
+ try:
+ dl_resp = requests.get(html_url, timeout=30)
+ dl_resp.raise_for_status()
+ dl_resp.encoding = "utf-8"
+ html_content = dl_resp.text
+ except Exception as dl_err:
+ st.warning(f"HTML ๋ค์ด๋ก๋ ์คํจ, URL ๋งํฌ๋ก ๋์ฒด: {dl_err}")
+ if not html_url and not html_content:
+ st.error("HTML ์์ฑ ์คํจ: ์คํ ๋ฆฌ์ง URL์ด ๋ฐํ๋์ง ์์์ต๋๋ค.")
+ else:
+ st.session_state[html_state_key] = {
+ "content": html_content,
+ "url": html_url,
+ "insights": enable_insights,
+ }
+ st.rerun()
+ else:
+ st.error("HTML ์์ฑ ์คํจ: " + str(result_url.get("error", "Unknown")))
+ except Exception as e:
+ st.error(f"์ค๋ฅ: {e}")
+
+ with col_csv:
+ try:
+ result = get_report_feature_data(api_key, campaign_id, feature_key, start_date, end_date, access_token=access_token)
+ if result.get("success"):
+ csv_data = convert_report_data_to_csv(feature_key, result.get("data", {}))
+ st.download_button(
+ label="๐ CSV ๋ค์ด๋ก๋",
+ data=csv_data.encode("utf-8-sig"),
+ file_name=f"{feature_key}_{campaign_id}_{start_date}_{end_date}.csv",
+ mime="text/csv",
+ use_container_width=True,
+ key=f"csv_{feature_key}",
+ )
+ else:
+ st.button("๐ CSV ๋ค์ด๋ก๋", disabled=True, use_container_width=True, key=f"csv_{feature_key}_disabled")
+ except Exception:
+ st.button("๐ CSV ๋ค์ด๋ก๋", disabled=True, use_container_width=True, key=f"csv_{feature_key}_error")
+
+
+def render_feature_preview(feature_key: str, data: dict):
+ """Feature๋ณ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์๊ฐํ."""
+ if feature_key == "overview":
+ cols = st.columns(4)
+ with cols[0]:
+ st.metric("์ด ์ง๋ฌธ ์", data.get("total_tasks", 0))
+ with cols[1]:
+ st.metric("์ด ๋ต๋ณ ์", data.get("total_answers", 0))
+ with cols[2]:
+ st.metric("๊ฐ์์ฑ", f"{data.get('overall_visibility_pct', 0):.1f}%")
+ with cols[3]:
+ dr = data.get("date_range", {})
+ period = f"{dr.get('start', '?')} ~ {dr.get('end', '?')}"
+ st.metric("๋ถ์ ๊ธฐ๊ฐ", period[:20])
+
+ elif feature_key == "visibility":
+ platforms = data.get("platforms", [])
+ if platforms:
+ rows = []
+ for p in platforms:
+ for b in p.get("brands", []):
+ rows.append({
+ "ํ๋ซํผ": p.get("platform", ""),
+ "๋ธ๋๋": b.get("brand_name", ""),
+ "๊ฐ์์ฑ (%)": b.get("visibility_pct", 0),
+ })
+ if rows:
+ df = pd.DataFrame(rows)
+ st.dataframe(df, use_container_width=True, hide_index=True)
+ else:
+ st.info("ํ๋ซํผ ๋ฐ์ดํฐ ์์")
+
+ elif feature_key == "citations":
+ sources = data.get("sources", [])[:10]
+ if sources:
+ df = pd.DataFrame(sources)
+ cols = [c for c in ["source_host_url", "total_citations", "pct_of_total"] if c in df.columns]
+ if cols:
+ st.dataframe(df[cols], use_container_width=True, hide_index=True)
+ else:
+ st.info("์ธ์ฉ ๋ฐ์ดํฐ ์์")
+
+ elif feature_key == "citation-trends":
+ sources = data.get("sources", [])
+ if sources:
+ rows = []
+ for s in sources:
+ for pt in s.get("trend", []):
+ rows.append({
+ "date": pt.get("task_date", ""),
+ "source": s.get("source_host_url", ""),
+ "citations": pt.get("citation_count", 0),
+ })
+ if rows:
+ df = pd.DataFrame(rows)
+ pivot = df.pivot_table(index="date", columns="source", values="citations", aggfunc="sum").fillna(0)
+ st.line_chart(pivot)
+ else:
+ st.info("์๊ณ์ด ๋ฐ์ดํฐ ์์")
+
+ elif feature_key == "content-types":
+ types = data.get("content_types", [])
+ if types:
+ df = pd.DataFrame(types)
+ if "content_type" in df.columns and "total_citations" in df.columns:
+ st.bar_chart(df.set_index("content_type")["total_citations"])
+ else:
+ st.info("์ฝํ
์ธ ์ ํ ๋ฐ์ดํฐ ์์")
+
+ elif feature_key == "sentiment":
+ in_house = data.get("in_house_brands", [])
+ competitor = data.get("competitor_brands", [])
+
+ if in_house:
+ st.markdown("**๐ข ์์ฌ ๋ธ๋๋**")
+ df_ih = pd.DataFrame(in_house)
+ cols_ih = ["brand_name", "total_mentions", "positive_rate", "negative_rate"]
+ cols_ih = [c for c in cols_ih if c in df_ih.columns]
+ if cols_ih:
+ st.dataframe(df_ih[cols_ih], use_container_width=True, hide_index=True)
+
+ if competitor:
+ st.markdown("**๐ฏ ๊ฒฝ์์ฌ ๋ธ๋๋**")
+ df_comp = pd.DataFrame(competitor)
+ cols_comp = ["brand_name", "total_mentions", "positive_rate", "negative_rate"]
+ cols_comp = [c for c in cols_comp if c in df_comp.columns]
+ if cols_comp:
+ st.dataframe(df_comp[cols_comp], use_container_width=True, hide_index=True)
+
+ if not in_house and not competitor:
+ brands = data.get("brands", [])
+ if brands:
+ df = pd.DataFrame(brands)
+ cols = [c for c in ["brand_name", "brand_type", "positive_rate", "negative_rate"] if c in df.columns]
+ if cols:
+ st.dataframe(df[cols], use_container_width=True, hide_index=True)
+ else:
+ st.info("๊ฐ์ ๋ถ์ ๋ฐ์ดํฐ ์์")
+
+ elif feature_key == "homepage-citations":
+ daily_data = data.get("daily_data", [])[:10]
+ if daily_data:
+ rows = []
+ for day in daily_data:
+ for entry in day.get("entries", []):
+ rows.append({
+ "๋ ์ง": day.get("task_date", ""),
+ "ํ๋ซํผ": entry.get("platform", ""),
+ "์ธ์ฉ ํ์": entry.get("citation_count", 0),
+ })
+ if rows:
+ df = pd.DataFrame(rows)
+ st.dataframe(df, use_container_width=True, hide_index=True)
+ else:
+ st.info("ํํ์ด์ง ์ธ์ฉ ๋ฐ์ดํฐ ์์")
+
+
+@st.cache_data(ttl=300)
+def get_report_feature_data(
+ api_key: str,
+ campaign_id: int,
+ feature: str,
+ start_date: str | None = None,
+ end_date: str | None = None,
+ access_token: str = "",
+):
+ """Fetch report feature data with caching."""
+ client = ChainShiftClient(api_key=api_key or None, access_token=access_token or None)
+
+ if feature == "overview":
+ return client.get_report_overview(campaign_id, start_date, end_date)
+ elif feature == "visibility":
+ return client.get_report_visibility(campaign_id, start_date, end_date)
+ elif feature == "citations":
+ return client.get_report_citations(campaign_id, start_date, end_date, limit=50)
+ elif feature == "citation-trends":
+ return client.get_report_citation_trends(campaign_id, start_date, end_date)
+ elif feature == "content-types":
+ return client.get_report_content_types(campaign_id, start_date, end_date)
+ elif feature == "sentiment":
+ return client.get_report_sentiment(campaign_id)
+ elif feature == "homepage-citations":
+ return client.get_report_homepage_citations(campaign_id, start_date, end_date)
+ else:
+ return {"success": False, "error": f"Unknown feature: {feature}"}
+
+
+def convert_report_data_to_csv(feature: str, data: dict) -> str:
+ """Convert report feature data to CSV format."""
+ output = io.StringIO()
+ writer = csv.writer(output)
+
+ if feature == "overview":
+ dr = data.get("date_range", {})
+ writer.writerow(["ํญ๋ชฉ", "๊ฐ"])
+ writer.writerow(["์บ ํ์ธ ID", data.get("campaign_id", "")])
+ writer.writerow(["๋ถ์ ๊ธฐ๊ฐ", f"{dr.get('start', '')} ~ {dr.get('end', '')}"])
+ writer.writerow(["์ด ์ง๋ฌธ ์", data.get("total_tasks", 0)])
+ writer.writerow(["์ด ๋ต๋ณ ์", data.get("total_answers", 0)])
+ writer.writerow(["๊ฐ์์ฑ ๋น์จ (%)", data.get("overall_visibility_pct", 0)])
+
+ elif feature == "visibility":
+ writer.writerow(["ํ๋ซํผ", "๋ธ๋๋", "์ ํ", "๊ฐ์์ฑ (%)", "๋ธ๋๋ ์ธ๊ธ ์", "์ด ๋ต๋ณ ์"])
+ for platform in data.get("platforms", []):
+ for brand in platform.get("brands", []):
+ writer.writerow([
+ platform.get("platform", ""),
+ brand.get("brand_name", ""),
+ brand.get("brand_type", ""),
+ brand.get("visibility_pct", 0),
+ brand.get("brand_mentions", 0),
+ platform.get("total_answers", 0),
+ ])
+
+ elif feature == "citations":
+ writer.writerow(["๋๋ฉ์ธ", "์ ํ", "์ธ์ฉ ํ์", "๋ต๋ณ ์ธ๊ธ ์", "๋น์จ (%)"])
+ for item in data.get("sources", []):
+ writer.writerow([
+ item.get("source_host_url", ""),
+ item.get("source_host_type", ""),
+ item.get("total_citations", 0),
+ item.get("total_answer_mentions", 0),
+ item.get("pct_of_total", 0),
+ ])
+
+ elif feature == "citation-trends":
+ writer.writerow(["์ธ์ฉ ์ถ์ฒ", "์ ํ", "๋ ์ง", "์ธ์ฉ ํ์", "๋ต๋ณ ์ธ๊ธ ์", "๋น์จ (%)"])
+ for source in data.get("sources", []):
+ host = source.get("source_host_url", "")
+ host_type = source.get("source_host_type", "")
+ for point in source.get("trend", []):
+ writer.writerow([
+ host,
+ host_type,
+ point.get("task_date", ""),
+ point.get("citation_count", 0),
+ point.get("answer_mention_count", 0),
+ point.get("citation_pct", 0),
+ ])
+
+ elif feature == "content-types":
+ writer.writerow(["์ฝํ
์ธ ์ ํ", "์ธ์ฉ ํ์", "๋ต๋ณ ์ธ๊ธ ์", "๋น์จ (%)"])
+ for item in data.get("content_types", []):
+ writer.writerow([
+ item.get("content_type", ""),
+ item.get("total_citations", 0),
+ item.get("total_answer_mentions", 0),
+ item.get("pct_of_total", 0),
+ ])
+
+ elif feature == "sentiment":
+ writer.writerow(["๋ธ๋๋", "์ ํ", "์ด ๋ฉ์
", "๊ธ์ %", "๋ถ์ %", "์ค๋ฆฝ %"])
+
+ for item in data.get("in_house_brands", []):
+ t = item.get("total_mentions", 0)
+ neutral = round(item.get("neutral_count", 0) / t * 100, 1) if t > 0 else 0.0
+ writer.writerow([
+ item.get("brand_name", ""),
+ "์์ฌ",
+ t,
+ f"{item.get('positive_rate', 0):.1f}",
+ f"{item.get('negative_rate', 0):.1f}",
+ f"{neutral:.1f}",
+ ])
+
+ for item in data.get("competitor_brands", []):
+ t = item.get("total_mentions", 0)
+ neutral = round(item.get("neutral_count", 0) / t * 100, 1) if t > 0 else 0.0
+ writer.writerow([
+ item.get("brand_name", ""),
+ "๊ฒฝ์์ฌ",
+ item.get("total_mentions", 0),
+ f"{item.get('positive_rate', 0):.1f}",
+ f"{item.get('negative_rate', 0):.1f}",
+ f"{neutral:.1f}",
+ ])
+
+ if not data.get("in_house_brands") and not data.get("competitor_brands"):
+ for item in data.get("brands", []):
+ pos = item.get("positive_rate", item.get("positive", 0))
+ neg = item.get("negative_rate", item.get("negative", 0))
+ neutral = 100 - pos - neg
+ writer.writerow([
+ item.get("brand_name", item.get("name", "")),
+ item.get("brand_type", item.get("type", "")),
+ item.get("total_mentions", 0),
+ f"{pos:.1f}",
+ f"{neg:.1f}",
+ f"{neutral:.1f}",
+ ])
+
+ elif feature == "homepage-citations":
+ writer.writerow(["๋ ์ง", "ํ๋ซํผ", "์ธ์ฉ ์ถ์ฒ", "์ธ์ฉ ํ์", "๋ต๋ณ ์ธ๊ธ ์"])
+ for day in data.get("daily_data", []):
+ task_date = day.get("task_date", "")
+ for entry in day.get("entries", []):
+ writer.writerow([
+ task_date,
+ entry.get("platform", ""),
+ entry.get("source_host_url", ""),
+ entry.get("citation_count", 0),
+ entry.get("answer_mention_count", 0),
+ ])
+
+ return output.getvalue()
diff --git a/features/research/__init__.py b/features/research/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..a33323ba710f35231ae09a565d71423307aa751a
--- /dev/null
+++ b/features/research/__init__.py
@@ -0,0 +1,189 @@
+"""ํ ํฝ ์ธํ
๋ฆฌ์ ์ค Feature Plugin.
+
+API: /api/v1/research
+ADR-013 Phase 4 + ADR-014 Phase 3 (Multi-Model UX).
+"""
+import streamlit as st
+
+from core.supabase_client import (
+ get_topic_clusters, get_topic_map_snapshot, find_cross_model_pair,
+)
+from . import topic_map, opportunities, distribution, guide, summary
+from .cross_model import render_cross_model
+from .content_actions import render_content_actions
+from .keyword_suggest import render_keyword_suggestions
+from .unified_scoring import render_unified_scoring
+
+FEATURE_CONFIG = {
+ "key": "research",
+ "name": "ํ ํฝ ์ธํ
๋ฆฌ์ ์ค",
+ "icon": "๐ฌ",
+ "description": "AI๊ฐ ์ด๋ค ํ ํฝ์ ๊ด์ฌ์ ๊ฐ๊ณ ์๋์ง, ์ด๋์ ์ฝํ
์ธ ๊ธฐํ๊ฐ ์๋์ง ๋ถ์",
+ "api_base": "/api/v1/research",
+ "order": 3,
+}
+
+# Source configs: label, description, help text
+SOURCE_OPTIONS = {
+ "์ ์ฒด": {
+ "source": None,
+ "desc": "ChatGPT + Gemini ์ ์ฒด ํ ํฝ์ ํตํฉํ์ฌ ๋ถ์ํฉ๋๋ค.",
+ "fanout_label": "Fanout/Citation",
+ "frame": "all",
+ },
+ "ChatGPT (Demand)": {
+ "source": "chatgpt",
+ "desc": "์๋น์๊ฐ AI์๊ฒ **๋ฌด์์ ๋ฌผ์ด๋ณด๋์ง** ๋ถ์ํฉ๋๋ค. ChatGPT์ sub-query ๋ถํด ๋ฐ์ดํฐ ๊ธฐ๋ฐ.",
+ "fanout_label": "Fanout",
+ "frame": "demand",
+ },
+ "Gemini (Supply)": {
+ "source": "gemini",
+ "desc": "AI๊ฐ **๋ฌด์์ ๊ทผ๊ฑฐ๋ก ๋ต๋ณํ๋์ง** ๋ถ์ํฉ๋๋ค. Gemini์ citation quote ๋ฐ์ดํฐ ๊ธฐ๋ฐ.",
+ "fanout_label": "Citation",
+ "frame": "supply",
+ },
+}
+
+
+def render(base_ctx):
+ """ํ ํฝ ์ธํ
๋ฆฌ์ ์ค feature ๋ ๋๋ง."""
+ campaign_id = base_ctx["campaign_id"]
+
+ st.caption("AI๊ฐ ์ด๋ค ํ ํฝ์ ๊ด์ฌ์ ๊ฐ๊ณ ์๋์ง, ์ด๋์ ์ฝํ
์ธ ๊ธฐํ๊ฐ ์๋์ง ๋ถ์ํฉ๋๋ค.")
+
+ # --- Model Selector ---
+ selected = st.radio(
+ "๋ถ์ ๋ชจ๋ธ",
+ list(SOURCE_OPTIONS.keys()),
+ horizontal=True,
+ key="research:model_selector",
+ help="ChatGPT๋ ์๋น์ ๊ฒ์ ์๋(Demand), Gemini๋ AI ์ธ์ฉ ๊ทผ๊ฑฐ(Supply)๋ฅผ ๋ํ๋
๋๋ค.",
+ )
+ source_cfg = SOURCE_OPTIONS[selected]
+ source = source_cfg["source"]
+
+ st.caption(source_cfg["desc"])
+
+ # Education expander
+ with st.expander("์ด๋ป๊ฒ ์๋ํ๋์?", expanded=False):
+ if source_cfg["frame"] == "demand":
+ st.markdown("""
+**ChatGPT Demand ๋ถ์**
+
+ChatGPT๋ ์ฌ์ฉ์ ์ง๋ฌธ์ 8-15๊ฐ์ ์ธ๋ถ ์ง๋ฌธ(fanout)์ผ๋ก ๋ถํดํ์ฌ Bing์์ ๊ฒ์ํฉ๋๋ค.
+์ด fanout ํจํด์ ๋ถ์ํ๋ฉด **์๋น์๊ฐ AI์๊ฒ ๋ฌด์์ ๋ฌผ์ด๋ณด๋์ง** ํ์
ํ ์ ์์ต๋๋ค.
+
+```
+์ฌ์ฉ์ ์ง๋ฌธ โ ChatGPT๊ฐ 8-15๊ฐ sub-query ์์ฑ โ Bing ๊ฒ์ โ ๋ต๋ณ ํฉ์ฑ
+ โ
+ sub-query ํด๋ฌ์คํฐ๋ง โ Demand ํ ํฝ
+```
+""")
+ elif source_cfg["frame"] == "supply":
+ st.markdown("""
+**Gemini Supply ๋ถ์**
+
+Gemini๋ ๋ต๋ณ ์ ์น ์ฝํ
์ธ ์์ ์ง์ ๋ฌธ์ฅ์ ์ถ์ถ(extractive summarization)ํ์ฌ ์ธ์ฉํฉ๋๋ค.
+์ธ์ฉ ํจํด์ ๋ถ์ํ๋ฉด **AI๊ฐ ์ด๋ค ์ฝํ
์ธ ๋ฅผ ๊ทผ๊ฑฐ๋ก ์ ํํ๋์ง** ํ์
ํ ์ ์์ต๋๋ค.
+
+```
+์ฌ์ฉ์ ์ง๋ฌธ โ Gemini ๊ฒ์ ํ๋จ โ Google Search โ 2000๋จ์ด ์์ฐ ๋ด ์ธ์ฉ ์ถ์ถ
+ โ
+ citation quote ํด๋ฌ์คํฐ๋ง โ Supply ํ ํฝ
+```
+""")
+ else:
+ st.markdown("""
+AI ๋ชจ๋ธ์ด ์ฌ์ฉ์ ์ง๋ฌธ์ ๋ต๋ณํ ๋, ๋ด๋ถ์ ์ผ๋ก ์ฌ๋ฌ ๊ฐ์ ์ธ๋ถ ์ง๋ฌธ(์ถ๊ฐ ์ง๋ฌธ)์
+๋ง๋ค์ด ์กฐ์ฌํฉ๋๋ค. ์ด ์ถ๊ฐ ์ง๋ฌธ๋ค์ ๋ถ์ํ๋ฉด **AI๊ฐ ์ด๋ค ์ฃผ์ ์ ๊ด์ฌ์ ๊ฐ๊ณ ์๋์ง**,
+์ด๋ค ๋ถ์ผ์์ **๊ฒฝ์์ด ์น์ดํ์ง**๋ฅผ ํ์
ํ ์ ์์ต๋๋ค.
+
+**๋ฐ์ดํฐ ํ๋ฆ:**
+```
+์ฌ์ฉ์ ์ง๋ฌธ โ AI๊ฐ ์ถ๊ฐ ์ง๋ฌธ ์์ฑ โ ๋ต๋ณ ์์ฑ โ ์ถ์ฒ ์ธ์ฉ
+ โ
+ ์ ์ฌํ ์ถ๊ฐ ์ง๋ฌธ๋ผ๋ฆฌ ๋ฌถ๊ธฐ (ํด๋ฌ์คํฐ๋ง)
+ โ
+ ๊ฐ ํ ํฝ์ ์ ์ ๊ณ์ฐ
+ ยท AI ๊ด์ฌ๋: AI๊ฐ ์ด ์ฃผ์ ๋ฅผ ์ผ๋ง๋ ์์ฃผ ๋ฌผ์ด๋ณด๋๊ฐ
+ ยท ๊ฒฝ์ ๋ฐ๋: ์ด ์ฃผ์ ์ ์ผ๋ง๋ ๋ง์ ์ถ์ฒ๊ฐ ์ธ์ฉ๋๋๊ฐ
+ ยท ๊ธฐํ ์ ์: ๊ด์ฌ์ ๋์ง๋ง ๊ฒฝ์์ด ๋ฎ์ ์์ญ
+```
+
+์์ธํ ๋ด์ฉ์ **"๋ถ์ ๊ฐ์ด๋"** ํญ์ ์ฐธ์กฐํ์ธ์.
+""")
+
+ clusters = get_topic_clusters(campaign_id, source=source)
+
+ if not clusters:
+ if source:
+ st.warning(f"{selected} ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค. ํด๋น ๋ชจ๋ธ์ ํด๋ฌ์คํฐ๋ง์ ๋จผ์ ์คํํ์ธ์.")
+ else:
+ st.warning("ํ ํฝ ํด๋ฌ์คํฐ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค. ํด๋ฌ์คํฐ๋ง์ ๋จผ์ ์คํํ์ธ์.")
+ return
+
+ snapshot = get_topic_map_snapshot(campaign_id, source=source)
+
+ # Summary metrics
+ summary.render_summary(clusters, frame=source_cfg["frame"])
+
+ st.markdown("---")
+
+ # Cross-model pair (one DB call, reused across tabs)
+ pair = find_cross_model_pair(campaign_id)
+
+ # Build tab list โ Unified Scoring only in "์ ์ฒด" mode with pair
+ tab_names = [
+ "๐บ๏ธ ํ ํฝ ๋งต",
+ "๐ฏ ๊ธฐํ ์์ญ",
+ "๐ ํ ํฝ ๋ถํฌ",
+ "๐ Cross-Model",
+ "๐ก ์ฝํ
์ธ ๊ฐ์ด๋",
+ "๐ ํค์๋ ์ถ์ฒ",
+ ]
+ show_unified = source_cfg["frame"] == "all" and pair is not None
+ if show_unified:
+ tab_names.append("โ๏ธ Unified Score")
+ tab_names.append("๐ ๋ถ์ ๊ฐ์ด๋")
+
+ tabs = st.tabs(tab_names)
+ idx = 0
+
+ with tabs[idx]:
+ topic_map.render_topic_map(clusters, snapshot, frame=source_cfg["frame"])
+ idx += 1
+
+ with tabs[idx]:
+ opportunities.render_opportunities(clusters, frame=source_cfg["frame"])
+ idx += 1
+
+ with tabs[idx]:
+ distribution.render_distribution(clusters, frame=source_cfg["frame"])
+ idx += 1
+
+ with tabs[idx]:
+ if pair:
+ render_cross_model(base_ctx, pair)
+ else:
+ st.info("์ด ์บ ํ์ธ์ ๋ํ Cross-Model ๋ถ์์ด ์์ง ์์ต๋๋ค.")
+ idx += 1
+
+ with tabs[idx]:
+ if pair:
+ render_content_actions(base_ctx, pair)
+ else:
+ st.info("Cross-Model ๋ถ์ ์๋ฃ ํ ์ฝํ
์ธ ๊ฐ์ด๋๋ฅผ ์ฌ์ฉํ ์ ์์ต๋๋ค.")
+ idx += 1
+
+ with tabs[idx]:
+ render_keyword_suggestions(clusters, frame=source_cfg["frame"])
+ idx += 1
+
+ if show_unified:
+ with tabs[idx]:
+ render_unified_scoring(base_ctx, pair)
+ idx += 1
+
+ with tabs[idx]:
+ guide.render_data_flow()
diff --git a/features/research/content_actions.py b/features/research/content_actions.py
new file mode 100644
index 0000000000000000000000000000000000000000..485d9911dbad303459024eb6f3b802eddda84c02
--- /dev/null
+++ b/features/research/content_actions.py
@@ -0,0 +1,217 @@
+"""์ฝํ
์ธ ์ก์
๊ฐ์ด๋ (R-5).
+
+GapScore OPPORTUNITY ํ ํฝ ๊ธฐ๋ฐ ์ฝํ
์ธ ์ ์ ์ ์.
+Cross-Model ๋ถ์ ๋ฐ์ดํฐ๋ฅผ ํ์ฉํ์ฌ ๊ตฌ์ฒด์ ์ก์
์์ดํ
์์ฑ.
+"""
+
+import streamlit as st
+import pandas as pd
+
+from core.supabase_client import get_gap_scores, get_topic_clusters
+
+
+# Content strategy templates per quadrant
+STRATEGY_TEMPLATES = {
+ "OPPORTUNITY": {
+ "priority": "๐ด ๋์",
+ "action": "์ฝํ
์ธ ์ ์",
+ "detail": (
+ "์ฌ์ฉ์๊ฐ ์์ฃผ ๊ฒ์ํ์ง๋ง AI๊ฐ ์ธ์ฉํ ๋งํ ์ฝํ
์ธ ๊ฐ ๋ถ์กฑํฉ๋๋ค. "
+ "์ด ํ ํฝ์ ๋ํ ์ ๋ฌธ ์ฝํ
์ธ ๋ฅผ ์ ์ํ๋ฉด AI ๋ต๋ณ์ ์ธ์ฉ๋ ํ๋ฅ ์ด ๋์ต๋๋ค."
+ ),
+ "tactics": [
+ "FAQ ํ์ด์ง์ ์ด ํ ํฝ ๊ด๋ จ ์ง๋ฌธ-๋ต๋ณ ์ถ๊ฐ",
+ "ํต๊ณ/๋ฐ์ดํฐ ํฌํจ โ AI ์ธ์ฉ ํ๋ฅ +41% (GEO ์ฐ๊ตฌ)",
+ "30-50๋จ์ด ์๊ธฐ ์๊ฒฐํ ๋ต๋ณ ๋ฌธ๋จ ํฌํจ (Answer Capsule)",
+ "Schema Markup ์ถ๊ฐ โ AI ์ธ์ฉ ํ๋ฅ 2.5๋ฐฐ ์ฆ๊ฐ",
+ ],
+ },
+ "SATURATED": {
+ "priority": "๐ก ์ค๊ฐ",
+ "action": "์ฐจ๋ณํ ๊ฐํ",
+ "detail": (
+ "์์์ ๊ณต๊ธ ๋ชจ๋ ๋์ ๊ฒฝ์ ํ ํฝ์
๋๋ค. "
+ "๊ธฐ์กด ์ฝํ
์ธ ์ ์ฐจ๋ณํ๋ ์ ๋ฌธ์ฑ์ด๋ ๊ณ ์ ๋ฐ์ดํฐ๊ฐ ํ์ํฉ๋๋ค."
+ ),
+ "tactics": [
+ "์์ฒด ์ฐ๊ตฌ ๋ฐ์ดํฐ/์ผ์ด์ค ์คํฐ๋ ์ถ๊ฐ",
+ "๊ธฐ์กด ์ธ์ฉ ์์ค ๋ถ์ โ ๋น ์ง ๊ฐ๋(angle) ๋ฐ๊ตด",
+ "E-E-A-T ์ ํธ ๊ฐํ (์ ์ ์ ๋ฌธ์ฑ, ์ธ์ฉ ์ถ์ฒ ๋ช
์)",
+ "๋น๊ตํ/๋ฐ์ดํฐ ์๊ฐํ๋ก ์ ๋ณด ๋ฐ๋ ๋์ด๊ธฐ",
+ ],
+ },
+ "LATENT_AUTHORITY": {
+ "priority": "๐ข ๋ฎ์",
+ "action": "์ ์ง + ๋ชจ๋ํฐ๋ง",
+ "detail": (
+ "์ด๋ฏธ AI์ ์ธ์ฉ๋๊ณ ์์ง๋ง ๊ฒ์ ์์๊ฐ ๋ฎ์ต๋๋ค. "
+ "๊ธฐ์กด ์ฝํ
์ธ ๋ฅผ ์ ์งํ๋ฉฐ ์์ ๋ณํ๋ฅผ ๋ชจ๋ํฐ๋งํ์ธ์."
+ ),
+ "tactics": [
+ "๊ธฐ์กด ์ธ์ฉ ์ฝํ
์ธ ์ ์ต์ ์
๋ฐ์ดํธ ์ ์ง",
+ "Demand ์ฆ๊ฐ ์ถ์ธ ๊ฐ์ง ์ ์ฝํ
์ธ ํ์ฅ",
+ "์ธ์ฉ๋๋ ๊ตฌ์ฒด์ ๋ฌธ์ฅ/๊ตฌ์ ํ์
โ ๊ฐํ",
+ ],
+ },
+ "NICHE": {
+ "priority": "โช ๊ด๋ง",
+ "action": "์ ํ์ ์คํ",
+ "detail": (
+ "์์์ ๊ณต๊ธ ๋ชจ๋ ๋ฎ์ ํ์ ์์ญ์
๋๋ค. "
+ "์์ฅ์ด ์ฑ์ฅํ๋ฉด ์ ์ ํจ๊ณผ๋ฅผ ๋ณผ ์ ์์ต๋๋ค."
+ ),
+ "tactics": [
+ "๋ฎ์ ๋น์ฉ์ผ๋ก ๊ธฐ๋ณธ ์ฝํ
์ธ ๋ง๋ จ (์ ์ )",
+ "๊ด๋ จ ํค์๋ ํธ๋ ๋ ๋ชจ๋ํฐ๋ง",
+ ],
+ },
+}
+
+
+def render_content_actions(base_ctx: dict, pair: dict):
+ """Render content action guide based on GapScore analysis."""
+ campaign_chatgpt = pair["campaign_chatgpt"]
+ campaign_gemini = pair["campaign_gemini"]
+
+ st.caption(
+ "Demand-Supply Gap ๋ถ์ ๊ฒฐ๊ณผ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก **๊ตฌ์ฒด์ ์ฝํ
์ธ ์ ๋ต**์ ์ ์ํฉ๋๋ค."
+ )
+
+ matches = get_gap_scores(campaign_chatgpt, campaign_gemini)
+ if not matches:
+ st.warning("GapScore ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.")
+ return
+
+ # Fetch cluster details for sample_fanouts and top_sources
+ chatgpt_clusters = get_topic_clusters(campaign_chatgpt, source="chatgpt")
+ gemini_clusters = get_topic_clusters(campaign_gemini, source="gemini")
+
+ chatgpt_map = {c["id"]: c for c in chatgpt_clusters}
+ gemini_map = {c["id"]: c for c in gemini_clusters}
+
+ # --- Overview: Quadrant distribution ---
+ quadrant_counts = {}
+ for m in matches:
+ q = m.get("quadrant", "NICHE")
+ quadrant_counts[q] = quadrant_counts.get(q, 0) + 1
+
+ opp_count = quadrant_counts.get("OPPORTUNITY", 0)
+ sat_count = quadrant_counts.get("SATURATED", 0)
+
+ col1, col2, col3 = st.columns(3)
+ with col1:
+ st.metric("์ฝํ
์ธ ์ ์ ํ์", f"{opp_count}๊ฐ ํ ํฝ",
+ help="OPPORTUNITY: Demand ๋์ + Supply ๋ฎ์")
+ with col2:
+ st.metric("์ฐจ๋ณํ ํ์", f"{sat_count}๊ฐ ํ ํฝ",
+ help="SATURATED: Demand ๋์ + Supply ๋์")
+ with col3:
+ st.metric("์ด ๋ถ์ ํ ํฝ", f"{len(matches)}๊ฐ")
+
+ st.markdown("---")
+
+ # --- Priority Action List ---
+ st.markdown("### ์ฐ์ ์ก์
๋ฆฌ์คํธ")
+ st.caption("GapScore ์์ผ๋ก ์ ๋ ฌ. OPPORTUNITY ํ ํฝ์ด ์ต์ฐ์ ์
๋๋ค.")
+
+ # Summary table
+ rows = []
+ for i, m in enumerate(matches, 1):
+ q = m.get("quadrant", "NICHE")
+ strategy = STRATEGY_TEMPLATES.get(q, STRATEGY_TEMPLATES["NICHE"])
+ rows.append({
+ "์์": i,
+ "ํ ํฝ (Demand)": (m.get("chatgpt_label") or "")[:35],
+ "ํ ํฝ (Supply)": (m.get("gemini_label") or "")[:35],
+ "GapScore": f"{float(m.get('gap_score', 0)):.4f}",
+ "Quadrant": q.replace("_", " ").title(),
+ "์ก์
": strategy["action"],
+ "์ฐ์ ์์": strategy["priority"],
+ })
+
+ df = pd.DataFrame(rows)
+ st.dataframe(df, use_container_width=True, hide_index=True)
+
+ st.markdown("---")
+
+ # --- Detailed Action Cards for Top OPPORTUNITY topics ---
+ opportunity_matches = [m for m in matches if m.get("quadrant") == "OPPORTUNITY"]
+
+ if opportunity_matches:
+ st.markdown("### OPPORTUNITY ํ ํฝ ์์ธ ๊ฐ์ด๋")
+ st.caption(
+ "์ฝํ
์ธ ์ ์ ROI๊ฐ ๊ฐ์ฅ ๋์ ํ ํฝ์
๋๋ค. "
+ "์ฌ์ฉ์๊ฐ ์์ฃผ ๋ฌป์ง๋ง AI๊ฐ ์ธ์ฉํ ์ฝํ
์ธ ๊ฐ ๋ถ์กฑํฉ๋๋ค."
+ )
+
+ for i, m in enumerate(opportunity_matches[:10], 1):
+ chatgpt_label = m.get("chatgpt_label", "Unknown")
+ gap = float(m.get("gap_score") or 0)
+ demand = float(m.get("demand_percentile") or 0)
+ supply = float(m.get("supply_percentile") or 0)
+
+ chatgpt_detail = chatgpt_map.get(m["chatgpt_cluster_id"], {})
+ gemini_detail = gemini_map.get(m["gemini_cluster_id"], {})
+
+ with st.expander(f"#{i} {chatgpt_label} (GapScore: {gap:.4f})", key=f"research:opp_action:{m['id']}"):
+ left, right = st.columns(2)
+
+ with left:
+ st.markdown("**Gap ๋ถ์**")
+ st.write(f"- Demand (ChatGPT): {demand:.0%}")
+ st.write(f"- Supply (Gemini): {supply:.0%}")
+ st.write(f"- Gap: Demand {demand:.0%} vs Supply {supply:.0%}")
+
+ # Sample fanouts = what users ask
+ samples = chatgpt_detail.get("sample_fanouts") or []
+ if samples:
+ st.markdown("**์ฌ์ฉ์๊ฐ ๋ฌป๋ ์ง๋ฌธ๋ค:**")
+ for s in samples[:5]:
+ st.write(f" - {s}")
+
+ with right:
+ st.markdown("**์ฝํ
์ธ ์ ๋ต**")
+ strategy = STRATEGY_TEMPLATES["OPPORTUNITY"]
+ st.info(strategy["detail"])
+
+ st.markdown("**๊ตฌ์ฒด์ ์คํ ํญ๋ชฉ:**")
+ for tactic in strategy["tactics"]:
+ st.write(f"- {tactic}")
+
+ # Show Gemini citation examples if available
+ gemini_samples = gemini_detail.get("sample_fanouts") or []
+ if gemini_samples:
+ st.markdown("**AI๊ฐ ํ์ฌ ์ธ์ฉํ๋ ๋ฌธ๊ตฌ ์์:**")
+ for s in gemini_samples[:3]:
+ st.write(f" > {s}")
+ st.caption("์ด๋ฐ ํํ์ ๋ฌธ์ฅ์ ์ฝํ
์ธ ์ ํฌํจํ์ธ์.")
+
+ # Show top sources if available
+ top_sources = gemini_detail.get("top_sources")
+ if top_sources and isinstance(top_sources, list):
+ domains = [s.get("host_url", s.get("url", "")) for s in top_sources[:5]]
+ if domains:
+ st.markdown("**ํ์ฌ AI ์ธ์ฉ ์ถ์ฒ:**")
+ for d in domains:
+ st.write(f" - {d}")
+ st.caption("์ด ์ถ์ฒ๋ค์ด ๋ค๋ฃจ์ง ์๋ ๊ฐ๋๋ฅผ ์ฐพ์ผ์ธ์.")
+ else:
+ st.success("๋ชจ๋ ํ ํฝ์ ์ถฉ๋ถํ Supply๊ฐ ์์ต๋๋ค. ์ฐจ๋ณํ ์ ๋ต์ ์ง์คํ์ธ์.")
+
+ # --- SATURATED topics brief ---
+ saturated_matches = [m for m in matches if m.get("quadrant") == "SATURATED"]
+ if saturated_matches:
+ st.markdown("---")
+ st.markdown("### SATURATED ํ ํฝ ์์ฝ")
+ st.caption("์ฐจ๋ณํ๊ฐ ํ์ํ ๊ฒฝ์ ํ ํฝ์
๋๋ค.")
+
+ for i, m in enumerate(saturated_matches[:5], 1):
+ label = m.get("chatgpt_label", "Unknown")
+ gap = float(m.get("gap_score", 0))
+
+ with st.expander(f"#{i} {label} (GapScore: {gap:.4f})", key=f"research:sat_action:{m['id']}"):
+ strategy = STRATEGY_TEMPLATES["SATURATED"]
+ st.info(strategy["detail"])
+ st.markdown("**์คํ ํญ๋ชฉ:**")
+ for tactic in strategy["tactics"]:
+ st.write(f"- {tactic}")
diff --git a/features/research/cross_model.py b/features/research/cross_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..c8081a85bfab947d0d031b51ce1663b90d544fdc
--- /dev/null
+++ b/features/research/cross_model.py
@@ -0,0 +1,652 @@
+"""Cross-Model Quadrant Matrix + Convergence Analysis (ADR-014 Phase 3).
+
+Demand (ChatGPT fanout) vs Supply (Gemini citation) gap analysis.
+Scatter plot + GapScore Top 10 + Convergence (Venn, matched/unmatched topics).
+"""
+
+import statistics
+
+import streamlit as st
+import pandas as pd
+import plotly.graph_objects as go
+
+from core.supabase_client import (
+ get_cross_model_analysis, get_gap_scores, get_topic_clusters,
+)
+
+
+# Quadrant colors matching ADR-014 spec
+QUADRANT_COLORS = {
+ "OPPORTUNITY": "#10B981", # Green
+ "SATURATED": "#3B82F6", # Blue
+ "LATENT_AUTHORITY": "#F59E0B", # Amber
+ "NICHE": "#9CA3AF", # Gray
+}
+
+QUADRANT_LABELS = {
+ "OPPORTUNITY": "Opportunity",
+ "SATURATED": "Saturated",
+ "LATENT_AUTHORITY": "Latent Authority",
+ "NICHE": "Niche",
+}
+
+QUADRANT_ACTIONS = {
+ "OPPORTUNITY": "์ฌ์ฉ์ ๊ด์ฌ์ด ๋์ง๋ง ์ธ์ฉ ์ฝํ
์ธ ๊ฐ ๋ถ์กฑํฉ๋๋ค. "
+ "์ด ์ฃผ์ ์ ์ ๋ฌธ ์ฝํ
์ธ ๋ฅผ ์ ์ํ๋ฉด AI ๋ต๋ณ์ ์ธ์ฉ๋ ๊ฐ๋ฅ์ฑ์ด ๋์ต๋๋ค.",
+ "SATURATED": "์์์ ๊ณต๊ธ ๋ชจ๋ ๋์ ๊ฒฝ์ ์์ญ์
๋๋ค. "
+ "์ฐจ๋ณํ๋ ์ ๋ฌธ์ฑ์ด๋ ๊ณ ์ ๋ฐ์ดํฐ๋ก ๊ธฐ์กด ์ฝํ
์ธ ์ ์ฐจ๋ณํํ์ธ์.",
+ "LATENT_AUTHORITY": "์ด๋ฏธ ์ธ์ฉ๋๊ณ ์์ง๋ง ๊ฒ์ ์์๋ ๋ฎ์ต๋๋ค. "
+ "๊ธฐ์กด ์ฝํ
์ธ ๋ฅผ ํ์ฉํ์ฌ ๋ธ๋๋ ๊ถ์๋ฅผ ๊ฐํํ์ธ์.",
+ "NICHE": "์์์ ๊ณต๊ธ ๋ชจ๋ ๋ฎ์ ํ์ ์์ญ์
๋๋ค. "
+ "์์ฅ ๋ณํ๋ฅผ ๋ชจ๋ํฐ๋งํ๋ฉฐ ๊ธฐํ๊ฐ ์ปค์ง๋ฉด ์ง์
์ ๊ฒํ ํ์ธ์.",
+}
+
+
+def render_cross_model(base_ctx: dict, pair: dict):
+ """Render cross-model quadrant matrix UI.
+
+ Args:
+ base_ctx: Dashboard base context with campaign_id etc.
+ pair: Cross-model pair dict from find_cross_model_pair().
+ """
+ campaign_chatgpt = pair["campaign_chatgpt"]
+ campaign_gemini = pair["campaign_gemini"]
+
+ st.caption(
+ "ChatGPT์ Gemini ๋ AI ๋ชจ๋ธ์ ํ ํฝ์ ๋น๊ตํ์ฌ "
+ "**์ฝํ
์ธ ์์-๊ณต๊ธ Gap**์ ๋ถ์ํฉ๋๋ค."
+ )
+
+ with st.expander("Cross-Model ๋ถ์์ด๋?", expanded=False):
+ st.markdown("""
+**์ ๋ ๋ชจ๋ธ์ ๋น๊ตํ๋์?**
+
+ChatGPT์ Gemini๋ ๊ฐ์ ์ฃผ์ ์ ๋ํด ์๋ก ๋ค๋ฅธ ๋ฐฉ์์ผ๋ก ์ ๋ณด๋ฅผ ํ์ํฉ๋๋ค:
+- **ChatGPT (Demand)**: ์ฌ์ฉ์ ์ง๋ฌธ์ ์ฌ๋ฌ ํ์ ์ง๋ฌธ์ผ๋ก ๋ถํดํ์ฌ ๊ฒ์ํฉ๋๋ค.
+ AI๊ฐ ์์ฃผ ๊ฒ์ํ๋ ํ ํฝ = **์ฌ์ฉ์ ๊ด์ฌ์ด ๋์ ํ ํฝ**
+- **Gemini (Supply)**: ๋ต๋ณ์ ์ค์ ์น ์ฝํ
์ธ ๋ฅผ ์ธ์ฉํฉ๋๋ค.
+ AI๊ฐ ์์ฃผ ์ธ์ฉํ๋ ํ ํฝ = **์ฝํ
์ธ ๊ณต๊ธ์ด ์ถฉ๋ถํ ํ ํฝ**
+
+**๋ ์ ํธ๋ฅผ ๊ต์ฐจ ๋ถ์**ํ๋ฉด, "์ฌ๋๋ค์ด ๋ง์ด ๋ฌผ์ด๋ณด์ง๋ง ์์ง ์ข์ ์ฝํ
์ธ ๊ฐ ์๋ ์์ญ"์
+๋ฐ์ดํฐ ๊ธฐ๋ฐ์ผ๋ก ๋ฐ๊ฒฌํ ์ ์์ต๋๋ค.
+
+**๋ถ์ ํ๋ก์ธ์ค:**
+```
+ChatGPT ํ์ ์ง๋ฌธ ํด๋ฌ์คํฐ๋ง (Demand ํ ํฝ)
+ โ
+Gemini ์ธ์ฉ ๋ฌธ๊ตฌ ํด๋ฌ์คํฐ๋ง (Supply ํ ํฝ)
+ โ
+๋ ๋ชจ๋ธ์ ์ ์ฌ ํ ํฝ ๋งค์นญ (Label + Centroid ์ ์ฌ๋)
+ โ
+Demand-Supply Gap ๊ณ์ฐ โ Quadrant ๋ถ๋ฅ
+```
+""")
+
+ # Fetch data
+ analysis = get_cross_model_analysis(campaign_chatgpt, campaign_gemini)
+ matches = get_gap_scores(campaign_chatgpt, campaign_gemini)
+
+ if not analysis or not matches:
+ st.warning("Cross-Model ๋ถ์ ๋ฐ์ดํฐ๋ฅผ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค.")
+ return
+
+ # Fetch clusters for convergence analysis
+ chatgpt_clusters = get_topic_clusters(campaign_chatgpt, source="chatgpt")
+ gemini_clusters = get_topic_clusters(campaign_gemini, source="gemini")
+
+ # --- A) Alignment Overview ---
+ _render_overview(analysis, matches)
+
+ st.markdown("---")
+
+ # --- B) Quadrant Scatter Plot ---
+ _render_scatter(matches)
+
+ st.markdown("---")
+
+ # --- C) GapScore Top 10 ---
+ _render_top_gaps(matches)
+
+ st.markdown("---")
+
+ # --- D) Convergence Analysis (Phase 3.4) ---
+ _render_convergence(
+ analysis, matches, chatgpt_clusters, gemini_clusters,
+ )
+
+
+def _render_overview(analysis: dict, matches: list[dict]):
+ """Render alignment overview metrics."""
+ alignment = float(analysis.get("nmi_score", 0))
+ total_matched = int(analysis.get("total_matched_topics", 0))
+
+ # Count by quadrant
+ quadrant_counts = {}
+ gap_scores = []
+ for m in matches:
+ q = m.get("quadrant", "NICHE")
+ quadrant_counts[q] = quadrant_counts.get(q, 0) + 1
+ gap_scores.append(float(m.get("gap_score", 0)))
+
+ opp_count = quadrant_counts.get("OPPORTUNITY", 0)
+ mean_gap = sum(gap_scores) / len(gap_scores) if gap_scores else 0
+
+ # Color-code alignment
+ if alignment >= 0.8:
+ align_color = "green"
+ align_label = "Strong"
+ elif alignment >= 0.6:
+ align_color = "orange"
+ align_label = "Moderate"
+ else:
+ align_color = "red"
+ align_label = "Weak"
+
+ c1, c2, c3, c4 = st.columns(4)
+ with c1:
+ st.metric(
+ "๋ชจ๋ธ ์ ํฉ๋", f"{alignment:.4f}",
+ help="ChatGPT์ Gemini ํ ํฝ ๋งค์นญ ํ์ง. "
+ "0.5 ์ด์์ด๋ฉด ๋ ๋ชจ๋ธ์ด ์ ์ฌํ ์ฃผ์ ๋ฅผ ๋ค๋ฃจ๊ณ ์์ด Gap ๋ถ์์ด ์ ๋ขฐํ ์ ์์ต๋๋ค.",
+ )
+ st.caption(f":{align_color}[{align_label}]")
+ with c2:
+ st.metric(
+ "๋งค์นญ๋ ํ ํฝ", f"{total_matched}์",
+ help="๋ AI ๋ชจ๋ธ์์ ๋์ผํ ์ฃผ์ ๋ก ๋งค์นญ๋ ํ ํฝ ์ ์",
+ )
+ with c3:
+ st.metric(
+ "์ฝํ
์ธ ๊ธฐํ", f"{opp_count}๊ฐ",
+ help="Demand ๋์ + Supply ๋ฎ์์ธ ํ ํฝ ์. "
+ "์ด ํ ํฝ๋ค์ ์ฝํ
์ธ ๋ฅผ ๋ง๋ค๋ฉด AI ์ธ์ฉ ๊ฐ๋ฅ์ฑ์ด ๋์ต๋๋ค.",
+ )
+ with c4:
+ st.metric(
+ "ํ๊ท GapScore", f"{mean_gap:.4f}",
+ help="์ ์ฒด ๋งค์นญ ํ ํฝ์ ํ๊ท Demand-Supply Gap. "
+ "๋์์๋ก ์ ๋ฐ์ ์ผ๋ก ์ฝํ
์ธ ๊ธฐํ๊ฐ ๋ง์์ ์๋ฏธํฉ๋๋ค.",
+ )
+
+
+def _render_scatter(matches: list[dict]):
+ """Render demand vs supply quadrant scatter plot."""
+ st.markdown("""
+**ChatGPT๊ฐ ์์ฃผ ๊ฒ์ํ๋ ํ ํฝ**(Demand)๊ณผ **Gemini๊ฐ ์ค์ ์ธ์ฉํ๋ ํ ํฝ**(Supply)์ ๋งค์นญํ์ฌ
+์ฝํ
์ธ ๊ธฐํ๋ฅผ ์๊ฐํํฉ๋๋ค. ๊ฐ ์ ์ ๋ AI ๋ชจ๋ธ์์ ๋์ผํ ์ฃผ์ ๋ก ๋งค์นญ๋ ํ ํฝ ์์
๋๋ค.
+
+| Quadrant | ์์น | ์๋ฏธ | ์ ๋ต |
+|----------|------|------|------|
+| **Opportunity** | ์ข์๋จ | Demand ๋์ + Supply ๋ฎ์ | ์ฝํ
์ธ ์ ์ ๊ธฐํ -- ์ฐ์ ์ ์ |
+| **Saturated** | ์ฐ์๋จ | Demand ๋์ + Supply ๋์ | ์ฐจ๋ณํ ํ์ -- ์ ๋ฌธ์ฑ ๊ฐํ |
+| **Latent Authority** | ์ฐํ๋จ | Demand ๋ฎ์ + Supply ๋์ | ์ด๋ฏธ ์ธ์ฉ๋จ -- ๋ธ๋๋ ๊ถ์ ํ์ฉ |
+| **Niche** | ์ขํ๋จ | Demand ๋ฎ์ + Supply ๋ฎ์ | ๋ฎ์ ์ฐ์ ์์ -- ๋ณํ ๋ชจ๋ํฐ๋ง |
+""")
+
+ xs, ys, colors, hover_texts, sizes = [], [], [], [], []
+
+ for m in matches:
+ supply = float(m.get("supply_percentile", 0))
+ demand = float(m.get("demand_percentile", 0))
+ quadrant = m.get("quadrant", "NICHE")
+ gap = float(m.get("gap_score", 0))
+ match_score = float(m.get("match_score", 0))
+ chatgpt_label = m.get("chatgpt_label", "")
+ gemini_label = m.get("gemini_label", "")
+
+ xs.append(supply)
+ ys.append(demand)
+ colors.append(QUADRANT_COLORS.get(quadrant, "#9CA3AF"))
+ sizes.append(max(8, min(30, gap * 300)))
+
+ hover_texts.append(
+ f"{chatgpt_label}
"
+ f"Gemini: {gemini_label}
"
+ f"Demand: {demand:.2%}
"
+ f"Supply: {supply:.2%}
"
+ f"GapScore: {gap:.4f}
"
+ f"Match: {match_score:.4f}
"
+ f"Quadrant: {QUADRANT_LABELS.get(quadrant, quadrant)}"
+ )
+
+ fig = go.Figure()
+
+ fig.add_trace(go.Scatter(
+ x=xs,
+ y=ys,
+ mode="markers",
+ marker=dict(
+ size=sizes,
+ color=colors,
+ opacity=0.7,
+ line=dict(width=0.5, color="#333"),
+ ),
+ text=hover_texts,
+ hoverinfo="text",
+ showlegend=False,
+ ))
+
+ # Compute actual medians from data (matches quadrant_method=p50_median in gap scorer)
+ demand_median = statistics.median(ys) if len(ys) > 1 else 0.5
+ supply_median = statistics.median(xs) if len(xs) > 1 else 0.5
+
+ fig.add_hline(y=demand_median, line_dash="dash", line_color="#9CA3AF", opacity=0.5)
+ fig.add_vline(x=supply_median, line_dash="dash", line_color="#9CA3AF", opacity=0.5)
+
+ # Quadrant annotations โ axes: X=Supply, Y=Demand
+ # OPPORTUNITY: high demand (top) + low supply (left) โ top-left
+ # SATURATED: high demand (top) + high supply (right) โ top-right
+ # LATENT_AUTHORITY: low demand (bottom) + high supply (right) โ bottom-right
+ # NICHE: low demand (bottom) + low supply (left) โ bottom-left
+ fig.add_annotation(x=0.05, y=0.95, text="Opportunity",
+ showarrow=False, font=dict(size=11, color=QUADRANT_COLORS["OPPORTUNITY"]))
+ fig.add_annotation(x=0.95, y=0.95, text="Saturated",
+ showarrow=False, font=dict(size=11, color=QUADRANT_COLORS["SATURATED"]))
+ fig.add_annotation(x=0.95, y=0.05, text="Latent Authority",
+ showarrow=False, font=dict(size=11, color=QUADRANT_COLORS["LATENT_AUTHORITY"]))
+ fig.add_annotation(x=0.05, y=0.05, text="Niche",
+ showarrow=False, font=dict(size=11, color=QUADRANT_COLORS["NICHE"]))
+
+ fig.update_layout(
+ title="Demand vs Supply Quadrant Matrix",
+ xaxis_title="Supply Percentile (Gemini Citation)",
+ yaxis_title="Demand Percentile (ChatGPT Fanout)",
+ xaxis=dict(range=[-0.05, 1.05]),
+ yaxis=dict(range=[-0.05, 1.05]),
+ height=600,
+ template="plotly_white",
+ hoverlabel=dict(bgcolor="white", font_size=12),
+ )
+
+ st.plotly_chart(fig, use_container_width=True, key="cross_model:scatter", config={"displayModeBar": False})
+
+ # Quadrant count summary
+ quadrant_counts = {}
+ for m in matches:
+ q = m.get("quadrant", "NICHE")
+ quadrant_counts[q] = quadrant_counts.get(q, 0) + 1
+
+ q1, q2, q3, q4 = st.columns(4)
+ with q1:
+ st.metric("๐ข Opportunity", f"{quadrant_counts.get('OPPORTUNITY', 0)}๊ฐ",
+ help="AI๊ฐ ์์ฃผ ๊ฒ์ํ์ง๋ง ์ธ์ฉ ์ฝํ
์ธ ๊ฐ ๋ถ์กฑํ ํ ํฝ. ์ฝํ
์ธ ์ ์ ๊ธฐํ.")
+ with q2:
+ st.metric("๐ต Saturated", f"{quadrant_counts.get('SATURATED', 0)}๊ฐ",
+ help="๊ฒ์๋ ๋ง๊ณ ์ธ์ฉ๋ ๋ง์ ๊ฒฝ์ ํ ํฝ. ์ฐจ๋ณํ ์ ๋ต ํ์.")
+ with q3:
+ st.metric("๐ก Latent Authority", f"{quadrant_counts.get('LATENT_AUTHORITY', 0)}๊ฐ",
+ help="์ด๋ฏธ ์ธ์ฉ๋๊ณ ์์ง๋ง ๊ฒ์ ์์๋ ๋ฎ์ ํ ํฝ. ๋ธ๋๋ ๊ถ์ ํ์ฉ.")
+ with q4:
+ st.metric("โช Niche", f"{quadrant_counts.get('NICHE', 0)}๊ฐ",
+ help="์์์ ๊ณต๊ธ ๋ชจ๋ ๋ฎ์ ํ์ ์์ญ. ๋ณํ ๋ชจ๋ํฐ๋ง.")
+
+ st.caption(f"Demand Median: {demand_median:.4f} | Supply Median: {supply_median:.4f}")
+
+
+def _render_top_gaps(matches: list[dict]):
+ """Render GapScore Top 10 with detail expanders."""
+ st.markdown("#### GapScore Top 10")
+ st.caption("Demand-Supply Gap์ด ํฐ ํ ํฝ์ผ์๋ก ์ฝํ
์ธ ๊ธฐํ๊ฐ ๋์ต๋๋ค.")
+
+ top10 = matches[:10]
+
+ for i, m in enumerate(top10, 1):
+ chatgpt_label = m.get("chatgpt_label", "Unknown")
+ gemini_label = m.get("gemini_label", "Unknown")
+ gap = float(m.get("gap_score", 0))
+ quadrant = m.get("quadrant", "NICHE")
+
+ with st.expander(
+ f"#{i} {chatgpt_label} | GapScore: {gap:.4f}",
+ key=f"cross_model:gap_{m['id']}",
+ ):
+ left, right = st.columns(2)
+
+ with left:
+ demand = float(m.get("demand_percentile") or 0)
+ supply = float(m.get("supply_percentile") or 0)
+ match_score = float(m.get("match_score") or 0)
+
+ st.markdown("**Metrics**")
+ st.write(f"- Demand Percentile: {demand:.2%}")
+ st.write(f"- Supply Percentile: {supply:.2%}")
+ st.write(f"- Match Score: {match_score:.4f}")
+ st.write(f"- ChatGPT Topic: {chatgpt_label}")
+ st.write(f"- Gemini Topic: {gemini_label}")
+
+ with right:
+ q_label = QUADRANT_LABELS.get(quadrant, quadrant)
+ action = QUADRANT_ACTIONS.get(quadrant, "")
+
+ st.markdown("**Quadrant & Action**")
+ st.markdown(f":{_st_color(quadrant)}[**{q_label}**]")
+ st.info(action)
+
+
+def _st_color(quadrant: str) -> str:
+ """Map quadrant to Streamlit markdown color name."""
+ return {
+ "OPPORTUNITY": "green",
+ "SATURATED": "blue",
+ "LATENT_AUTHORITY": "orange",
+ "NICHE": "gray",
+ }.get(quadrant, "gray")
+
+
+# ---------------------------------------------------------------------------
+# Phase 3.4: Convergence Analysis
+# ---------------------------------------------------------------------------
+
+def _render_convergence(
+ analysis: dict,
+ matches: list[dict],
+ chatgpt_clusters: list[dict],
+ gemini_clusters: list[dict],
+):
+ """Render convergence analysis: Venn, matched/unmatched topic lists."""
+ st.markdown("#### ์๋ ด ๋ถ์ (Convergence)")
+ st.caption(
+ "ChatGPT(Demand)์ Gemini(Supply) ํ ํฝ์ด ์ผ๋ง๋ ๊ฒน์น๋์ง ๋ถ์ํฉ๋๋ค. "
+ "๋งค์นญ๋์ง ์์ ํ ํฝ์ ํ์ชฝ ๋ชจ๋ธ์์๋ง ๋ํ๋๋ ๊ณ ์ ์ ํธ์
๋๋ค."
+ )
+
+ # Compute matched / unmatched sets
+ matched_chatgpt_ids = {m["chatgpt_cluster_id"] for m in matches}
+ matched_gemini_ids = {m["gemini_cluster_id"] for m in matches}
+
+ all_chatgpt_ids = {c["id"] for c in chatgpt_clusters}
+ all_gemini_ids = {c["id"] for c in gemini_clusters}
+
+ unmatched_chatgpt_ids = all_chatgpt_ids - matched_chatgpt_ids
+ unmatched_gemini_ids = all_gemini_ids - matched_gemini_ids
+
+ n_chatgpt_only = len(unmatched_chatgpt_ids)
+ n_matched = len(matches)
+ n_gemini_only = len(unmatched_gemini_ids)
+ n_total = n_chatgpt_only + n_matched + n_gemini_only
+
+ # --- Venn-style overlap chart ---
+ _render_venn_chart(n_chatgpt_only, n_matched, n_gemini_only)
+
+ # --- Alignment Score gauge ---
+ alignment = float(analysis.get("nmi_score") or 0)
+ _render_alignment_gauge(alignment, n_matched, n_total)
+
+ st.markdown("---")
+
+ # --- Matched topics table ---
+ _render_matched_topics(matches)
+
+ st.markdown("---")
+
+ # --- Unmatched topics per model ---
+ _render_unmatched_topics(
+ chatgpt_clusters, gemini_clusters,
+ unmatched_chatgpt_ids, unmatched_gemini_ids,
+ )
+
+
+def _render_venn_chart(
+ n_chatgpt_only: int, n_matched: int, n_gemini_only: int,
+):
+ """Render Venn-style horizontal stacked bar showing overlap proportions."""
+ n_total = n_chatgpt_only + n_matched + n_gemini_only
+ if n_total == 0:
+ return
+
+ pct_chatgpt = n_chatgpt_only / n_total * 100
+ pct_matched = n_matched / n_total * 100
+ pct_gemini = n_gemini_only / n_total * 100
+
+ fig = go.Figure()
+
+ fig.add_trace(go.Bar(
+ y=["ํ ํฝ ๋ถํฌ"],
+ x=[pct_chatgpt],
+ name=f"ChatGPT ๊ณ ์ ({n_chatgpt_only})",
+ orientation="h",
+ marker_color="#3B82F6",
+ text=f"{pct_chatgpt:.0f}%",
+ textposition="inside",
+ hovertemplate=(
+ f"ChatGPT ๊ณ ์ ํ ํฝ: {n_chatgpt_only}๊ฐ
"
+ f"๋น์จ: {pct_chatgpt:.1f}%"
+ ),
+ ))
+ fig.add_trace(go.Bar(
+ y=["ํ ํฝ ๋ถํฌ"],
+ x=[pct_matched],
+ name=f"๊ณตํต ๋งค์นญ ({n_matched})",
+ orientation="h",
+ marker_color="#10B981",
+ text=f"{pct_matched:.0f}%",
+ textposition="inside",
+ hovertemplate=(
+ f"๊ณตํต ๋งค์นญ ํ ํฝ: {n_matched}๊ฐ
"
+ f"๋น์จ: {pct_matched:.1f}%"
+ ),
+ ))
+ fig.add_trace(go.Bar(
+ y=["ํ ํฝ ๋ถํฌ"],
+ x=[pct_gemini],
+ name=f"Gemini ๊ณ ์ ({n_gemini_only})",
+ orientation="h",
+ marker_color="#F59E0B",
+ text=f"{pct_gemini:.0f}%",
+ textposition="inside",
+ hovertemplate=(
+ f"Gemini ๊ณ ์ ํ ํฝ: {n_gemini_only}๊ฐ
"
+ f"๋น์จ: {pct_gemini:.1f}%"
+ ),
+ ))
+
+ fig.update_layout(
+ barmode="stack",
+ height=120,
+ margin=dict(l=0, r=0, t=30, b=0),
+ title="ํ ํฝ ๊ฒน์นจ ๋ถํฌ (Venn)",
+ xaxis=dict(title="๋น์จ (%)", range=[0, 100]),
+ yaxis=dict(visible=False),
+ template="plotly_white",
+ legend=dict(orientation="h", yanchor="bottom", y=-0.5),
+ )
+
+ st.plotly_chart(fig, use_container_width=True, key="cross_model:venn", config={"displayModeBar": False})
+
+ # Summary metrics
+ c1, c2, c3 = st.columns(3)
+ with c1:
+ st.metric(
+ "ChatGPT ๊ณ ์ ",
+ f"{n_chatgpt_only}๊ฐ",
+ help="ChatGPT์์๋ง ๋ฐ๊ฒฌ๋ Demand ํ ํฝ. "
+ "์๋น์๊ฐ ๊ด์ฌ ์์ง๋ง Gemini๊ฐ ์์ง ์ธ์ฉํ์ง ์๋ ์์ญ.",
+ )
+ with c2:
+ st.metric(
+ "๊ณตํต ๋งค์นญ",
+ f"{n_matched}๊ฐ",
+ help="๋ ๋ชจ๋ธ ๋ชจ๋์์ ๋ฐ๊ฒฌ๋ ํ ํฝ. "
+ "Demand์ Supply๊ฐ ๋ง๋๋ ํต์ฌ ์์ญ.",
+ )
+ with c3:
+ st.metric(
+ "Gemini ๊ณ ์ ",
+ f"{n_gemini_only}๊ฐ",
+ help="Gemini์์๋ง ์ธ์ฉ๋๋ Supply ํ ํฝ. "
+ "AI๊ฐ ๊ทผ๊ฑฐ๋ก ์ฌ์ฉํ์ง๋ง ์๋น์ ๊ฒ์ ์์๊ฐ ๋ฎ์ ์์ญ.",
+ )
+
+
+def _render_alignment_gauge(alignment: float, n_matched: int, n_total: int):
+ """Render alignment score as a gauge chart with interpretation."""
+ coverage = n_matched / n_total * 100 if n_total > 0 else 0
+
+ fig = go.Figure(go.Indicator(
+ mode="gauge+number",
+ value=alignment,
+ number=dict(suffix="", valueformat=".4f"),
+ gauge=dict(
+ axis=dict(range=[0, 1], tickvals=[0, 0.3, 0.6, 0.8, 1.0]),
+ bar=dict(color="#059669"),
+ steps=[
+ dict(range=[0, 0.3], color="#FEE2E2"),
+ dict(range=[0.3, 0.6], color="#FEF3C7"),
+ dict(range=[0.6, 0.8], color="#D1FAE5"),
+ dict(range=[0.8, 1.0], color="#A7F3D0"),
+ ],
+ threshold=dict(
+ line=dict(color="#059669", width=2),
+ thickness=0.75,
+ value=alignment,
+ ),
+ ),
+ title=dict(text="Alignment Score"),
+ ))
+
+ fig.update_layout(
+ height=250,
+ margin=dict(l=30, r=30, t=50, b=10),
+ template="plotly_white",
+ )
+
+ left, right = st.columns([2, 1])
+ with left:
+ st.plotly_chart(fig, use_container_width=True, key="cross_model:gauge", config={"displayModeBar": False})
+ with right:
+ if alignment >= 0.8:
+ st.success(
+ f"**Strong** โ ๋ ๋ชจ๋ธ์ด ๋งค์ฐ ์ ์ฌํ ํ ํฝ์ ๋ค๋ฃจ๊ณ ์์ต๋๋ค. "
+ f"Gap ๋ถ์์ ์ ๋ขฐ๋๊ฐ ๋์ต๋๋ค."
+ )
+ elif alignment >= 0.6:
+ st.warning(
+ f"**Moderate** โ ๋ถ๋ถ์ ์ผ๋ก ๊ฒน์น๋ ํ ํฝ์ด ์์ต๋๋ค. "
+ f"Gap ๋ถ์์ ์ฐธ๊ณ ์ฉ์ผ๋ก ํ์ฉํ์ธ์."
+ )
+ else:
+ st.error(
+ f"**Weak** โ ๋ ๋ชจ๋ธ์ ํ ํฝ ์ ์ฌ๋๊ฐ ๋ฎ์ต๋๋ค. "
+ f"๊ฐ ๋ชจ๋ธ์ ๊ฐ๋ณ ๋ทฐ๋ฅผ ์ฐ์ ์ฐธ๊ณ ํ์ธ์."
+ )
+ st.caption(f"ํ ํฝ ์ปค๋ฒ๋ฆฌ์ง: {coverage:.1f}% ({n_matched}/{n_total})")
+
+
+def _render_matched_topics(matches: list[dict]):
+ """Render matched topic pairs table with scores."""
+ st.markdown("#### ๋งค์นญ๋ ํ ํฝ ์")
+ st.caption(
+ "๋ ๋ชจ๋ธ์์ ๋์ผํ ์ฃผ์ ๋ก ๋งค์นญ๋ ํ ํฝ์
๋๋ค. "
+ "Match Score๊ฐ ๋์์๋ก ๋ ํ ํฝ์ ์ ์ฌ๋๊ฐ ๋์ต๋๋ค."
+ )
+
+ rows = []
+ for i, m in enumerate(matches, 1):
+ rows.append({
+ "#": i,
+ "ChatGPT ํ ํฝ": (m.get("chatgpt_label") or "")[:35],
+ "Gemini ํ ํฝ": (m.get("gemini_label") or "")[:35],
+ "Match Score": f"{float(m.get('match_score') or 0):.4f}",
+ "Label Sim": f"{float(m.get('label_similarity') or 0):.4f}",
+ "Centroid Sim": f"{float(m.get('centroid_similarity') or 0):.4f}",
+ "GapScore": f"{float(m.get('gap_score') or 0):.4f}",
+ "Quadrant": QUADRANT_LABELS.get(m.get("quadrant", "NICHE"), "Niche"),
+ })
+
+ if rows:
+ df = pd.DataFrame(rows)
+ st.dataframe(df, use_container_width=True, hide_index=True)
+
+ # Match quality stats
+ if matches:
+ scores = [float(m.get("match_score") or 0) for m in matches]
+ avg_score = sum(scores) / len(scores)
+ min_score = min(scores)
+ max_score = max(scores)
+ st.caption(
+ f"Match Score โ ํ๊ท : {avg_score:.4f} | "
+ f"์ต์: {min_score:.4f} | ์ต๋: {max_score:.4f}"
+ )
+
+
+def _render_unmatched_topics(
+ chatgpt_clusters: list[dict],
+ gemini_clusters: list[dict],
+ unmatched_chatgpt_ids: set,
+ unmatched_gemini_ids: set,
+):
+ """Render unmatched (model-specific) topics."""
+ st.markdown("#### ๋ชจ๋ธ๋ณ ๊ณ ์ ํ ํฝ")
+ st.caption(
+ "ํ์ชฝ ๋ชจ๋ธ์์๋ง ๋ํ๋๋ ํ ํฝ์
๋๋ค. "
+ "๋งค์นญ๋์ง ์์ ํ ํฝ์ ํด๋น ๋ชจ๋ธ ๊ณ ์ ์ ์ ํธ๋ฅผ ๋ํ๋
๋๋ค."
+ )
+
+ left, right = st.columns(2)
+
+ with left:
+ st.markdown("**ChatGPT ๊ณ ์ ํ ํฝ (Demand Only)**")
+ st.caption(
+ "์๋น์๊ฐ ๊ด์ฌ ์์ง๋ง Gemini๊ฐ ์ธ์ฉํ์ง ์๋ ํ ํฝ. "
+ "์์ง ์ฝํ
์ธ ๊ฐ ๋ถ์กฑํ์ฌ AI๊ฐ ๊ทผ๊ฑฐ๋ฅผ ์ฐพ์ง ๋ชปํ๋ ์์ญ์ผ ์ ์์ต๋๋ค."
+ )
+
+ unmatched_chatgpt = [
+ c for c in chatgpt_clusters if c["id"] in unmatched_chatgpt_ids
+ ]
+ # Sort by opportunity_score DESC (already sorted from DB, but filter may reorder)
+ unmatched_chatgpt.sort(
+ key=lambda c: float(c.get("opportunity_score") or 0), reverse=True,
+ )
+
+ if unmatched_chatgpt:
+ rows = []
+ for c in unmatched_chatgpt[:20]:
+ rows.append({
+ "ํ ํฝ": (c.get("cluster_label") or f"Cluster-{c['id'][:8]}")[:30],
+ "Opportunity": f"{float(c.get('opportunity_score') or 0):.4f}",
+ "Attention": f"{float(c.get('attention_score') or 0):.4f}",
+ "Fanouts": c.get("fanout_count", 0),
+ })
+ st.dataframe(
+ pd.DataFrame(rows),
+ use_container_width=True,
+ hide_index=True,
+ )
+ if len(unmatched_chatgpt) > 20:
+ st.caption(f"... ์ธ {len(unmatched_chatgpt) - 20}๊ฐ")
+ else:
+ st.info("๋ชจ๋ ChatGPT ํ ํฝ์ด Gemini์ ๋งค์นญ๋์์ต๋๋ค.")
+
+ with right:
+ st.markdown("**Gemini ๊ณ ์ ํ ํฝ (Supply Only)**")
+ st.caption(
+ "AI๊ฐ ์ธ์ฉํ์ง๋ง ์๋น์ ๊ฒ์ ์์๊ฐ ๋ฎ์ ํ ํฝ. "
+ "์ ์ฌ์ ๊ถ์(Latent Authority) ์์ญ์ด๊ฑฐ๋, ํฅํ ์์๊ฐ ์ฆ๊ฐํ ์ ์์ต๋๋ค."
+ )
+
+ unmatched_gemini = [
+ c for c in gemini_clusters if c["id"] in unmatched_gemini_ids
+ ]
+ unmatched_gemini.sort(
+ key=lambda c: float(c.get("opportunity_score") or 0), reverse=True,
+ )
+
+ if unmatched_gemini:
+ rows = []
+ for c in unmatched_gemini[:20]:
+ rows.append({
+ "ํ ํฝ": (c.get("cluster_label") or f"Cluster-{c['id'][:8]}")[:30],
+ "Opportunity": f"{float(c.get('opportunity_score') or 0):.4f}",
+ "Density": f"{float(c.get('citation_density') or 0):.4f}",
+ "Citations": c.get("fanout_count", 0),
+ })
+ st.dataframe(
+ pd.DataFrame(rows),
+ use_container_width=True,
+ hide_index=True,
+ )
+ if len(unmatched_gemini) > 20:
+ st.caption(f"... ์ธ {len(unmatched_gemini) - 20}๊ฐ")
+ else:
+ st.info("๋ชจ๋ Gemini ํ ํฝ์ด ChatGPT์ ๋งค์นญ๋์์ต๋๋ค.")
diff --git a/features/research/distribution.py b/features/research/distribution.py
new file mode 100644
index 0000000000000000000000000000000000000000..13c828c2ff939e669b9c0012def2a744a8f01a22
--- /dev/null
+++ b/features/research/distribution.py
@@ -0,0 +1,186 @@
+"""Attention vs Density 4-Quadrant Scatter.
+
+X-axis: attention_score
+Y-axis: citation_density
+Quadrant lines at median values.
+Point size: fanout_count, hover: cluster_label.
+"""
+
+import statistics
+
+import streamlit as st
+import plotly.graph_objects as go
+
+
+# Quadrant colors
+QUAD_COLORS = {
+ "high_attn_low_density": "#10B981", # Green - Opportunity
+ "high_attn_high_density": "#3B82F6", # Blue - Competitive
+ "low_attn_low_density": "#9CA3AF", # Gray - Niche
+ "low_attn_high_density": "#EF4444", # Red - Crowded
+}
+
+
+def render_distribution(clusters: list[dict], frame: str = "all"):
+ """Render attention vs density quadrant scatter chart."""
+ scored = [
+ c for c in clusters
+ if c.get("attention_score") is not None
+ and c.get("citation_density") is not None
+ ]
+
+ if not scored:
+ st.info("์ค์ฝ์ด๊ฐ ๊ณ์ฐ๋ ํด๋ฌ์คํฐ๊ฐ ์์ต๋๋ค.")
+ return
+
+ # 4-Quadrant business interpretation (frame-specific)
+ if frame == "demand":
+ st.markdown("""
+ChatGPT Demand ํ ํฝ์ **๊ฒ์ ๋น๋**(๊ฐ๋ก์ถ)์ **์ถ์ฒ ๊ฒฝ์**(์ธ๋ก์ถ)๋ก ๋ถ๋ฅํฉ๋๋ค:
+- **Opportunity** (์ฐํ๋จ): ๊ฒ์ ๋น๋ ๋์ + ๊ฒฝ์ ๋ฎ์ โ ์ฝํ
์ธ ์ ์ ๊ธฐํ
+- **Competitive** (์ฐ์๋จ): ๊ฒ์ ๋น๋ ๋์ + ๊ฒฝ์ ๋์ โ ์ฐจ๋ณํ ํ์
+- **Niche** (์ขํ๋จ): ๊ฒ์ ๋น๋ ๋ฎ์ + ๊ฒฝ์ ๋ฎ์ โ ํ์ ์์ญ
+- **Crowded** (์ข์๋จ): ๊ฒ์ ๋น๋ ๋ฎ์ + ๊ฒฝ์ ๋์ โ ํฌํ ์์ญ
+""")
+ elif frame == "supply":
+ st.markdown("""
+Gemini Supply ํ ํฝ์ **์ธ์ฉ ๋น๋**(๊ฐ๋ก์ถ)์ **์ถ์ฒ ์ง์ค๋**(์ธ๋ก์ถ)๋ก ๋ถ๋ฅํฉ๋๋ค:
+- **Opportunity** (์ฐํ๋จ): ์ธ์ฉ ๋น๋ ๋์ + ์ถ์ฒ ๋ถ์ฐ โ ์ ์ถ์ฒ ์ง์
๊ธฐํ
+- **Competitive** (์ฐ์๋จ): ์ธ์ฉ ๋น๋ ๋์ + ์ถ์ฒ ์ง์ค โ ๊ธฐ์กด ๊ถ์์ ์ง๋ฐฐ
+- **Niche** (์ขํ๋จ): ์ธ์ฉ ๋น๋ ๋ฎ์ + ์ถ์ฒ ๋ถ์ฐ โ ํ์ ์์ญ
+- **Crowded** (์ข์๋จ): ์ธ์ฉ ๋น๋ ๋ฎ์ + ์ถ์ฒ ์ง์ค โ ํฌํ ์์ญ
+""")
+ else:
+ st.markdown("""
+ํ ํฝ์ **AI ๊ด์ฌ๋**(๊ฐ๋ก์ถ)์ **๊ฒฝ์ ๋ฐ๋**(์ธ๋ก์ถ)๋ก ๋ถ๋ฅํฉ๋๋ค:
+- **Opportunity** (์ฐํ๋จ): AI ๊ด์ฌ ๋์ + ๊ฒฝ์ ๋ฎ์ โ ์ฝํ
์ธ ์ ์ ๊ธฐํ
+- **Competitive** (์ฐ์๋จ): AI ๊ด์ฌ ๋์ + ๊ฒฝ์ ๋์ โ ์ฐจ๋ณํ ํ์
+- **Niche** (์ขํ๋จ): AI ๊ด์ฌ ๋ฎ์ + ๊ฒฝ์ ๋ฎ์ โ ํ์ ์์ญ
+- **Crowded** (์ข์๋จ): AI ๊ด์ฌ ๋ฎ์ + ๊ฒฝ์ ๋์ โ ํฌํ ์์ญ
+""")
+
+ attns = [float(c["attention_score"]) for c in scored]
+ densities = [float(c["citation_density"]) for c in scored]
+
+ median_attn = statistics.median(attns)
+ median_density = statistics.median(densities)
+
+ # Classify each point into quadrant
+ xs, ys, sizes, colors, hover_texts = [], [], [], [], []
+ quadrant_counts = {"opportunity": 0, "competitive": 0, "niche": 0, "crowded": 0}
+
+ for c in scored:
+ attn = float(c["attention_score"])
+ density = float(c["citation_density"])
+ fanout_count = c.get("fanout_count", 10)
+ label = c.get("cluster_label") or f"Cluster-{c['id'][:8]}"
+
+ xs.append(attn)
+ ys.append(density)
+ sizes.append(max(5, min(35, fanout_count / 5)))
+
+ if attn >= median_attn and density < median_density:
+ color = QUAD_COLORS["high_attn_low_density"]
+ quad = "Opportunity"
+ quadrant_counts["opportunity"] += 1
+ elif attn >= median_attn and density >= median_density:
+ color = QUAD_COLORS["high_attn_high_density"]
+ quad = "Competitive"
+ quadrant_counts["competitive"] += 1
+ elif attn < median_attn and density < median_density:
+ color = QUAD_COLORS["low_attn_low_density"]
+ quad = "Niche"
+ quadrant_counts["niche"] += 1
+ else:
+ color = QUAD_COLORS["low_attn_high_density"]
+ quad = "Crowded"
+ quadrant_counts["crowded"] += 1
+
+ colors.append(color)
+ opp = float(c.get("opportunity_score", 0) or 0)
+ hover_texts.append(
+ f"{label}
"
+ f"Attention: {attn:.4f}
"
+ f"Density: {density:.4f}
"
+ f"Opportunity: {opp:.4f}
"
+ f"Fanouts: {fanout_count}
"
+ f"Quadrant: {quad}"
+ )
+
+ fig = go.Figure()
+
+ # Data points
+ fig.add_trace(go.Scatter(
+ x=xs,
+ y=ys,
+ mode="markers",
+ marker=dict(
+ size=sizes,
+ color=colors,
+ opacity=0.7,
+ line=dict(width=0.5, color="#333"),
+ ),
+ text=hover_texts,
+ hoverinfo="text",
+ showlegend=False,
+ ))
+
+ # Quadrant lines (add padding to avoid collapse when all values are identical)
+ attn_span = max(attns) - min(attns) or 0.001
+ density_span = max(densities) - min(densities) or 0.1
+ x_range = [min(attns) - attn_span * 0.1, max(attns) + attn_span * 0.1]
+ y_range = [min(densities) - density_span * 0.1, max(densities) + density_span * 0.1]
+
+ fig.add_hline(y=median_density, line_dash="dash", line_color="#9CA3AF", opacity=0.5)
+ fig.add_vline(x=median_attn, line_dash="dash", line_color="#9CA3AF", opacity=0.5)
+
+ # Quadrant labels
+ fig.add_annotation(x=x_range[1], y=y_range[0], text="๐ข Opportunity",
+ showarrow=False, font=dict(size=11, color=QUAD_COLORS["high_attn_low_density"]))
+ fig.add_annotation(x=x_range[1], y=y_range[1], text="๐ต Competitive",
+ showarrow=False, font=dict(size=11, color=QUAD_COLORS["high_attn_high_density"]))
+ fig.add_annotation(x=x_range[0], y=y_range[0], text="โช Niche",
+ showarrow=False, font=dict(size=11, color=QUAD_COLORS["low_attn_low_density"]))
+ fig.add_annotation(x=x_range[0], y=y_range[1], text="๐ด Crowded",
+ showarrow=False, font=dict(size=11, color=QUAD_COLORS["low_attn_high_density"]))
+
+ fig.update_layout(
+ title="Attention vs Citation Density (4-Quadrant)",
+ xaxis_title="Attention Score",
+ yaxis_title="Citation Density",
+ height=600,
+ template="plotly_white",
+ hoverlabel=dict(bgcolor="white", font_size=12),
+ )
+
+ st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
+
+ # Quadrant summary with action guides
+ st.markdown("#### Quadrant ์์ฝ")
+ q1, q2, q3, q4 = st.columns(4)
+ with q1:
+ st.metric(
+ "๐ข Opportunity", f"{quadrant_counts['opportunity']}๊ฐ",
+ help="AI ๊ด์ฌ๋ ๋์ + ๊ฒฝ์ ๋ฎ์ โ ์ฝํ
์ธ ์ ์ ๊ธฐํ. "
+ "์ด ํ ํฝ์ ๋ํ ์ ๋ฌธ ์ฝํ
์ธ ๋ฅผ ์ ์ํ๋ฉด AI ๋ต๋ณ์ ์ธ์ฉ๋ ๊ฐ๋ฅ์ฑ์ด ๋์ต๋๋ค.",
+ )
+ with q2:
+ st.metric(
+ "๐ต Competitive", f"{quadrant_counts['competitive']}๊ฐ",
+ help="AI ๊ด์ฌ๋ ๋์ + ๊ฒฝ์ ๋์ โ ๊ฒฝ์ ์น์ด. "
+ "์ฐจ๋ณํ๋ ์ฝํ
์ธ ๋ ์ ๋ฌธ์ฑ์ด ํ์ํฉ๋๋ค.",
+ )
+ with q3:
+ st.metric(
+ "โช Niche", f"{quadrant_counts['niche']}๊ฐ",
+ help="AI ๊ด์ฌ๋ ๋ฎ์ + ๊ฒฝ์ ๋ฎ์ โ ํ์ ์์ญ. "
+ "์์ฅ์ด ์ฑ์ฅํ๋ฉด ์ ์ ํจ๊ณผ๋ฅผ ๋ณผ ์ ์์ต๋๋ค.",
+ )
+ with q4:
+ st.metric(
+ "๐ด Crowded", f"{quadrant_counts['crowded']}๊ฐ",
+ help="AI ๊ด์ฌ๋ ๋ฎ์ + ๊ฒฝ์ ๋์ โ ํฌํ ์์ญ. "
+ "์๋ก์ด ์ง์ถ๋ณด๋ค ๊ธฐ์กด ์ฝํ
์ธ ์ ์ง์ ์ง์คํ์ธ์.",
+ )
+
+ st.caption(f"Median Attention: {median_attn:.4f} | Median Density: {median_density:.4f}")
diff --git a/features/research/guide.py b/features/research/guide.py
new file mode 100644
index 0000000000000000000000000000000000000000..6a7e0dacf2a13138a55e0cc85003c42aad6eb17f
--- /dev/null
+++ b/features/research/guide.py
@@ -0,0 +1,143 @@
+"""๋ถ์ ๊ฐ์ด๋ - ํ ํฝ ์ธํ
๋ฆฌ์ ์ค ๋ฐ์ดํฐ ํ๋ฆ ์ค๋ช
.
+
+๋น๊ธฐ์ ์ฌ์ฉ์๋ฅผ ์ํ ํ์ดํ๋ผ์ธ, ์ ์ ๊ณ์ฐ, ์ฉ์ด, FAQ.
+v9.0 ์ ๊ท. (renamed from data_flow.py)
+"""
+
+import streamlit as st
+
+
+def render_data_flow():
+ """ํ ํฝ ์ธํ
๋ฆฌ์ ์ค ๋ฐ์ดํฐ ํ๋ฆ ๊ฐ์ด๋."""
+
+ st.markdown("### ํ ํฝ ์ธํ
๋ฆฌ์ ์ค ๋ถ์ ๊ฐ์ด๋")
+ st.caption("์ด ํ์ด์ง๋ ํ ํฝ ์ธํ
๋ฆฌ์ ์ค์ ์๋ ๋ฐฉ์๊ณผ ์ฃผ์ ์งํ๋ฅผ ์ค๋ช
ํฉ๋๋ค.")
+
+ # 1. Pipeline diagram
+ st.markdown("#### 1. ๋ฐ์ดํฐ ํ์ดํ๋ผ์ธ")
+ st.markdown("""
+```
+์ฌ์ฉ์๊ฐ AI์ ์ง๋ฌธ
+ โ
+AI๊ฐ ๋ด๋ถ์ ์ผ๋ก ์ถ๊ฐ ์ง๋ฌธ(Fanout) ์์ฑ
+ ์: "best moisturizer for dry skin"
+ โ
+AI๊ฐ ์ถ๊ฐ ์ง๋ฌธ๋ณ๋ก ์น์ ๊ฒ์ํ๊ณ ์ถ์ฒ๋ฅผ ์ธ์ฉ
+ โ
+AI๊ฐ ์ข
ํฉํ์ฌ ์ต์ข
๋ต๋ณ ์์ฑ
+```
+
+์ ํฌ๋ ์ด ๊ณผ์ ์์ ์์ฑ๋ **์ถ๊ฐ ์ง๋ฌธ**๊ณผ **์ธ์ฉ ์ถ์ฒ**๋ฅผ ์์งํ์ฌ ๋ถ์ํฉ๋๋ค.
+
+```
+์์ง๋ ์ถ๊ฐ ์ง๋ฌธ๋ค
+ โ
+์ ์ฌํ ์ง๋ฌธ๋ผ๋ฆฌ ์๋ ๊ทธ๋ฃนํ (ํด๋ฌ์คํฐ๋ง)
+ โ ๊ฐ ๊ทธ๋ฃน = ํ๋์ "ํ ํฝ"
+ โ
+๊ฐ ํ ํฝ๋ณ ์ ์ ๊ณ์ฐ
+ โ AI ๊ด์ฌ๋, ๊ฒฝ์ ๋ฐ๋, ๊ธฐํ ์ ์
+ โ
+ํ ํฝ ๋งต / ๊ธฐํ ์์ญ / ๋ถํฌ ์๊ฐํ
+```
+""")
+
+ st.markdown("---")
+
+ # 2. Score explanations
+ st.markdown("#### 2. ์ ์ ๊ณ์ฐ ๋ฐฉ๋ฒ")
+
+ score_col1, score_col2, score_col3 = st.columns(3)
+
+ with score_col1:
+ st.markdown("""
+**AI ๊ด์ฌ๋ (Attention)**
+
+ํด๋น ํ ํฝ์ ์ถ๊ฐ ์ง๋ฌธ ์๊ฐ ์ ์ฒด์์
+์ฐจ์งํ๋ ๋น์ค์
๋๋ค.
+
+`ํ ํฝ์ ์ถ๊ฐ ์ง๋ฌธ ์ / ์ ์ฒด ์ถ๊ฐ ์ง๋ฌธ ์`
+
+๊ฐ์ด ๋์์๋ก AI๊ฐ ์ด ์ฃผ์ ์ ๋ํด
+์์ฃผ ์ง๋ฌธํ๋ค๋ ์๋ฏธ์
๋๋ค.
+""")
+
+ with score_col2:
+ st.markdown("""
+**๊ฒฝ์ ๋ฐ๋ (Density)**
+
+ํด๋น ํ ํฝ์์ AI๊ฐ ์ธ์ฉํ๋
+ํ๊ท ์ถ์ฒ ์์
๋๋ค.
+
+๊ฐ์ด ๋์์๋ก ์ด๋ฏธ ๋ง์ ์น์ฌ์ดํธ๊ฐ
+์ด ์ฃผ์ ์ ๋ํ ์ฝํ
์ธ ๋ฅผ ๊ฐ๊ณ ์์ด
+๊ฒฝ์์ด ์น์ดํ๋ค๋ ์๋ฏธ์
๋๋ค.
+""")
+
+ with score_col3:
+ st.markdown("""
+**๊ธฐํ ์ ์ (Opportunity)**
+
+AI ๊ด์ฌ๋์ ๊ฒฝ์ ๋ฐ๋๋ฅผ ๊ฒฐํฉํ
+์ข
ํฉ ์งํ์
๋๋ค.
+
+`AI ๊ด์ฌ๋ x (1 - ๊ฒฝ์ ๋ฐ๋)`
+
+**๊ด์ฌ์ ๋์ง๋ง ๊ฒฝ์์ด ๋ฎ์** ์์ญ์
+์ฐพ์์ค๋๋ค.
+""")
+
+ st.markdown("---")
+
+ # 3. Glossary
+ st.markdown("#### 3. ์ฉ์ด ์ฌ์ ")
+
+ glossary = {
+ "Fanout (์ถ๊ฐ ์ง๋ฌธ)": "AI๊ฐ ๋ต๋ณ์ ์์ฑํ๊ธฐ ์ํด ๋ด๋ถ์ ์ผ๋ก ๋ง๋๋ ์ธ๋ถ ์ง๋ฌธ. ์: ์ฌ์ฉ์๊ฐ '์ข์ ์ ํฌ๋ฆผ ์ถ์ฒํด์ค'๋ผ๊ณ ๋ฌผ์ผ๋ฉด AI๋ 'best sunscreen for sensitive skin', 'sunscreen SPF comparison' ๋ฑ์ ์ถ๊ฐ ์ง๋ฌธ์ ์์ฑํฉ๋๋ค.",
+ "Cluster (ํ ํฝ ๊ทธ๋ฃน)": "์ ์ฌํ ์ถ๊ฐ ์ง๋ฌธ๋ค์ AI๊ฐ ์๋์ผ๋ก ๋ฌถ์ ๊ฒ. ํ๋์ ํด๋ฌ์คํฐ = ํ๋์ ํ ํฝ.",
+ "Citation (์ธ์ฉ ์ถ์ฒ)": "AI๊ฐ ๋ต๋ณ์์ ์ฐธ์กฐํ ์น ํ์ด์ง. ํน์ ๋๋ฉ์ธ์ด ์์ฃผ ์ธ์ฉ๋๋ฉด ํด๋น ์ฃผ์ ์ ๊ถ์ ์๋ ์ถ์ฒ๋ก ์ธ์๋จ.",
+ "UMAP (ํ ํฝ ๋งต)": "๊ณ ์ฐจ์ ๋ฐ์ดํฐ๋ฅผ 2D๋ก ํฌ์ํ์ฌ ํ ํฝ ๊ฐ ์ ์ฌ๋๋ฅผ ์๊ฐ์ ์ผ๋ก ๋ณด์ฌ์ฃผ๋ ๊ธฐ๋ฒ. ๊ฐ๊น์ด ์ = ์ ์ฌํ ํ ํฝ.",
+ "Quadrant (4๋ถ๋ฉด)": "AI ๊ด์ฌ๋์ ๊ฒฝ์ ๋ฐ๋๋ฅผ ๊ธฐ์ค์ผ๋ก ํ ํฝ์ 4๊ฐ ์์ญ์ผ๋ก ๋ถ๋ฅํ ๊ฒ.",
+ }
+
+ for term, definition in glossary.items():
+ st.markdown(f"**{term}**")
+ st.markdown(f"> {definition}")
+ st.markdown("")
+
+ st.markdown("---")
+
+ # 4. FAQ
+ st.markdown("#### 4. ์์ฃผ ๋ฌป๋ ์ง๋ฌธ")
+
+ with st.expander("์ Top 10๋ง ์์ธ ๋ถ์ํ๋์?"):
+ st.markdown(
+ "๊ธฐํ ์ ์ ์์ 10๊ฐ ํ ํฝ์ด ์ค์ง์ ์ผ๋ก ์ฝํ
์ธ ์ ์ ์ฐ์ ์์๊ฐ ๊ฐ์ฅ ๋์ ์์ญ์
๋๋ค. "
+ "์ ์ฒด ๋ญํน ํ
์ด๋ธ์์๋ 50์๊น์ง ํ์ธํ ์ ์์ต๋๋ค."
+ )
+
+ with st.expander("์ธ์ฉ ์ถ์ฒ ๊ทธ๋ํ๊ฐ ์๋ ํ ํฝ์ ๋ฌด์์ธ๊ฐ์?"):
+ st.markdown(
+ "AI๊ฐ ํด๋น ํ ํฝ์์ ์์ง ํน์ ์น์ฌ์ดํธ๋ฅผ ์ธ์ฉํ์ง ์๊ณ ์๋ค๋ ์๋ฏธ์
๋๋ค. "
+ "์ด๋ ๊ฒฝ์์๊ฐ ๊ฑฐ์ ์๋ค๋ ๋ป์ด๋ฏ๋ก, **์ฝํ
์ธ ์ ์ ๊ธฐํ๊ฐ ๋์ฑ ํฐ** ์์ญ์
๋๋ค."
+ )
+
+ with st.expander("์ ์๊ฐ ๋งค์ฐ ๋ฎ์ ํ ํฝ์ ๋ฌด์ํด๋ ๋๋์?"):
+ st.markdown(
+ "์ ์๋ ์ ์ฒด ํ ํฝ ๋๋น ์๋์ ๋น์ค์
๋๋ค. "
+ "์๋ฅผ ๋ค์ด 481๊ฐ ํ ํฝ ์ค ํ๋์ Attention์ด 0.001์ด๋ฉด ์ ์ฒด์ 0.1%๋ฅผ ์ฐจ์งํ๋ ๊ฒ์
๋๋ค. "
+ "์ ๋์ ์ผ๋ก ๋ฎ์ ๋ณด์ฌ๋ ํด๋น ๋์น์์๋ ์ถฉ๋ถํ ์๋ฏธ ์์ ์ ์์ต๋๋ค."
+ )
+
+ with st.expander("ํ ํฝ ๋งต์์ ์ ๋ค์ด ๋ญ์ณ ์์ผ๋ฉด ๋ฌด์์ ์๋ฏธํ๋์?"):
+ st.markdown(
+ "์๋ก ๊ฐ๊น์ด ์๋ ์ (ํ ํฝ)๋ค์ ์๋ฏธ์ ์ผ๋ก ์ ์ฌํ ์ฃผ์ ์
๋๋ค. "
+ "๋ญ์ณ ์๋ ํ ํฝ ๊ทธ๋ฃน์ ํ๋์ ํฐ ์ฃผ์ ์์ญ์ ๋ํ๋ด๋ฉฐ, "
+ "์ด ์์ญ ์ ์ฒด์ ๋ํ ์ข
ํฉ์ ์ธ ์ฝํ
์ธ ์ ๋ต์ ์๋ฆฝํ๋ฉด ํจ๊ณผ์ ์
๋๋ค."
+ )
+
+ with st.expander("๋ฐ์ดํฐ๋ ์ผ๋ง๋ ์์ฃผ ์
๋ฐ์ดํธ๋๋์?"):
+ st.markdown(
+ "์บ ํ์ธ๋ณ๋ก ํด๋ฌ์คํฐ๋ง์ ์คํํ ๋ ๋ฐ์ดํฐ๊ฐ ๊ฐฑ์ ๋ฉ๋๋ค. "
+ "ํ์ฌ๋ ์๋ ์คํ ๋ฐฉ์์ด๋ฉฐ, ์ถํ ์๋ ๊ฐฑ์ ์ด ์ถ๊ฐ๋ ์์ ์
๋๋ค."
+ )
diff --git a/features/research/keyword_suggest.py b/features/research/keyword_suggest.py
new file mode 100644
index 0000000000000000000000000000000000000000..5f5d2eb6be4401c35f0d6bfacac8fa9b2424d9c8
--- /dev/null
+++ b/features/research/keyword_suggest.py
@@ -0,0 +1,195 @@
+"""ํค์๋ ์ถ์ฒ (R-6).
+
+ํด๋ฌ์คํฐ์ sample_fanouts์์ ๋น๋ ๋์ ํค์๋/n-gram์ ์ถ์ถํ์ฌ
+์ฝํ
์ธ ๊ธฐํ๊ฐ ๋์ ํค์๋๋ฅผ ์ถ์ฒํฉ๋๋ค.
+"""
+
+import re
+from collections import Counter
+
+import streamlit as st
+import pandas as pd
+
+
+# Common stopwords (English + Korean particles)
+_STOPWORDS = {
+ # English
+ "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
+ "should", "may", "might", "shall", "can", "need", "dare", "ought",
+ "used", "to", "of", "in", "for", "on", "with", "at", "by", "from",
+ "as", "into", "through", "during", "before", "after", "above", "below",
+ "between", "out", "off", "over", "under", "again", "further", "then",
+ "once", "here", "there", "when", "where", "why", "how", "all", "each",
+ "every", "both", "few", "more", "most", "other", "some", "such", "no",
+ "nor", "not", "only", "own", "same", "so", "than", "too", "very",
+ "just", "because", "but", "and", "or", "if", "while", "about", "what",
+ "which", "who", "whom", "this", "that", "these", "those", "am", "it",
+ "its", "my", "your", "his", "her", "our", "their", "me", "him", "us",
+ "them", "i", "you", "he", "she", "we", "they",
+ "best", "top", "vs", "good", "new", "review", "reviews",
+ # Korean particles
+ "์", "์", "๋ฅผ", "์", "์ด", "๊ฐ", "์", "๋", "๋ก", "์ผ๋ก", "์", "๊ณผ",
+ "๋", "๋ง", "๊น์ง", "๋ถํฐ", "์์", "ํ", "๋", "๋๋", "ํ๋", "์๋",
+}
+
+
+def _tokenize(text: str) -> list[str]:
+ """Simple tokenization: lowercase, split on non-alphanumeric (preserving Korean)."""
+ text = text.lower().strip()
+ # Split on whitespace and punctuation, keeping Korean characters
+ tokens = re.findall(r"[a-z0-9\uac00-\ud7af]+", text)
+ return [t for t in tokens if t not in _STOPWORDS and len(t) >= 2]
+
+
+def _extract_ngrams(texts: list[str], n: int = 2) -> Counter:
+ """Extract n-grams from a list of texts."""
+ ngram_counter = Counter()
+ for text in texts:
+ tokens = _tokenize(text)
+ for i in range(len(tokens) - n + 1):
+ ngram = " ".join(tokens[i:i + n])
+ ngram_counter[ngram] += 1
+ return ngram_counter
+
+
+def render_keyword_suggestions(clusters: list[dict], frame: str = "all"):
+ """Render keyword suggestions extracted from cluster sample_fanouts."""
+ # Frame-specific description
+ if frame == "supply":
+ st.caption(
+ "Gemini ์ธ์ฉ ๋ฌธ๊ตฌ์์ ์ถ์ถํ ํค์๋์
๋๋ค. "
+ "AI๊ฐ ์ค์ ๋ก ์ธ์ฉํ๋ ํต์ฌ ํํ์ ํ์
ํ์ฌ ์ฝํ
์ธ ์ ๋ฐ์ํ์ธ์."
+ )
+ elif frame == "demand":
+ st.caption(
+ "ChatGPT sub-query์์ ์ถ์ถํ ํค์๋์
๋๋ค. "
+ "์๋น์๊ฐ AI์๊ฒ ๋ฌผ์ด๋ณด๋ ํต์ฌ ํํ์ ํ์
ํ์ฌ ์ฝํ
์ธ ๋ฅผ ์ต์ ํํ์ธ์."
+ )
+ else:
+ st.caption(
+ "AI ํ ํฝ ํด๋ฌ์คํฐ์์ ์ถ์ถํ ํต์ฌ ํค์๋์
๋๋ค. "
+ "๋น๋๊ฐ ๋์์๋ก AI๊ฐ ์์ฃผ ๋ค๋ฃจ๋ ํํ์
๋๋ค."
+ )
+
+ # Collect all sample texts
+ all_samples = []
+ cluster_samples = {} # cluster_label -> samples
+ for c in clusters:
+ samples = c.get("sample_fanouts") or []
+ label = c.get("cluster_label") or "Unknown"
+ opp = float(c.get("opportunity_score", 0) or 0)
+ all_samples.extend(samples)
+ if samples:
+ cluster_samples[label] = {
+ "samples": samples,
+ "opportunity": opp,
+ "fanout_count": c.get("fanout_count", 0),
+ }
+
+ if not all_samples:
+ st.info("ํด๋ฌ์คํฐ์ ์ํ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.")
+ return
+
+ # --- Unigram Analysis ---
+ st.markdown("### ๋จ์ผ ํค์๋ (Unigram)")
+
+ unigram_counter = Counter()
+ for text in all_samples:
+ tokens = _tokenize(text)
+ unigram_counter.update(tokens)
+
+ top_unigrams = unigram_counter.most_common(30)
+
+ if top_unigrams:
+ uni_df = pd.DataFrame(top_unigrams, columns=["ํค์๋", "๋น๋"])
+ uni_df.index = range(1, len(uni_df) + 1)
+ uni_df.index.name = "์์"
+
+ col1, col2 = st.columns([2, 1])
+ with col1:
+ st.dataframe(uni_df, use_container_width=True)
+ with col2:
+ st.metric("๊ณ ์ ํค์๋", f"{len(unigram_counter):,}๊ฐ")
+ st.metric("์ด ํ ํฐ", f"{sum(unigram_counter.values()):,}๊ฐ")
+ st.metric("๋ถ์ ํ
์คํธ", f"{len(all_samples):,}๊ฐ")
+
+ st.markdown("---")
+
+ # --- Bigram Analysis ---
+ st.markdown("### ํค์๋ ์กฐํฉ (Bigram)")
+
+ bigram_counter = _extract_ngrams(all_samples, n=2)
+ top_bigrams = bigram_counter.most_common(20)
+
+ if top_bigrams:
+ bi_df = pd.DataFrame(top_bigrams, columns=["ํค์๋ ์กฐํฉ", "๋น๋"])
+ bi_df.index = range(1, len(bi_df) + 1)
+ bi_df.index.name = "์์"
+ st.dataframe(bi_df, use_container_width=True)
+
+ st.markdown("---")
+
+ # --- Opportunity-Weighted Keywords ---
+ st.markdown("### ๊ธฐํ ๊ฐ์ค ํค์๋")
+ st.caption(
+ "Opportunity Score๊ฐ ๋์ ํด๋ฌ์คํฐ์์ ๋ง์ด ๋ฑ์ฅํ๋ ํค์๋์
๋๋ค. "
+ "์ด ํค์๋๋ฅผ ์ฝํ
์ธ ์ ํฌํจํ๋ฉด AI ๋
ธ์ถ ๊ธฐํ๊ฐ ๋์์ง๋๋ค."
+ )
+
+ # Weight keywords by cluster opportunity score
+ weighted_counter = Counter()
+ for label, info in cluster_samples.items():
+ opp = info["opportunity"]
+ tokens_in_cluster = Counter()
+ for text in info["samples"]:
+ tokens_in_cluster.update(_tokenize(text))
+ # Weight by opportunity score (normalize by cluster token total to avoid volume bias)
+ total = sum(tokens_in_cluster.values()) or 1
+ for token, count in tokens_in_cluster.items():
+ weighted_counter[token] += (count / total) * opp
+
+ top_weighted = weighted_counter.most_common(20)
+
+ if top_weighted:
+ w_df = pd.DataFrame(top_weighted, columns=["ํค์๋", "๊ฐ์ค ์ ์"])
+ w_df["๊ฐ์ค ์ ์"] = w_df["๊ฐ์ค ์ ์"].apply(lambda x: f"{x:.4f}")
+ w_df.index = range(1, len(w_df) + 1)
+ w_df.index.name = "์์"
+ st.dataframe(w_df, use_container_width=True)
+
+ st.markdown("---")
+
+ # --- Per-Cluster Keyword Breakdown ---
+ st.markdown("### ํด๋ฌ์คํฐ๋ณ ํต์ฌ ํค์๋")
+ st.caption("๊ฐ ํ ํฝ ํด๋ฌ์คํฐ์ ๋ํ ํค์๋์
๋๋ค. Opportunity Score ์์ผ๋ก ์ ๋ ฌ.")
+
+ sorted_clusters = sorted(
+ cluster_samples.items(),
+ key=lambda x: x[1]["opportunity"],
+ reverse=True,
+ )
+
+ for label, info in sorted_clusters[:10]:
+ opp = info["opportunity"]
+ cluster_tokens = Counter()
+ for text in info["samples"]:
+ cluster_tokens.update(_tokenize(text))
+
+ top5 = [kw for kw, _ in cluster_tokens.most_common(5)]
+ keywords_str = ", ".join(top5)
+
+ with st.expander(f"{label} (Opp: {opp:.4f}) โ {keywords_str}"):
+ st.write(f"**Fanout/Citation ์:** {info['fanout_count']}")
+ st.write(f"**Opportunity Score:** {opp:.4f}")
+ st.markdown("**Top ํค์๋:**")
+
+ kw_df = pd.DataFrame(
+ cluster_tokens.most_common(10),
+ columns=["ํค์๋", "๋น๋"],
+ )
+ st.dataframe(kw_df, use_container_width=True, hide_index=True)
+
+ st.markdown("**์ํ ํ
์คํธ:**")
+ for s in info["samples"][:3]:
+ st.write(f"- {s}")
diff --git a/features/research/opportunities.py b/features/research/opportunities.py
new file mode 100644
index 0000000000000000000000000000000000000000..39327a1ea92460727ea953de18ca2fab4a7dcd30
--- /dev/null
+++ b/features/research/opportunities.py
@@ -0,0 +1,173 @@
+"""๊ธฐํ ์์ญ ๋ญํน ํ
์ด๋ธ + ์์ธ Expander.
+
+Clusters sorted by opportunity_score DESC.
+Top-10 with expanders showing sample fanouts and top sources.
+v9.0: ๋น์ฆ๋์ค ํด์, percentile, ์ธ์ฉ ์ ๋ฌด ๋ฉ์์ง ์ถ๊ฐ.
+"""
+
+import statistics
+
+import streamlit as st
+import pandas as pd
+import plotly.graph_objects as go
+
+
+def _get_percentile_rank(value: float, all_values: list[float]) -> int:
+ """Return the percentile rank (0-100) of a value within a list."""
+ if not all_values:
+ return 0
+ count_below = sum(1 for v in all_values if v < value)
+ return int(count_below / len(all_values) * 100)
+
+
+def _interpret_density(density: float, median_density: float) -> str:
+ """Interpret density relative to median."""
+ if density < median_density * 0.5:
+ return "๊ฒฝ์ ๋ฎ์"
+ elif density < median_density * 1.5:
+ return "๊ฒฝ์ ๋ณดํต"
+ return "๊ฒฝ์ ๋์"
+
+
+def _interpret_opportunity(opp: float, median_opp: float) -> str:
+ """Interpret opportunity score."""
+ if opp >= median_opp * 1.5:
+ return "๊ธฐํ ํผ"
+ elif opp >= median_opp * 0.5:
+ return "๊ธฐํ ๋ณดํต"
+ return "๊ธฐํ ๋ฎ์"
+
+
+def render_opportunities(clusters: list[dict], frame: str = "all"):
+ """Render opportunity ranking table with detail expanders."""
+ scored = [
+ c for c in clusters
+ if c.get("opportunity_score") is not None
+ and c.get("attention_score") is not None
+ ]
+
+ if not scored:
+ st.info("์ค์ฝ์ด๊ฐ ๊ณ์ฐ๋ ํด๋ฌ์คํฐ๊ฐ ์์ต๋๋ค.")
+ return
+
+ # Sort by opportunity_score DESC
+ scored.sort(key=lambda c: float(c.get("opportunity_score", 0) or 0), reverse=True)
+
+ # Pre-compute statistics for percentile/interpretation
+ all_attns = [float(c.get("attention_score", 0) or 0) for c in scored]
+ all_densities = [float(c.get("citation_density", 0) or 0) for c in scored]
+ all_opps = [float(c.get("opportunity_score", 0) or 0) for c in scored]
+ median_density = statistics.median(all_densities) if all_densities else 0
+ median_opp = statistics.median(all_opps) if all_opps else 0
+
+ # Top metrics
+ col1, col2, col3 = st.columns(3)
+ with col1:
+ st.metric("์ค์ฝ์ด๋ง ์๋ฃ", f"{len(scored)}๊ฐ ํด๋ฌ์คํฐ")
+ with col2:
+ avg_opp = sum(all_opps) / len(all_opps)
+ st.metric("ํ๊ท Opportunity", f"{avg_opp:.4f}")
+ with col3:
+ top = scored[0]
+ st.metric("Top ๊ธฐํ ํ ํฝ", (top.get("cluster_label") or "N/A")[:25])
+
+ st.markdown("---")
+
+ # Explanation before ranking table (frame-specific)
+ if frame == "demand":
+ st.info(
+ "ChatGPT๊ฐ ์์ฃผ ๊ฒ์ํ์ง๋ง ๊ฒฝ์์ด ๋ฎ์ Demand ํ ํฝ์
๋๋ค.\n\n"
+ "๊ธฐํ ์ ์๊ฐ ๋์์๋ก, ์ด ์ฃผ์ ์ ์ฝํ
์ธ ๋ฅผ ๋ง๋ค๋ฉด ChatGPT ๊ฒ์์ ๋
ธ์ถ๋ ๊ฐ๋ฅ์ฑ์ด ๋์ต๋๋ค."
+ )
+ elif frame == "supply":
+ st.info(
+ "Gemini๊ฐ ์์ฃผ ์ธ์ฉํ์ง๋ง ์ถ์ฒ ๊ฒฝ์์ด ๋ฎ์ Supply ํ ํฝ์
๋๋ค.\n\n"
+ "๊ธฐํ ์ ์๊ฐ ๋์์๋ก, ์ด ์ฃผ์ ์ ์ฝํ
์ธ ๋ฅผ ๋ง๋ค๋ฉด Gemini ๋ต๋ณ์ ์ธ์ฉ๋ ๊ฐ๋ฅ์ฑ์ด ๋์ต๋๋ค."
+ )
+ else:
+ st.info(
+ "๊ธฐํ ์ ์ Top 10 ํ ํฝ์ ์์ธ ๋ถ์ํฉ๋๋ค. "
+ "๊ธฐํ ์ ์๋ **'AI ๊ด์ฌ๋๊ฐ ๋์ง๋ง ๊ฒฝ์(์ธ์ฉ ์ถ์ฒ)์ด ์ ์ ํ ํฝ'**์ ์ฐพ์์ฃผ๋ ์งํ์
๋๋ค.\n\n"
+ "์ ์๊ฐ ๋์์๋ก ์ฝํ
์ธ ๋ฅผ ๋ง๋ค๋ฉด AI์ ์ธ์ฉ๋ ๊ฐ๋ฅ์ฑ์ด ๋์ต๋๋ค."
+ )
+
+ # Ranking table
+ rows = []
+ for i, c in enumerate(scored[:50], 1):
+ rows.append({
+ "์์": i,
+ "ํ ํฝ": (c.get("cluster_label") or f"Cluster-{c['id'][:8]}")[:40],
+ "Attention": f"{float(c.get('attention_score', 0) or 0):.4f}",
+ "Density": f"{float(c.get('citation_density', 0) or 0):.4f}",
+ "Opportunity": f"{float(c.get('opportunity_score', 0) or 0):.4f}",
+ "Fanouts": c.get("fanout_count", 0),
+ })
+
+ df = pd.DataFrame(rows)
+ st.dataframe(df, use_container_width=True, hide_index=True)
+
+ # Detail expanders for top 10
+ st.markdown("#### Top 10 ์์ธ")
+ for i, c in enumerate(scored[:10], 1):
+ label = c.get("cluster_label") or f"Cluster-{c['id'][:8]}"
+ opp = float(c.get("opportunity_score", 0) or 0)
+
+ with st.expander(f"#{i} {label} (Opportunity: {opp:.4f})", key=f"research:opp_detail:{c['id']}"):
+ detail_col1, detail_col2 = st.columns(2)
+
+ with detail_col1:
+ attn = float(c.get("attention_score", 0) or 0)
+ density = float(c.get("citation_density", 0) or 0)
+ attn_pct = _get_percentile_rank(attn, all_attns)
+ density_label = _interpret_density(density, median_density)
+ opp_label = _interpret_opportunity(opp, median_opp)
+
+ st.markdown("**Score ์์ธ**")
+ st.write(f"- Attention: {attn:.4f} (์์ {100 - attn_pct}%)")
+ st.write(f"- Density: {density:.4f} ({density_label})")
+ st.write(f"- Opportunity: {opp:.4f} ({opp_label})")
+ st.write(f"- Fanout ์: {c.get('fanout_count', 0)}")
+ if c.get("unique_questions"):
+ st.write(f"- ๊ด๋ จ ์ง๋ฌธ ์: {c['unique_questions']}")
+
+ with detail_col2:
+ # Sample fanouts/citations with context
+ samples = c.get("sample_fanouts") or []
+ if samples:
+ if frame == "supply":
+ st.markdown("**๋ํ ์ธ์ฉ ๋ฌธ๊ตฌ** (Gemini๊ฐ ์ค์ ๋ก ์ธ์ฉํ ํ
์คํธ):")
+ else:
+ st.markdown("**๋ํ AI ์ถ๊ฐ ์ง๋ฌธ** (์ด ํ ํฝ์์ AI๊ฐ ์ค์ ๋ก ์์ฑํ ์ง๋ฌธ๋ค):")
+ for s in samples[:5]:
+ st.write(f"- {s}")
+ if frame == "supply":
+ st.caption("์ด๋ฐ ํํ์ ์ฝํ
์ธ ๋ฅผ ๋ง๋ค๋ฉด Gemini ๋ต๋ณ์ ์ธ์ฉ๋ ์ ์์ต๋๋ค.")
+ else:
+ st.caption("์ด๋ฐ ์ง๋ฌธ์ ๋ํ ์ฝํ
์ธ ๋ฅผ ๋ง๋ค๋ฉด AI ๋ต๋ณ์ ์ธ์ฉ๋ ์ ์์ต๋๋ค.")
+ else:
+ st.write("์ํ ์์")
+
+ # Top sources bar chart
+ top_sources = c.get("top_sources")
+ if top_sources and isinstance(top_sources, list) and len(top_sources) > 0:
+ st.caption("์ด ํ ํฝ์์ AI๊ฐ ์ธ์ฉํ ์ฃผ์ ์ถ์ฒ์
๋๋ค. ์ฌ๊ธฐ์ ์์ฌ ์ฝํ
์ธ ๊ฐ ์๋ค๋ฉด ์ง์ถ ๊ธฐํ์
๋๋ค.")
+ domains = [s.get("host_url", "unknown") for s in top_sources[:10]]
+ counts = [s.get("count", 0) for s in top_sources[:10]]
+
+ fig = go.Figure(go.Bar(
+ x=counts,
+ y=domains,
+ orientation="h",
+ marker_color="#059669",
+ ))
+ fig.update_layout(
+ title="Top ์ธ์ฉ ์ถ์ฒ",
+ xaxis_title="์ธ์ฉ ์",
+ yaxis=dict(autorange="reversed"),
+ height=300,
+ margin=dict(l=0, r=0, t=30, b=0),
+ template="plotly_white",
+ )
+ st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
+ else:
+ st.info("์ด ํ ํฝ์ ์์ง AI๊ฐ ํน์ ์ถ์ฒ๋ฅผ ์ธ์ฉํ์ง ์๊ณ ์์ด, ์ฝํ
์ธ ์ ์ ๊ธฐํ๊ฐ ๋์ฑ ํฝ๋๋ค.")
diff --git a/features/research/summary.py b/features/research/summary.py
new file mode 100644
index 0000000000000000000000000000000000000000..c7f001c4c1c31f8928f6a5f9dac944a465597063
--- /dev/null
+++ b/features/research/summary.py
@@ -0,0 +1,48 @@
+"""ํ ํฝ ์ธํ
๋ฆฌ์ ์ค Feature ์์ฝ ์นด๋."""
+import streamlit as st
+
+
+def render_summary(clusters: list, frame: str = "all"):
+ """ํ ํฝ ์ธํ
๋ฆฌ์ ์ค ์์ฝ ๋ฉํธ๋ฆญ.
+
+ Args:
+ clusters: List of cluster dicts.
+ frame: "demand" (ChatGPT), "supply" (Gemini), or "all".
+ """
+ scored = [c for c in clusters if c.get("opportunity_score") is not None]
+
+ # Frame-specific labels
+ if frame == "demand":
+ count_label = "Demand ํ ํฝ"
+ volume_label = "์ด Fanout"
+ volume_help = "ChatGPT๊ฐ ์ฌ์ฉ์ ์ง๋ฌธ์ ๋ถํดํ ์ธ๋ถ ์ง๋ฌธ์ ์ด ์"
+ attn_help = "๊ฐ ํ ํฝ์ด ์ ์ฒด ChatGPT ์ง์์์ ์ฐจ์งํ๋ ๋น์ค์ ํ๊ท "
+ top_help = "ChatGPT์์ ๊ด์ฌ๋๋ ๋์ง๋ง ๊ฒฝ์์ด ๋ฎ์ ์ฝํ
์ธ ๊ธฐํ๊ฐ ๊ฐ์ฅ ํฐ ํ ํฝ"
+ elif frame == "supply":
+ count_label = "Supply ํ ํฝ"
+ volume_label = "์ด Citation"
+ volume_help = "Gemini๊ฐ ๋ต๋ณ์์ ์ธ์ฉํ ๋ฌธ๊ตฌ์ ์ด ์"
+ attn_help = "๊ฐ ํ ํฝ์ด ์ ์ฒด Gemini ์ธ์ฉ์์ ์ฐจ์งํ๋ ๋น์ค์ ํ๊ท "
+ top_help = "Gemini์์ ์ธ์ฉ ๋น๋๋ ๋์ง๋ง ๊ฒฝ์์ด ๋ฎ์ ์ฝํ
์ธ ๊ธฐํ๊ฐ ๊ฐ์ฅ ํฐ ํ ํฝ"
+ else:
+ count_label = "์ด ํด๋ฌ์คํฐ"
+ volume_label = "์ด Fanout"
+ volume_help = "AI๊ฐ ์ฌ์ฉ์ ์ง๋ฌธ์ ์กฐ์ฌํ๊ธฐ ์ํด ์์ฑํ ์ธ๋ถ ์ง๋ฌธ์ ์ด ์"
+ attn_help = "๊ฐ ํ ํฝ์ด ์ ์ฒด ์ง์์์ ์ฐจ์งํ๋ ๋น์ค์ ํ๊ท "
+ top_help = "AI ๊ด์ฌ๋๋ ๋์ง๋ง ๊ฒฝ์์ด ๋ฎ์ ์ฝํ
์ธ ๊ธฐํ๊ฐ ๊ฐ์ฅ ํฐ ํ ํฝ"
+
+ col1, col2, col3, col4 = st.columns(4)
+ with col1:
+ st.metric(
+ count_label, len(clusters),
+ help="์ ์ฌํ AI ์ง๋ฌธ/์ธ์ฉ๋ค์ ๋ฌถ์ ํ ํฝ ๊ทธ๋ฃน ์",
+ )
+ with col2:
+ total_fanouts = sum(c.get("fanout_count", 0) for c in clusters)
+ st.metric(volume_label, f"{total_fanouts:,}", help=volume_help)
+ with col3:
+ avg_attn = sum(float(c.get("attention_score", 0) or 0) for c in scored) / len(scored) if scored else 0
+ st.metric("ํ๊ท Attention", f"{avg_attn:.4f}", help=attn_help)
+ with col4:
+ top_label = scored[0].get("cluster_label", "N/A") if scored else "N/A"
+ st.metric("Top Opportunity", top_label[:20], help=top_help)
diff --git a/features/research/topic_map.py b/features/research/topic_map.py
new file mode 100644
index 0000000000000000000000000000000000000000..d29a0cee708e0634c381216698e36de013bf02f1
--- /dev/null
+++ b/features/research/topic_map.py
@@ -0,0 +1,108 @@
+"""UMAP ํ ํฝ ๋งต ์๊ฐํ (Plotly scatter).
+
+snapshot.coordinates: [{cluster_id, x, y, size, label}]
+clusters: [{id, cluster_label, attention_score, citation_density, opportunity_score, fanout_count, ...}]
+"""
+
+import streamlit as st
+import plotly.graph_objects as go
+
+
+def render_topic_map(clusters: list[dict], snapshot: dict | None, frame: str = "all"):
+ """Render UMAP 2D scatter from snapshot coordinates + cluster metadata."""
+ if not snapshot or not snapshot.get("coordinates"):
+ st.info("UMAP ์ขํ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค. ํด๋ฌ์คํฐ๋ง ์คํ ํ ์์ฑ๋ฉ๋๋ค.")
+ return
+
+ # Frame-specific guide
+ if frame == "demand":
+ st.markdown("""
+๊ฐ ์ ์ ํ๋์ **Demand ํ ํฝ** (ChatGPT sub-query ๊ทธ๋ฃน)์
๋๋ค.
+- **์ ํฌ๊ธฐ**: ํด๋น ํ ํฝ์ fanout ์ (ํด์๋ก ์๋น์๊ฐ ์์ฃผ ๋ฌป๋ ํ ํฝ)
+- **์ ์์**: ๊ธฐํ ์ ์ (๋นจ๊ฐ = ๊ธฐํ ํผ, ๋
ธ๋ = ๋ณดํต)
+- **๊ฐ๊น์ด ์๋ ์ **: ์ ์ฌํ ๊ฒ์ ์๋์ ํ ํฝ
+""")
+ elif frame == "supply":
+ st.markdown("""
+๊ฐ ์ ์ ํ๋์ **Supply ํ ํฝ** (Gemini citation quote ๊ทธ๋ฃน)์
๋๋ค.
+- **์ ํฌ๊ธฐ**: ํด๋น ํ ํฝ์ ์ธ์ฉ ์ (ํด์๋ก AI๊ฐ ์์ฃผ ์ธ์ฉํ๋ ํ ํฝ)
+- **์ ์์**: ๊ธฐํ ์ ์ (๋นจ๊ฐ = ๊ธฐํ ํผ, ๋
ธ๋ = ๋ณดํต)
+- **๊ฐ๊น์ด ์๋ ์ **: ์ ์ฌํ ์ธ์ฉ ์ฃผ์ ์ ํ ํฝ
+""")
+ else:
+ st.markdown("""
+๊ฐ ์ ์ ํ๋์ **ํ ํฝ**(AI ์ถ๊ฐ ์ง๋ฌธ ๊ทธ๋ฃน)์
๋๋ค.
+- **์ ํฌ๊ธฐ**: ํด๋น ํ ํฝ์ AI ์ถ๊ฐ ์ง๋ฌธ ์ (ํด์๋ก AI๊ฐ ์์ฃผ ๋ฌป๋ ํ ํฝ)
+- **์ ์์**: ๊ธฐํ ์ ์ (๋นจ๊ฐ = ๊ธฐํ ํผ, ๋
ธ๋ = ๋ณดํต)
+- **๊ฐ๊น์ด ์๋ ์ **: ์ ์ฌํ ์ฃผ์ ์ ํ ํฝ
+""")
+
+ count_label = "Citations" if frame == "supply" else "Fanouts"
+ coords = snapshot["coordinates"]
+
+ # Build cluster lookup by id
+ cluster_map = {c["id"]: c for c in clusters}
+
+ # Merge coordinate data with cluster metadata
+ xs, ys, sizes, colors, hover_texts = [], [], [], [], []
+
+ for pt in coords:
+ cid = pt.get("cluster_id")
+ meta = cluster_map.get(cid, {})
+
+ xs.append(pt["x"])
+ ys.append(pt["y"])
+
+ fanout_count = pt.get("size", meta.get("fanout_count", 10))
+ # Normalize size for display (min 5, max 40)
+ norm_size = max(5, min(40, fanout_count / 5))
+ sizes.append(norm_size)
+
+ opp = float(meta.get("opportunity_score", 0) or 0)
+ colors.append(opp)
+
+ label = meta.get("cluster_label") or f"Cluster {pt.get('label', '?')}"
+ attn = float(meta.get("attention_score", 0) or 0)
+ density = float(meta.get("citation_density", 0) or 0)
+
+ hover_texts.append(
+ f"{label}
"
+ f"Attention: {attn:.4f}
"
+ f"Density: {density:.4f}
"
+ f"Opportunity: {opp:.4f}
"
+ f"{count_label}: {fanout_count}"
+ )
+
+ fig = go.Figure()
+ fig.add_trace(go.Scatter(
+ x=xs,
+ y=ys,
+ mode="markers",
+ marker=dict(
+ size=sizes,
+ color=colors,
+ colorscale="YlOrRd",
+ colorbar=dict(title="Opportunity"),
+ opacity=0.7,
+ line=dict(width=0.5, color="#333"),
+ ),
+ text=hover_texts,
+ hoverinfo="text",
+ ))
+
+ fig.update_layout(
+ title="AI ํ ํฝ ๋งต (UMAP 2D Projection)",
+ xaxis=dict(title="UMAP-1", showgrid=False, zeroline=False),
+ yaxis=dict(title="UMAP-2", showgrid=False, zeroline=False),
+ height=600,
+ template="plotly_white",
+ hoverlabel=dict(bgcolor="white", font_size=12),
+ )
+
+ st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
+
+ # Algorithm params info
+ params = snapshot.get("algorithm_params")
+ if params:
+ with st.expander("๋ถ์ ์ค์ (๊ธฐ์ ์์ธ)"):
+ st.json(params)
diff --git a/features/research/unified_scoring.py b/features/research/unified_scoring.py
new file mode 100644
index 0000000000000000000000000000000000000000..ec18ee2d6dd9f4dce6a6b3789a1bebee114fe535
--- /dev/null
+++ b/features/research/unified_scoring.py
@@ -0,0 +1,178 @@
+"""Unified Scoring (ADR-014 Phase 4).
+
+Cross-model unified score = weighted average of demand/supply percentiles.
+Displayed in "์ ์ฒด" mode when cross-model pair exists.
+"""
+
+import streamlit as st
+import pandas as pd
+import plotly.graph_objects as go
+
+from core.supabase_client import (
+ get_gap_scores, get_topic_clusters,
+)
+
+
+def render_unified_scoring(base_ctx: dict, pair: dict):
+ """Render unified scoring view combining demand + supply signals.
+
+ Args:
+ base_ctx: Dashboard base context.
+ pair: Cross-model pair dict from find_cross_model_pair().
+ """
+ campaign_chatgpt = pair["campaign_chatgpt"]
+ campaign_gemini = pair["campaign_gemini"]
+
+ matches = get_gap_scores(campaign_chatgpt, campaign_gemini)
+ if not matches:
+ st.info("Unified Scoring์ ํ์ํ Cross-Model ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.")
+ return
+
+ # Fetch cluster counts for weight calculation
+ chatgpt_clusters = get_topic_clusters(campaign_chatgpt, source="chatgpt")
+ gemini_clusters = get_topic_clusters(campaign_gemini, source="gemini")
+
+ chatgpt_volume = sum(c.get("fanout_count", 0) for c in chatgpt_clusters)
+ gemini_volume = sum(c.get("fanout_count", 0) for c in gemini_clusters)
+ total_volume = chatgpt_volume + gemini_volume
+
+ # Weight by data volume (ADR-014 spec)
+ w_chatgpt = chatgpt_volume / total_volume if total_volume > 0 else 0.5
+ w_gemini = gemini_volume / total_volume if total_volume > 0 else 0.5
+
+ st.caption(
+ "Demand(ChatGPT)์ Supply(Gemini) ์ ํธ๋ฅผ ๋ฐ์ดํฐ ๋ณผ๋ฅจ ๋น๋ก๋ก ํตํฉํ ์ ์์
๋๋ค."
+ )
+
+ # Weight info
+ c1, c2, c3 = st.columns(3)
+ with c1:
+ st.metric(
+ "Demand ๊ฐ์ค์น",
+ f"{w_chatgpt:.1%}",
+ help=f"ChatGPT fanout ๋ณผ๋ฅจ: {chatgpt_volume:,}",
+ )
+ with c2:
+ st.metric(
+ "Supply ๊ฐ์ค์น",
+ f"{w_gemini:.1%}",
+ help=f"Gemini citation ๋ณผ๋ฅจ: {gemini_volume:,}",
+ )
+ with c3:
+ st.metric("๋งค์นญ ํ ํฝ", f"{len(matches)}๊ฐ")
+
+ st.markdown("---")
+
+ # Compute unified scores
+ scored = []
+ for m in matches:
+ demand_pct = float(m.get("demand_percentile") or 0)
+ supply_pct = float(m.get("supply_percentile") or 0)
+ unified = w_chatgpt * demand_pct + w_gemini * supply_pct
+ scored.append({
+ **m,
+ "unified_score": unified,
+ })
+
+ # Sort by unified score DESC
+ scored.sort(key=lambda x: x["unified_score"], reverse=True)
+
+ # --- Unified Ranking Table ---
+ st.markdown("#### Unified Score ๋ญํน")
+ st.caption(
+ "๋ ๋ชจ๋ธ์ ์ ํธ๋ฅผ ํตํฉํ ์์์
๋๋ค. "
+ "Unified Score๊ฐ ๋์์๋ก Demand์ Supply ๋ชจ๋์์ ์ค์ํ ํ ํฝ์
๋๋ค."
+ )
+
+ rows = []
+ for i, s in enumerate(scored, 1):
+ demand_pct = float(s.get("demand_percentile") or 0)
+ supply_pct = float(s.get("supply_percentile") or 0)
+ rows.append({
+ "#": i,
+ "ํ ํฝ (ChatGPT)": (s.get("chatgpt_label") or "")[:30],
+ "ํ ํฝ (Gemini)": (s.get("gemini_label") or "")[:30],
+ "Unified": f"{s['unified_score']:.4f}",
+ "Demand": f"{demand_pct:.2%}",
+ "Supply": f"{supply_pct:.2%}",
+ "GapScore": f"{float(s.get('gap_score') or 0):.4f}",
+ "Quadrant": s.get("quadrant", "NICHE").replace("_", " ").title(),
+ })
+
+ df = pd.DataFrame(rows)
+ st.dataframe(df, use_container_width=True, hide_index=True)
+
+ st.markdown("---")
+
+ # --- Unified Score Distribution ---
+ st.markdown("#### Unified Score vs GapScore")
+ st.caption(
+ "X์ถ์ ํตํฉ ์ค์๋(๋์์๋ก ๋ ๋ชจ๋ธ ๋ชจ๋ ์ค์), "
+ "Y์ถ์ ๊ธฐํ ํฌ๊ธฐ(๋์์๋ก ์ฝํ
์ธ ์ ์ ROI๊ฐ ๋์)."
+ )
+
+ _render_unified_scatter(scored)
+
+
+def _render_unified_scatter(scored: list[dict]):
+ """Scatter: Unified Score (x) vs GapScore (y)."""
+ from .cross_model import QUADRANT_COLORS, QUADRANT_LABELS
+
+ xs, ys, colors, hovers, sizes = [], [], [], [], []
+
+ for s in scored:
+ unified = s["unified_score"]
+ gap = float(s.get("gap_score") or 0)
+ quadrant = s.get("quadrant", "NICHE")
+ chatgpt_label = s.get("chatgpt_label", "")
+
+ xs.append(unified)
+ ys.append(gap)
+ colors.append(QUADRANT_COLORS.get(quadrant, "#9CA3AF"))
+ sizes.append(max(8, min(25, unified * 30)))
+ hovers.append(
+ f"{chatgpt_label}
"
+ f"Unified: {unified:.4f}
"
+ f"GapScore: {gap:.4f}
"
+ f"Quadrant: {QUADRANT_LABELS.get(quadrant, quadrant)}"
+ )
+
+ fig = go.Figure()
+ fig.add_trace(go.Scatter(
+ x=xs,
+ y=ys,
+ mode="markers",
+ marker=dict(
+ size=sizes,
+ color=colors,
+ opacity=0.7,
+ line=dict(width=0.5, color="#333"),
+ ),
+ text=hovers,
+ hoverinfo="text",
+ showlegend=False,
+ ))
+
+ fig.update_layout(
+ title="Unified Score vs GapScore",
+ xaxis_title="Unified Score (ํตํฉ ์ค์๋)",
+ yaxis_title="GapScore (์ฝํ
์ธ ๊ธฐํ)",
+ height=450,
+ template="plotly_white",
+ hoverlabel=dict(bgcolor="white", font_size=12),
+ )
+
+ st.plotly_chart(fig, use_container_width=True, key="cross_model:unified_scatter", config={"displayModeBar": False})
+
+ # Insight: Top-right quadrant = high importance + high opportunity
+ high_unified = [s for s in scored if s["unified_score"] > 0.5]
+ high_gap_and_unified = [
+ s for s in high_unified
+ if float(s.get("gap_score") or 0) > 0.05
+ ]
+ if high_gap_and_unified:
+ st.info(
+ f"Unified Score > 0.5 ์ด๋ฉด์ GapScore๊ฐ ๋์ ํ ํฝ์ด "
+ f"**{len(high_gap_and_unified)}๊ฐ** ์์ต๋๋ค. "
+ f"์ด ํ ํฝ๋ค์ ๋ ๋ชจ๋ธ ๋ชจ๋์์ ์ค์ํ๋ฉด์ ์ฝํ
์ธ ๊ธฐํ๋ ํฐ ์ต์ฐ์ ์์ญ์
๋๋ค."
+ )
diff --git a/features/sentiment/__init__.py b/features/sentiment/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..5916f404eddd1f5cab5fab85712471b839152ee2
--- /dev/null
+++ b/features/sentiment/__init__.py
@@ -0,0 +1,63 @@
+"""๊ฐ์ฑ ๋ถ์ Feature Plugin.
+
+API: /api/v1/sentiment
+"""
+import streamlit as st
+
+from . import run, overview, in_house, competitor, keyword_analysis, feedback, summary
+from .data import load_sentiment_data
+
+FEATURE_CONFIG = {
+ "key": "sentiment",
+ "name": "๊ฐ์ฑ ๋ถ์",
+ "icon": "๐",
+ "description": "AI ํ๋ซํผ์ ๋ธ๋๋ ๊ฐ์ฑ ๋ถ์ ๊ฒฐ๊ณผ",
+ "api_base": "/api/v1/sentiment",
+ "order": 1,
+}
+
+
+def render(base_ctx):
+ """๊ฐ์ฑ ๋ถ์ feature ๋ ๋๋ง."""
+ with st.spinner("๊ฐ์ฑ ๋ถ์ ๋ฐ์ดํฐ ๋ก๋ฉ ์ค..."):
+ data = load_sentiment_data(
+ base_ctx.get("api_key") or "",
+ base_ctx["campaign_id"],
+ access_token=base_ctx.get("access_token") or "",
+ )
+
+ if data is None:
+ st.error("๊ฐ์ฑ ๋ถ์ ๋ฐ์ดํฐ ๋ก๋ฉ ์คํจ")
+ return
+
+ # Inject base_ctx fields into data
+ data["campaign_name"] = base_ctx.get("campaign_name", "")
+
+ # 1. Feature summary
+ summary.render_summary(data)
+
+ # 2. Sub-tabs
+ tabs = st.tabs([
+ "๐ ์คํ์์ฒญ",
+ "๐ ์ค๋ฒ๋ทฐ",
+ "๐ ์์ฌ ๋ธ๋๋",
+ "๐ข ๊ฒฝ์์ฌ",
+ "๐ ํค์๋ ๋ถ์",
+ ])
+
+ tab_renderers = [
+ ("์คํ์์ฒญ", run.render),
+ ("์ค๋ฒ๋ทฐ", overview.render),
+ ("์์ฌ ๋ธ๋๋", in_house.render),
+ ("๊ฒฝ์์ฌ", competitor.render),
+ ("ํค์๋ ๋ถ์", keyword_analysis.render),
+ ]
+ for tab, (label, renderer) in zip(tabs, tab_renderers):
+ with tab:
+ try:
+ renderer(data)
+ except Exception as e:
+ st.error(f"{label} ๋ก๋ฉ ์คํจ: {e}")
+
+ # 3. Feedback (below tabs)
+ feedback.render_feedback_stats(data.get("feedback_stats", {}))
diff --git a/features/sentiment/competitor.py b/features/sentiment/competitor.py
new file mode 100644
index 0000000000000000000000000000000000000000..db5b04817ef550dcd1582aa69411be63d6eee794
--- /dev/null
+++ b/features/sentiment/competitor.py
@@ -0,0 +1,576 @@
+"""๊ฐ์ฑ๋ถ์ ๊ฒฝ์์ฌ ๋ถ์ ํญ.
+
+๊ฒฝ์์ฌ ๋ธ๋๋๋ณ ๊ฐ์ฑ ๋ถ์ ๊ฒฐ๊ณผ ๋ฐ ๋ถ์ ์ธ๊ธ ๋ถ์.
+"""
+import html
+
+import streamlit as st
+
+from core.api_client import ChainShiftClient
+from core.charts import CONFIDENCE_TIER_COLORS, EMOTION_KO, create_brand_sentiment_chart
+from core.athena_client import fetch_full_answer
+from core.styles import TIER_BORDER_COLORS
+from core.utils import (
+ get_confidence_tier,
+ get_llm_tier_badge,
+ highlight_evidence_spans,
+ truncate_text,
+)
+
+from .data import _get_competitor_mentions
+
+
+def render(data: dict):
+ """๊ฒฝ์์ฌ ๋ถ์ ํญ ๋ ๋๋ง."""
+ st.markdown("##### ๐ข ๊ฒฝ์์ฌ ๋ธ๋๋ ๋ถ์ ์ธ๊ธ ๋ถ์")
+ st.caption("AI ํ๋ซํผ์์ ๊ฒฝ์์ฌ ๋ธ๋๋๊ฐ ๋ถ์ ์ ์ผ๋ก ์ธ๊ธ๋๋ ์ฌ๋ก๋ฅผ ๋ถ์ํฉ๋๋ค")
+
+ # Initialize page state
+ if "sentiment:comp_page" not in st.session_state:
+ st.session_state["sentiment:comp_page"] = 1
+
+ # Brand summary from pre-loaded data (Supabase RPC via data.py)
+ brand_data = data.get("brand_data", {})
+ competitor_summary = brand_data.get("competitor_summary", [])
+ brand_names = ["์ ์ฒด"] + [b.get("brand_name", "") for b in competitor_summary if b.get("brand_name")]
+
+ # Filters โ Row 1: ๊ฐ์ฑ, ํ๋ซํผ, 2์ฐจ ๊ฒ์ฆ, ๋ธ๋๋
+ f1, f2, f3, f4 = st.columns(4)
+ with f1:
+ polarity_filter = st.selectbox(
+ "๊ฐ์ฑ",
+ options=["์ ์ฒด", "negative", "positive", "neutral"],
+ format_func=lambda x: {"์ ์ฒด": "์ ์ฒด", "negative": "๋ถ์ ", "positive": "๊ธ์ ", "neutral": "์ค๋ฆฝ"}.get(x, x),
+ key="sentiment:comp_polarity",
+ )
+ with f2:
+ platform_filter = st.selectbox(
+ "ํ๋ซํผ",
+ options=["์ ์ฒด", "CHATGPT", "GEMINI", "PERPLEXITY", "CLAUDE"],
+ key="sentiment:comp_platform",
+ )
+ with f3:
+ llm_filter = st.selectbox(
+ "2์ฐจ ๊ฒ์ฆ",
+ options=["์ ์ฒด", "์ ํ", "์คํ", "๋ฏธ๊ฒ์ฆ"],
+ key="sentiment:comp_llm",
+ )
+ with f4:
+ brand_filter = st.selectbox(
+ "๋ธ๋๋",
+ options=brand_names,
+ key="sentiment:comp_brand",
+ )
+
+ # Filters โ Row 2: ํ์ด์ง ํฌ๊ธฐ (์ฐ์ธก ์ ๋ ฌ)
+ _, size_col = st.columns([4, 1])
+ with size_col:
+ page_size = st.selectbox("ํ์ด์ง ํฌ๊ธฐ", options=[20, 50, 100], index=1, key="sentiment:comp_page_size")
+
+ # Reset page when filter changes
+ current_filters = f"{polarity_filter}_{platform_filter}_{llm_filter}_{brand_filter}_{page_size}"
+ if st.session_state.get("sentiment:comp_last_filters") != current_filters:
+ st.session_state["sentiment:comp_page"] = 1
+ st.session_state["sentiment:comp_last_filters"] = current_filters
+
+ current_page = st.session_state["sentiment:comp_page"]
+
+ # Fetch competitor data with all filters (server-side via RPC)
+ try:
+ polarity_param = polarity_filter if polarity_filter != "์ ์ฒด" else None
+ platform_param = platform_filter if platform_filter != "์ ์ฒด" else None
+ brand_param = brand_filter if brand_filter != "์ ์ฒด" else None
+
+ # Map ์ ํ/์คํ/๋ฏธ๊ฒ์ฆ โ direct bool params (server-side filtering)
+ llm_verified_param: bool | None = None
+ llm_is_neg_param: bool | None = None
+ if llm_filter == "์ ํ":
+ llm_verified_param = True
+ llm_is_neg_param = True
+ elif llm_filter == "์คํ":
+ llm_verified_param = True
+ llm_is_neg_param = False
+ elif llm_filter == "๋ฏธ๊ฒ์ฆ":
+ llm_verified_param = False
+
+ resp_data = _get_competitor_mentions(
+ "sb",
+ data["campaign_id"],
+ polarity=polarity_param,
+ competitor_llm_verified=llm_verified_param,
+ competitor_llm_is_negative=llm_is_neg_param,
+ brand_name=brand_param,
+ platform=platform_param,
+ page=current_page,
+ page_size=page_size,
+ )
+
+ recent_mentions = resp_data.get("recent_mentions", [])
+ total_answers = resp_data.get("total_answers", 0)
+
+ except Exception as e:
+ st.error(f"๊ฒฝ์์ฌ ๋ฐ์ดํฐ ๋ก๋ ์คํจ: {e}")
+ return
+
+ # Summary stats with filter info
+ filter_tags = []
+ if polarity_filter != "์ ์ฒด":
+ filter_tags.append(f"๊ฐ์ฑ:{polarity_filter}")
+ if platform_filter != "์ ์ฒด":
+ filter_tags.append(f"ํ๋ซํผ:{platform_filter}")
+ if llm_filter != "์ ์ฒด":
+ filter_tags.append(f"LLM:{llm_filter}")
+ if brand_filter != "์ ์ฒด":
+ filter_tags.append(f"๋ธ๋๋:{brand_filter}")
+
+ # Stats and Export button row
+ stat_col, export_col = st.columns([4, 1])
+
+ with stat_col:
+ if filter_tags:
+ st.markdown(f"**ํํฐ ์ ์ฉ**: {' | '.join(filter_tags)} โ **{total_answers:,}๊ฑด**")
+ else:
+ st.markdown(f"**๋ถ์๋ AI ๋ต๋ณ**: {total_answers:,}๊ฑด")
+
+ with export_col:
+ if st.button("๐ฅ Excel ๋ค์ด๋ก๋", key="sentiment:comp_export_btn"):
+ with st.spinner("Excel ํ์ผ ์์ฑ ์ค..."):
+ try:
+ client = ChainShiftClient(api_key=data.get("api_key"), access_token=data.get("access_token"))
+ # Map bool params back to string for API export endpoint
+ export_llm = None
+ if llm_verified_param is True:
+ export_llm = "verified"
+ elif llm_verified_param is False:
+ export_llm = "unverified"
+ excel_data = client.export_brand_mentions(
+ data["campaign_id"],
+ brand_type="competitor",
+ polarity=polarity_param,
+ llm_verified=export_llm,
+ brand_name=brand_param,
+ )
+ st.session_state["sentiment:comp_excel_data"] = excel_data
+ st.session_state["sentiment:comp_excel_ready"] = True
+ except Exception as e:
+ st.error(f"Excel ์์ฑ ์คํจ: {e}")
+
+ # Download button if data is ready
+ if st.session_state.get("sentiment:comp_excel_ready"):
+ st.download_button(
+ label="๐พ ์ ์ฅ",
+ data=st.session_state["sentiment:comp_excel_data"],
+ file_name=f"competitor_mentions_{data['campaign_id']}.xlsx",
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ key="sentiment:comp_dl_btn",
+ )
+
+ if not competitor_summary and not recent_mentions:
+ st.info("๊ฒฝ์์ฌ ๋ธ๋๋ ์ธ๊ธ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค")
+ return
+
+ # Brand summary cards (always shows ALL data for context)
+ if competitor_summary:
+ st.markdown("---")
+ st.markdown("##### ๐ข ๊ฒฝ์์ฌ ๋ธ๋๋ ์์ฝ")
+ st.caption("๐ ์ ์ฒด ๋ฐ์ดํฐ ๊ธฐ์ค (ํํฐ ๋ฏธ์ ์ฉ)")
+
+ # Create columns for brand cards (max 3 per row)
+ for i in range(0, len(competitor_summary), 3):
+ cols = st.columns(3)
+ for j, col in enumerate(cols):
+ if i + j < len(competitor_summary):
+ brand = competitor_summary[i + j]
+ with col:
+ _render_brand_summary_card(brand)
+
+ # Brand sentiment comparison chart
+ st.markdown("---")
+ st.markdown("##### ๐ ๊ฒฝ์์ฌ ๋ธ๋๋๋ณ ๊ฐ์ฑ ๋น๊ต")
+ if len(competitor_summary) > 0:
+ fig = create_brand_sentiment_chart(competitor_summary)
+ st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
+
+ # Recent mentions list
+ st.markdown("---")
+ st.markdown("##### ๐ ๊ฒฝ์์ฌ ์ธ๊ธ ๋ชฉ๋ก")
+
+ # Calculate pagination info
+ total_pages = max(1, (total_answers + page_size - 1) // page_size)
+ start_idx = (current_page - 1) * page_size + 1
+ end_idx = min(current_page * page_size, total_answers)
+
+ # Pagination header
+ col_info, col_prev, col_page, col_next = st.columns([3, 1, 1, 1])
+
+ with col_info:
+ st.markdown(f"**์ ์ฒด {total_answers:,}๊ฑด** | ํ์ด์ง {current_page}/{total_pages} ({start_idx}-{end_idx}๊ฑด)")
+
+ with col_prev:
+ if st.button("โฌ
๏ธ ์ด์ ", disabled=current_page <= 1, key="sentiment:comp_prev"):
+ st.session_state["sentiment:comp_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:comp_page_input",
+ )
+ if new_page != current_page:
+ st.session_state["sentiment:comp_page"] = new_page
+ st.rerun()
+
+ with col_next:
+ if st.button("๋ค์ โก๏ธ", disabled=current_page >= total_pages, key="sentiment:comp_next"):
+ st.session_state["sentiment:comp_page"] = current_page + 1
+ st.rerun()
+
+ if not recent_mentions:
+ st.info("ํ์ฌ ํ์ด์ง์ ํ์ํ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.")
+ return
+
+ # Render mention cards - use unique index for each card
+ for i, item in enumerate(recent_mentions):
+ # Create unique index combining page and position to avoid key collisions
+ unique_idx = (current_page - 1) * page_size + i
+ _render_mention_card(data, item, unique_idx)
+
+
+def _render_brand_summary_card(brand: dict):
+ """๋ธ๋๋ ์์ฝ ์นด๋ ๋ ๋๋ง."""
+ brand_name = brand.get("brand_name", "Unknown")
+ total_mentions = brand.get("total_mentions", 0)
+ positive_count = brand.get("positive_count", 0)
+ negative_count = brand.get("negative_count", 0)
+ neutral_count = brand.get("neutral_count", 0)
+ positive_rate = brand.get("positive_rate", 0)
+ negative_rate = brand.get("negative_rate", 0)
+ neutral_rate = round(neutral_count / total_mentions * 100, 1) if total_mentions > 0 else 0.0
+
+ # Determine primary sentiment color
+ if negative_rate > positive_rate:
+ bg_color = "#FEF2F2" # Light red
+ border_color = "#FCA5A5"
+ elif positive_rate > negative_rate:
+ bg_color = "#F0FDF4" # Light green
+ border_color = "#86EFAC"
+ else:
+ bg_color = "#FEF3C7" # Light yellow
+ border_color = "#FCD34D"
+
+ # Build optional lines
+ extra_lines = []
+
+ aliases = brand.get("aliases", [])
+ if aliases:
+ alias_str = ", ".join(aliases[:4])
+ if len(aliases) > 4:
+ alias_str += f" ์ธ {len(aliases) - 4}๊ฐ"
+ extra_lines.append(f'{alias_str}
')
+
+ verified_count = brand.get("llm_verified_count", 0)
+ if verified_count > 0:
+ verified_rate = (verified_count / total_mentions * 100) if total_mentions > 0 else 0
+ extra_lines.append(f'LLM ๊ฒ์ฆ: {verified_count}๊ฑด ({verified_rate:.0f}%)
')
+
+ alias_html = extra_lines[0] if aliases else ""
+ llm_html = extra_lines[-1] if verified_count > 0 else ""
+
+ card_html = (
+ f''
+ f'
{brand_name}
'
+ f'{alias_html}'
+ f'
์ด ์ธ๊ธ: {total_mentions:,}๊ฑด
'
+ f'
'
+ f'๊ธ์ {positive_count}๊ฑด ({positive_rate:.1f}%)'
+ f'์ค๋ฆฝ {neutral_count}๊ฑด ({neutral_rate:.1f}%)'
+ f'๋ถ์ {negative_count}๊ฑด ({negative_rate:.1f}%)'
+ f'
'
+ f'{llm_html}'
+ f'
'
+ )
+ st.markdown(card_html, unsafe_allow_html=True)
+
+
+def _render_mention_card(data: dict, item: dict, index: int):
+ """๊ฐ๋ณ ์ธ๊ธ ์นด๋ ๋ ๋๋ง."""
+ # Extract data
+ polarity = item.get("overall_polarity", "neutral")
+ confidence = item.get("overall_confidence", 0) or 0
+ tier, emoji, tier_desc = get_confidence_tier(confidence)
+
+ platform = item.get("platform", "N/A")
+ question = item.get("question_content", "")
+ answer = item.get("answer_content") or item.get("answer_preview") or ""
+ # BrandMention already has brand_name field for the specific brand
+ brand_name = item.get("brand_name", "")
+ # For display, show the main brand from this mention
+ competitor_brands = [brand_name] if brand_name else []
+
+ # Polarity styling
+ polarity_colors = {
+ "negative": ("#FEF2F2", "#EF4444", "๐ ๋ถ์ "),
+ "positive": ("#F0FDF4", "#10B981", "๐ ๊ธ์ "),
+ "neutral": ("#F5F5F4", "#6B7280", "๐ ์ค๋ฆฝ"),
+ }
+ bg_color, accent_color, polarity_label = polarity_colors.get(polarity, polarity_colors["neutral"])
+
+ tier_color = CONFIDENCE_TIER_COLORS.get(tier, "#6B7280")
+ border_color = TIER_BORDER_COLORS.get(tier, "#6B7280")
+
+ answer_id = item.get("answer_id")
+ question_display = html.escape(truncate_text(question, 200))
+ answer_short = html.escape(truncate_text(answer, 150))
+ brands_display = ", ".join(competitor_brands[:3]) if competitor_brands else "N/A"
+
+ # LLM verification status (flat DB fields from get_nudge_export_data RPC)
+ llm_verified = item.get("competitor_llm_verified", False)
+ if llm_verified:
+ llm_is_neg = item.get("competitor_llm_is_negative", False)
+ if llm_is_neg:
+ llm_badge = "๐ด ๋ถ์ ํ์ธ"
+ llm_badge_color = "#DC2626"
+ else:
+ llm_badge = "๐ข ๋ถ์ ์๋"
+ llm_badge_color = "#059669"
+ else:
+ llm_badge = "โณ ๋ฏธ๊ฒ์ฆ"
+ llm_badge_color = "#F59E0B"
+
+ # Card header โ left border strip style (matches in_house tab)
+ header_html = f'''
+
+
+ #{answer_id or index+1} โ {brands_display}
+
+ {llm_badge}
+ {tier}
+
+
+
+ {platform} | {polarity_label}{f" ({EMOTION_KO.get(item.get('dominant_emotion', ''), item.get('dominant_emotion', ''))})" if item.get("dominant_emotion") else ""} | ํ์ ๋ {confidence:.0%}
+
+
+ '''
+ st.markdown(header_html, unsafe_allow_html=True)
+
+ # Expander for full details
+ with st.expander(f"๐ ์์ธ ๋ณด๊ธฐ โ #{answer_id or index+1}"):
+ _render_mention_detail(data, item, answer_id, answer, index)
+
+
+def _render_mention_detail(data: dict, item: dict, answer_id: int | None, answer: str, index: int):
+ """์ธ๊ธ ์์ธ ์ ๋ณด ๋ ๋๋ง."""
+ confidence = item.get("overall_confidence", 0) or 0
+ tier, _, _ = get_confidence_tier(confidence)
+
+ # 1. Question context
+ question = item.get("question_content", "")
+ if question:
+ st.markdown(f"""
+
+
๐ฌ ์ง๋ฌธ
+
{html.escape(question[:500])}
+
+""", unsafe_allow_html=True)
+
+ # 2. AI ๋ต๋ณ
+ st.markdown("**๐ค AI ๋ต๋ณ**")
+ display_answer = _load_full_answer(answer_id, answer, index)
+
+ # 3. ๊ฐ์ฑ ๋ถ์ (ABSA)
+ brand_detail = item.get("brand_sentiments") or {}
+ if brand_detail and isinstance(brand_detail, dict):
+ _render_competitor_absa(brand_detail)
+
+ # 4. ์ธ์ฉ ์ถ์ฒ
+ _render_citations(answer_id, item, index)
+
+ # 5. LLM 2์ฐจ ๊ฒ์ฆ
+ st.markdown("---")
+ _render_llm_verification(data, item, answer_id, display_answer, index)
+
+
+def _load_full_answer(answer_id: int | None, answer: str, index: int) -> str:
+ """์ ์ฒด ๋ต๋ณ ๋ก๋."""
+ display_answer = answer or "N/A"
+
+ if answer_id:
+ full_answer_key = f"sentiment:comp_full_{answer_id}_{index}"
+ load_full_key = f"sentiment:comp_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_full_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 answer or "N/A"
+ label = "โ
์ ์ฒด ๋ต๋ณ ๋ก๋๋จ" if is_loaded else f"๐ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ({len(answer or '')}์)"
+ st.caption(label)
+
+ st.markdown(
+ f''
+ f'{html.escape(display_answer)}
',
+ unsafe_allow_html=True
+ )
+
+ return display_answer
+
+
+def _render_competitor_absa(brand_detail: dict):
+ """๊ฒฝ์์ฌ ABSA ๊ฒฐ๊ณผ ๋ ๋๋ง."""
+ st.markdown("**๐ ๋ธ๋๋๋ณ ๊ฐ์ฑ ๋ถ์ (ABSA)**")
+
+ # Parse competitor ABSA results
+ competitor_data = brand_detail.get("competitor", [])
+ competitor_absa = []
+ if isinstance(competitor_data, list):
+ competitor_absa = competitor_data
+ elif isinstance(competitor_data, dict):
+ 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''
+ f'{sentiment} {brand_name} (๐ข ๊ฒฝ์์ฌ) - {absa_emoji} ํ์ ๋ {conf:.0%} ({absa_tier})',
+ unsafe_allow_html=True
+ )
+ else:
+ st.caption("ABSA ๋ถ์ ๊ฒฐ๊ณผ ์์")
+
+
+def _render_citations(answer_id: int | None, item: dict, index: int):
+ """์ธ์ฉ ์ถ์ฒ ๋ ๋๋ง (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_llm_verification(data: dict, item: dict, answer_id: int | None, display_answer: str, index: int):
+ """LLM ๊ฒ์ฆ ์น์
๋ ๋๋ง."""
+ # Read from flat DB fields (raw Supabase row, not nested API dict)
+ llm_verified = item.get("competitor_llm_verified", False)
+ llm_is_negative = item.get("competitor_llm_is_negative")
+ llm_confidence = item.get("competitor_llm_confidence")
+ llm_evidence_spans = item.get("competitor_llm_evidence_spans") or []
+ llm_reasoning = item.get("competitor_llm_reasoning") or ""
+ llm_adjusted_tier = item.get("competitor_llm_adjusted_tier")
+
+ verify_key = f"sentiment:comp_verify_{answer_id}_{index}"
+ if verify_key not in st.session_state:
+ st.session_state[verify_key] = None
+
+ if llm_verified or st.session_state.get(verify_key):
+ verify_data = st.session_state.get(verify_key) or {
+ "is_negative": llm_is_negative,
+ "confidence": llm_confidence,
+ "evidence_spans": llm_evidence_spans,
+ "reasoning": llm_reasoning,
+ "adjusted_tier": llm_adjusted_tier,
+ }
+
+ badge_text, badge_color = get_llm_tier_badge(
+ verify_data.get("adjusted_tier"),
+ verify_data.get("is_negative")
+ )
+ llm_conf = verify_data.get("confidence", 0) or 0
+
+ st.markdown(f"""
+
+
+ ๐ฌ LLM 2์ฐจ ๊ฒ์ฆ
+ {badge_text}
+
+
+ LLM ํ์ ๋: {llm_conf:.0%}
+ ํ๋จ ๊ทผ๊ฑฐ: {html.escape(verify_data.get("reasoning", "N/A"))}
+
+
+""", unsafe_allow_html=True)
+
+ # Evidence spans
+ evidence_spans = verify_data.get("evidence_spans", [])
+ if evidence_spans and display_answer:
+ st.markdown("**๐ ๊ทผ๊ฑฐ ๋ฌธ์ฅ (ํ์ด๋ผ์ดํธ)**")
+ highlighted_html = highlight_evidence_spans(display_answer, evidence_spans)
+ st.markdown(
+ f'{highlighted_html}
',
+ unsafe_allow_html=True
+ )
+ st.caption("๐ด ๋ถ์ | ๐ข ๊ธ์ | ๐ต ์ค๋ฆฝ | ๐ก ๋น๊ต")
+
+ # Re-verify button
+ if st.button("๐ ์ฌ๊ฒ์ฆ ์์ฒญ", key=f"sentiment:comp_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"):
+ verified = result["data"].get("verified")
+ st.session_state[verify_key] = verified
+ st.rerun()
+ else:
+ st.info("์์ง LLM 2์ฐจ ๊ฒ์ฆ์ด ์ํ๋์ง ์์์ต๋๋ค.")
+ if st.button("๐ฌ LLM ๊ฒ์ฆ ์์ฒญ", key=f"sentiment:comp_verify_req_{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"):
+ verified = result["data"].get("verified")
+ st.session_state[verify_key] = verified
+ st.success("๊ฒ์ฆ ์๋ฃ!")
+ st.rerun()
+
+
+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
diff --git a/features/sentiment/data.py b/features/sentiment/data.py
new file mode 100644
index 0000000000000000000000000000000000000000..3bd7d7e02e4c3792d8dfa9f10b47c4fd95d829cf
--- /dev/null
+++ b/features/sentiment/data.py
@@ -0,0 +1,352 @@
+"""๊ฐ์ฑ๋ถ์ Feature ๋ฐ์ดํฐ ๋ก๋ฉ.
+
+All data fetched via direct Supabase (RPC + PostgREST).
+Vercel API is NOT used โ avoids 10s timeout and HTTP overhead.
+"""
+import logging
+import streamlit as st
+
+from core.api_client import ChainShiftClient
+from core.supabase_client import get_campaign_overview, get_supabase_client
+
+logger = logging.getLogger(__name__)
+
+
+@st.cache_data(ttl=60)
+def _get_nudge_data(_sb_id: str, campaign_id: int) -> dict:
+ """Fetch nudge stats (RPC) + all candidates (direct PostgREST).
+
+ _sb_id is a cache-buster (not used) since Supabase client isn't hashable.
+ """
+ try:
+ sb = get_supabase_client()
+
+ # 1. Aggregated stats via RPC (single query, ~3ms)
+ stats = sb.rpc("get_nudge_stats_agg", {"p_campaign_id": campaign_id}).execute()
+ nudge_stats = stats.data or {}
+
+ # 2. All candidates via RPC (inline CTE, avoids VIEW timeout)
+ candidates_rpc = sb.rpc("get_nudge_export_data", {
+ "p_campaign_id": campaign_id,
+ "p_in_house_only": True,
+ "p_limit": 500,
+ }).execute()
+ rpc_data = candidates_rpc.data or {}
+ candidates = rpc_data.get("rows", []) if isinstance(rpc_data, dict) else []
+
+ # Merge stats + candidates into nudge_data shape (backward-compatible)
+ nudge_data = {
+ "total_nudge_candidates": nudge_stats.get("total_nudge_candidates", 0),
+ "by_confidence_tier": nudge_stats.get("by_confidence_tier", {}),
+ "by_platform": nudge_stats.get("by_platform", {}),
+ "by_cej": nudge_stats.get("by_cej", {}),
+ "by_bit_quadrant": nudge_stats.get("by_bit_quadrant", {}),
+ "llm_verification_stats": nudge_stats.get("llm_verification_stats", {}),
+ "candidates": candidates,
+ }
+ return nudge_data
+ except Exception as e:
+ logger.warning("Nudge data fetch failed for campaign %s: %s", campaign_id, e)
+ return {}
+
+
+@st.cache_data(ttl=60)
+def _get_brand_stats(_sb_id: str, campaign_id: int) -> dict:
+ """Fetch brand mention stats via RPC (DB-side aggregation).
+
+ Returns dict compatible with overview.py's brand_data format:
+ - total_answers: int
+ - in_house_summary: list of brand stat dicts
+ - competitor_summary: list of brand stat dicts
+ """
+ try:
+ sb = get_supabase_client()
+
+ # Single RPC returns brand stats + total count (no separate VIEW query)
+ result = sb.rpc("get_brand_mention_stats_agg", {"p_campaign_id": campaign_id}).execute()
+ data = result.data
+ # PostgREST may wrap json return as [dict] or dict
+ if isinstance(data, list) and data:
+ data = data[0]
+ if not isinstance(data, dict):
+ return {"total_answers": 0, "in_house_summary": [], "competitor_summary": []}
+
+ brands = data.get("brands") or []
+ total_answers = int(data.get("total_unique_answers") or 0)
+
+ in_house_summary = []
+ competitor_summary = []
+
+ for b in brands:
+ total = b["total_mentions"]
+ entry = {
+ "brand_name": b["brand_name"],
+ "brand_type": b["brand_type"],
+ "total_mentions": total,
+ "positive_count": b["positive_count"],
+ "negative_count": b["negative_count"],
+ "neutral_count": b["neutral_count"],
+ "positive_rate": round(100.0 * b["positive_count"] / total, 2) if total > 0 else 0.0,
+ "negative_rate": round(100.0 * b["negative_count"] / total, 2) if total > 0 else 0.0,
+ }
+ if b["brand_type"] == "IN_HOUSE":
+ in_house_summary.append(entry)
+ else:
+ competitor_summary.append(entry)
+
+ # Enrich competitor brands with aliases (synonyms) and llm_verified_count
+ if competitor_summary:
+ synonyms_map: dict[str, list] = {}
+ verified_map: dict[str, int] = {}
+ try:
+ brands_info = sb.table("brands_sync").select(
+ "name, synonyms"
+ ).eq("rds_campaign_id", campaign_id).eq(
+ "brand_type", "SECONDARY"
+ ).execute()
+ synonyms_map = {
+ row["name"]: row.get("synonyms") or []
+ for row in (brands_info.data or [])
+ }
+ except Exception:
+ pass
+ try:
+ vcounts = sb.rpc("get_competitor_llm_verified_counts", {"p_campaign_id": campaign_id}).execute()
+ # PostgREST wraps json return as [result].
+ # RPC uses json_agg โ array. Actual shape: [[{brand_name, verified_count}, ...]]
+ vdata = vcounts.data
+ if isinstance(vdata, list) and len(vdata) > 0 and isinstance(vdata[0], list):
+ vdata = vdata[0] # unwrap PostgREST double-nesting
+ elif isinstance(vdata, dict):
+ vdata = [vdata] # single dict โ wrap in list
+ elif not isinstance(vdata, list):
+ vdata = []
+ for row in vdata:
+ if isinstance(row, dict):
+ verified_map[row.get("brand_name", "")] = row.get("verified_count", 0)
+ except Exception:
+ pass
+ for entry in competitor_summary:
+ entry["aliases"] = synonyms_map.get(entry["brand_name"], [])
+ entry["llm_verified_count"] = verified_map.get(entry["brand_name"], 0)
+
+ return {
+ "total_answers": total_answers,
+ "in_house_summary": in_house_summary,
+ "competitor_summary": competitor_summary,
+ }
+ except Exception as e:
+ logger.warning("Brand stats fetch failed for campaign %s: %s", campaign_id, e)
+ return {}
+
+
+@st.cache_data(ttl=60)
+def _get_feedback_stats(_sb_id: str, campaign_id: int) -> dict:
+ """Fetch feedback stats via RPC (DB-side aggregation)."""
+ try:
+ sb = get_supabase_client()
+ result = sb.rpc("get_feedback_stats_agg", {"p_campaign_id": campaign_id}).execute()
+
+ data = result.data
+ # PostgREST may wrap json return as [dict] or dict
+ if isinstance(data, list) and data:
+ data = data[0]
+ if not isinstance(data, dict):
+ return {}
+
+ correct_count = data.get("correct_count", 0)
+ wrong_count = data.get("wrong_count", 0)
+ evaluated = correct_count + wrong_count
+ data["accuracy_rate"] = round(correct_count / evaluated * 100, 1) if evaluated > 0 else 0.0
+
+ return data
+ except Exception as e:
+ logger.warning("Feedback stats fetch failed for campaign %s: %s", campaign_id, e)
+ return {}
+
+
+@st.cache_data(ttl=120)
+def _get_keyword_summary(_sb_id: str, campaign_id: int):
+ """Get keyword summary via direct Supabase RPC (bypasses Vercel 10s timeout)."""
+ try:
+ sb = get_supabase_client()
+
+ # DB-side aggregation (covering indexes: <0.3s for 660K+ rows)
+ summary = sb.rpc("get_keyword_summary_agg", {"p_campaign_id": campaign_id}).execute()
+ tags = sb.rpc("get_keyword_llm_tags_agg", {"p_campaign_id": campaign_id}).limit(10000).execute()
+
+ # Build tag lookup
+ tag_map: dict[str, dict[str, int]] = {}
+ for tr in (tags.data or []):
+ kw = tr["keyword"]
+ if kw not in tag_map:
+ tag_map[kw] = {}
+ tag_map[kw][tr["tag"]] = int(tr["tag_count"])
+
+ total_sentences = 0
+ keywords = []
+ for row in sorted(summary.data or [], key=lambda r: r["keyword"]):
+ kw = row["keyword"]
+ total = int(row["total_sentences"])
+ total_sentences += total
+ brand_count = int(row["brand_mentioned_count"])
+
+ kw_tags = tag_map.get(kw, {})
+ top_tags = dict(sorted(kw_tags.items(), key=lambda x: -x[1])[:20])
+
+ item = {
+ "keyword": kw,
+ "total_sentences": total,
+ "keyword_sentiment": {
+ "positive": int(row["kw_positive"]),
+ "neutral": int(row["kw_neutral"]),
+ "negative": int(row["kw_negative"]),
+ },
+ "brand_mentioned_count": brand_count,
+ "brand_sentiment": {
+ "positive": int(row["brand_positive"]),
+ "neutral": int(row["brand_neutral"]),
+ "negative": int(row["brand_negative"]),
+ } if brand_count > 0 else None,
+ "llm_reason_tags": top_tags,
+ }
+ keywords.append(item)
+
+ return {
+ "campaign_id": campaign_id,
+ "total_keywords": len(keywords),
+ "total_sentences": total_sentences,
+ "keywords": keywords,
+ }
+ except Exception as e:
+ logger.warning("Keyword summary RPC failed for campaign %s: %s", campaign_id, e)
+ st.warning(f"ํค์๋ RPC ์คํจ: {e}")
+ return None
+
+
+@st.cache_data(ttl=60)
+def _get_competitor_mentions(
+ _sb_id: str,
+ campaign_id: int,
+ polarity: str | None = None,
+ competitor_llm_verified: bool | None = None,
+ competitor_llm_is_negative: bool | None = None,
+ brand_name: str | None = None,
+ platform: str | None = None,
+ page: int = 1,
+ page_size: int = 50,
+) -> dict:
+ """Fetch competitor brand mentions via RPC (server-side filtering + pagination).
+
+ All filters and pagination are handled by `get_nudge_export_data` RPC.
+ _sb_id is a cache-buster (not used) since Supabase client isn't hashable.
+ """
+ try:
+ sb = get_supabase_client()
+
+ params: dict = {
+ "p_campaign_id": campaign_id,
+ "p_in_house_only": False,
+ "p_has_mentioned_brands": True,
+ "p_offset": (page - 1) * page_size,
+ "p_limit": page_size,
+ }
+ if polarity:
+ params["p_polarity"] = polarity
+ if brand_name:
+ params["p_brand_name"] = brand_name
+ if platform:
+ params["p_platform"] = platform
+ if competitor_llm_verified is not None:
+ params["p_competitor_llm_verified"] = competitor_llm_verified
+ if competitor_llm_is_negative is not None:
+ params["p_competitor_llm_is_negative"] = competitor_llm_is_negative
+
+ result = sb.rpc("get_nudge_export_data", params).execute()
+ data = result.data or {}
+ if not isinstance(data, dict):
+ data = {}
+
+ return {
+ "recent_mentions": data.get("rows", []),
+ "total_answers": data.get("total", 0),
+ }
+ except Exception as e:
+ logger.warning("Competitor mentions fetch failed for campaign %s: %s", campaign_id, e)
+ return {"recent_mentions": [], "total_answers": 0}
+
+
+def load_sentiment_data(api_key: str, campaign_id: int, access_token: str = "") -> dict | None:
+ """๊ฐ์ฑ๋ถ์์ ํ์ํ ๋ชจ๋ ๋ฐ์ดํฐ ๋ก๋ฉ.
+
+ All data fetched via direct Supabase (no Vercel API dependency).
+
+ Returns:
+ dict with all sentiment data, or None on failure.
+ """
+ try:
+ # Cache buster for Supabase client (not hashable by st.cache_data)
+ sb_id = "sb"
+
+ # Direct Supabase: nudge stats RPC + candidates PostgREST
+ nudge_data = _get_nudge_data(sb_id, campaign_id)
+
+ # Direct Supabase: brand aggregation RPC
+ brand_data = _get_brand_stats(sb_id, campaign_id)
+
+ # Direct Supabase: feedback stats PostgREST
+ feedback_stats = _get_feedback_stats(sb_id, campaign_id)
+
+ try:
+ campaign_overview = get_campaign_overview(campaign_id)
+ except Exception:
+ campaign_overview = {}
+
+ # Extract metrics
+ total_nudge = nudge_data.get("total_nudge_candidates", 0)
+ tier_stats = {k: v for k, v in nudge_data.get("by_confidence_tier", {}).items() if k}
+ platform_stats = {k: v for k, v in nudge_data.get("by_platform", {}).items() if k}
+ cej_stats = {k: v for k, v in nudge_data.get("by_cej", {}).items() if k}
+ bit_stats = {k: v for k, v in nudge_data.get("by_bit_quadrant", {}).items() if k}
+ candidates = nudge_data.get("candidates", [])
+ llm_stats = nudge_data.get("llm_verification_stats", {})
+
+ risk_score = ChainShiftClient.calculate_risk_score(tier_stats)
+ domain_counts = ChainShiftClient.aggregate_citation_domains(candidates)
+
+ # Keyword data (optional, doesn't fail if unavailable)
+ keyword_data = _get_keyword_summary(sb_id, campaign_id) or {}
+
+ # Overview derived values
+ overview_llm_done = campaign_overview.get("llm_verified_in_house", 0)
+ overview_llm_confirmed = campaign_overview.get("llm_confirmed_negative", 0)
+
+ return {
+ "api_key": api_key,
+ "access_token": access_token,
+ "campaign_id": campaign_id,
+ "nudge_data": nudge_data,
+ "brand_data": brand_data,
+ "feedback_stats": feedback_stats,
+ "campaign_overview": campaign_overview,
+ "candidates": candidates,
+ "total_nudge": total_nudge,
+ "tier_stats": tier_stats,
+ "platform_stats": platform_stats,
+ "cej_stats": cej_stats,
+ "bit_stats": bit_stats,
+ "domain_counts": domain_counts,
+ "risk_score": risk_score,
+ "high_count": tier_stats.get("HIGH", 0),
+ "medium_count": tier_stats.get("MEDIUM", 0),
+ "low_count": tier_stats.get("LOW", 0),
+ "overview_total_answers": campaign_overview.get("total_answers", 0),
+ "overview_nudge_candidates": campaign_overview.get("in_house_negative_count", 0),
+ "overview_llm_verified": overview_llm_done,
+ "overview_llm_pending": campaign_overview.get("llm_pending", 0),
+ "overview_false_positive_rate": (overview_llm_done - overview_llm_confirmed) / overview_llm_done if overview_llm_done > 0 else 0,
+ "keyword_data": keyword_data,
+ "llm_verification_stats": llm_stats,
+ }
+ except Exception:
+ return None
diff --git a/features/sentiment/feedback.py b/features/sentiment/feedback.py
new file mode 100644
index 0000000000000000000000000000000000000000..dfa32eacf665df12b55b2c765f3d6a27e4bd9cf0
--- /dev/null
+++ b/features/sentiment/feedback.py
@@ -0,0 +1,66 @@
+"""ํผ๋๋ฐฑ ํต๊ณ ์น์
.
+
+์ฌ์ฉ์ ๊ฒ์ฆ ํํฉ (Human-in-the-Loop).
+"""
+import pandas as pd
+import streamlit as st
+
+from core.utils import get_feedback_reason_label, get_feedback_type_emoji
+
+
+def render_feedback_stats(feedback_stats: dict):
+ """ํผ๋๋ฐฑ ํต๊ณ ์น์
๋ ๋๋ง."""
+ if not feedback_stats or feedback_stats.get("total_feedback", 0) == 0:
+ return
+
+ with st.expander("๐ **ํผ๋๋ฐฑ ๋ถ์** - ์ฌ์ฉ์ ๊ฒ์ฆ ํํฉ", expanded=False):
+ fb_total = feedback_stats.get("total_feedback", 0)
+ fb_correct = feedback_stats.get("correct_count", 0)
+ fb_wrong = feedback_stats.get("wrong_count", 0)
+ fb_ambiguous = feedback_stats.get("ambiguous_count", 0)
+ accuracy = feedback_stats.get("accuracy_rate", 0)
+
+ fb_col1, fb_col2, fb_col3, fb_col4, fb_col5 = st.columns(5)
+
+ with fb_col1:
+ st.metric(label="์ด ํผ๋๋ฐฑ", value=f"{fb_total}๊ฑด", help="์ฌ์ฉ์๊ฐ ์ ์ถํ ์ด ํผ๋๋ฐฑ ์")
+ with fb_col2:
+ st.metric(
+ label="๐ ์ ํ", value=f"{fb_correct}๊ฑด",
+ delta=f"{fb_correct/fb_total*100:.0f}%" if fb_total > 0 else None,
+ delta_color="normal", help="์ ํํ๋ค๊ณ ํ๊ฐ๋ ๋ถ์ ์",
+ )
+ with fb_col3:
+ st.metric(
+ label="๐ ์ค๋ฅ", value=f"{fb_wrong}๊ฑด",
+ delta=f"{fb_wrong/fb_total*100:.0f}%" if fb_total > 0 else None,
+ delta_color="inverse", help="ํ๋ ธ๋ค๊ณ ํ๊ฐ๋ ๋ถ์ ์",
+ )
+ with fb_col4:
+ st.metric(label="๐ค ์ ๋งค", value=f"{fb_ambiguous}๊ฑด", help="ํ๋จํ๊ธฐ ์ด๋ ค์ด ๊ฒฝ์ฐ")
+ with fb_col5:
+ st.metric(label="์ ํ๋", value=f"{accuracy:.1f}%", help="correct / (correct + wrong) x 100")
+
+ wrong_reasons = feedback_stats.get("wrong_reasons", {})
+ if wrong_reasons:
+ st.markdown("##### ์ค๋ฅ ์์ธ ๋ถํฌ")
+ reason_df = pd.DataFrame([
+ {"์์ธ": get_feedback_reason_label(k), "๊ฑด์": v}
+ for k, v in wrong_reasons.items()
+ ]).sort_values("๊ฑด์", ascending=False)
+ st.dataframe(reason_df, use_container_width=True, hide_index=True)
+
+ recent_feedback = feedback_stats.get("recent_feedback", [])
+ if recent_feedback:
+ st.markdown("##### ์ต๊ทผ ํผ๋๋ฐฑ (10๊ฑด)")
+ recent_df = pd.DataFrame([
+ {
+ "Answer ID": fb.get("answer_id"),
+ "์ ํ": get_feedback_type_emoji(fb.get("feedback_type", "")),
+ "์์ธ": get_feedback_reason_label(fb.get("wrong_reason")) if fb.get("wrong_reason") else "-",
+ "์ฝ๋ฉํธ": fb.get("comment", "-")[:50] + "..." if fb.get("comment") and len(fb.get("comment", "")) > 50 else fb.get("comment", "-"),
+ "์๊ฐ": fb.get("created_at", "")[:16].replace("T", " ") if fb.get("created_at") else "-",
+ }
+ for fb in recent_feedback
+ ])
+ st.dataframe(recent_df, use_container_width=True, hide_index=True)
diff --git a/features/sentiment/in_house.py b/features/sentiment/in_house.py
new file mode 100644
index 0000000000000000000000000000000000000000..44fb3636da43ec650b5c870a1fa570fbd96becbb
--- /dev/null
+++ b/features/sentiment/in_house.py
@@ -0,0 +1,626 @@
+"""์์ฌ ๋ธ๋๋ ๋ถ์ ํญ.
+
+์์ฌ ๋ธ๋๋ ๋ถ์ ์ธ๊ธ ๋ถ์ + 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"""
+
+
์ถ์ ๋ธ๋๋: {html.escape(brands_display)}
+
+ ์ ์ฒด ๋ถ์ ๊ฐ์ง: {total}๊ฑด
+ ๐ด HIGH: {high} | ๐ก MEDIUM: {medium} | ๐ข LOW: {low}
+ LLM ๊ฒ์ฆ: {llm_text}
+
+
+""", 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 = '๐ด ๋ถ์ ํ์ธ'
+ else:
+ llm_badge = '๐ข ๋ถ์ ์๋'
+ else:
+ llm_badge = 'โณ ๋ฏธ๊ฒ์ฆ'
+
+ st.markdown(f"""
+
+
+ #{answer_id} โ {', '.join(brands) if brands else 'N/A'}
+ {llm_badge} {tier}
+
+
+ {platform} | ๐ ๋ถ์ ({emotion}) | ํ์ ๋ {confidence:.0%}
+
+
+ """, 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"""
+
+
๐ฌ ์ง๋ฌธ
+
{html.escape(question[:500])}
+
+""", 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"""
+
+
+ ๐ฌ LLM 2์ฐจ ๊ฒ์ฆ
+ {badge_text}
+
+
+ LLM ํ์ ๋: {llm_confidence:.0%}
+ ํ๋จ ๊ทผ๊ฑฐ: {html.escape(llm_reasoning[:500]) if llm_reasoning else 'N/A'}
+
+
+""", 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'{highlighted_html}
',
+ 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''
+ f'{html.escape(display_answer)}
',
+ 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'{brand}: {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'๐ด ๋ถ์ ํ์ธ ({tier})'
+ border_color = "#DC2626"
+ else:
+ badge = '๐ข ๋ถ์ ์๋'
+ border_color = "#059669"
+
+ st.markdown(f"""
+
+
+ {html.escape(brand)}
+ {badge}
+
+
+ ํ์ ๋ {conf:.0%} โ {html.escape(reasoning[:200]) if reasoning else 'N/A'}
+
+
+""", 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"""
+
+
#{answer_id} โ ์คํ ํ์
+
{html.escape(reasoning[:200])}
+
+ """, 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"""
+
+
#{answer_id} โ ๋ถ์ ํ์
+
{html.escape(reasoning[:200])}
+
+ """, 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)
diff --git a/features/sentiment/keyword_analysis/__init__.py b/features/sentiment/keyword_analysis/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..19bf4845b5d4eb0bb3f8dbff1978963d3ca21774
--- /dev/null
+++ b/features/sentiment/keyword_analysis/__init__.py
@@ -0,0 +1,75 @@
+"""ํค์๋ ๊ฐ์ฑ๋ถ์ ํญ.
+
+ํค์๋๋ณ ๊ฐ์ฑ ์์ฝ, ๋ธ๋๋xํค์๋ ๊ต์ฐจ ๋ถ์, LLM ์ด์ ํ๊ทธ.
+ํจํค์ง๋ก ๋ถ๋ฆฌ (overview, drilldown, detail, cross_analysis, export_kw).
+"""
+import streamlit as st
+
+from .overview import render_keyword_overview, render_summary_table, render_sentiment_chart
+from .drilldown import render_keyword_drilldown, render_competitor_summary, render_competitor_drilldown
+from .cross_analysis import render_brand_keyword_cross
+
+
+def render(data: dict):
+ """ํค์๋ ๋ถ์ ํญ ๋ ๋๋ง."""
+ st.markdown("##### ๐ ํค์๋ ๊ฐ์ฑ ๋ถ์")
+ st.caption("ํค์๋๋ณ AI ๋ต๋ณ ๊ฐ์ฑ ๋ถํฌ์ ๋ธ๋๋ ์ธ๊ธ ๋ถ์")
+
+ keyword_data = data.get("keyword_data", {})
+ keywords_list = keyword_data.get("keywords", [])
+
+ if not keywords_list:
+ st.info("ํค์๋ ๋ถ์ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค. ์คํ์์ฒญ ํญ์์ ํค์๋ ๋ถ์์ ์คํํด์ฃผ์ธ์.")
+ return
+
+ # Brand type toggle
+ brand_mode = st.radio(
+ "๋ถ์ ๋์",
+ options=["๐ ์์ฌ ๋ธ๋๋", "๐ข ๊ฒฝ์์ฌ ๋ธ๋๋"],
+ horizontal=True,
+ key="sentiment:kw_brand_mode",
+ )
+ is_competitor = brand_mode == "๐ข ๊ฒฝ์์ฌ ๋ธ๋๋"
+
+ if is_competitor:
+ _render_competitor_view(data)
+ else:
+ _render_inhouse_view(data, keywords_list)
+
+
+def _render_inhouse_view(data: dict, keywords_list: list[dict]):
+ """์์ฌ ๋ธ๋๋ ํค์๋ ๋ถ์."""
+ # --- Overview Card ---
+ render_keyword_overview(keywords_list)
+
+ # --- Section 1: Summary Table ---
+ render_summary_table(keywords_list)
+
+ # --- Section 2: Sentiment Comparison Chart ---
+ st.markdown("---")
+ render_sentiment_chart(keywords_list)
+
+ # --- Section 3: Keyword Drill-down (includes export) ---
+ st.markdown("---")
+ render_keyword_drilldown(data, keywords_list)
+
+ # --- Section 4: Brand x Keyword Cross Analysis ---
+ st.markdown("---")
+ render_brand_keyword_cross(data)
+
+
+def _render_competitor_view(data: dict):
+ """๊ฒฝ์์ฌ ํค์๋ LLM ๋ถ์ ๊ฒฐ๊ณผ."""
+ st.markdown("##### ๐ข ๊ฒฝ์์ฌ ํค์๋ LLM ๋ถ์")
+ st.caption("๊ฒฝ์์ฌ๋ง ์ธ๊ธ๋ ๋ฌธ์ฅ์ ๋ํ LLM ๊ฐ์ฑ ๋ถ์ ๊ฒฐ๊ณผ")
+
+ # Competitor summary card
+ render_competitor_summary(data)
+
+ # Cross analysis
+ st.markdown("---")
+ render_brand_keyword_cross(data, competitor=True)
+
+ # Drill-down
+ st.markdown("---")
+ render_competitor_drilldown(data)
diff --git a/features/sentiment/keyword_analysis/cross_analysis.py b/features/sentiment/keyword_analysis/cross_analysis.py
new file mode 100644
index 0000000000000000000000000000000000000000..e19a4a8d221ee6f877a572411edaffd2ffc18106
--- /dev/null
+++ b/features/sentiment/keyword_analysis/cross_analysis.py
@@ -0,0 +1,104 @@
+"""๋ธ๋๋ x ํค์๋ ๊ต์ฐจ ๋ถ์ + LLM ์ด์ ํ๊ทธ."""
+import html
+
+import pandas as pd
+import plotly.graph_objects as go
+import streamlit as st
+
+from core.api_client import ChainShiftClient
+
+
+def render_brand_keyword_cross(data: dict, competitor: bool = False):
+ """๋ธ๋๋ x ํค์๋ ๊ต์ฐจ ๋ถ์ + LLM ์ด์ ํ๊ทธ."""
+ brand_label = "๊ฒฝ์์ฌ" if competitor else "์์ฌ"
+ st.markdown(f"**{brand_label} ๋ธ๋๋ x ํค์๋ ๊ต์ฐจ ๋ถ์**")
+ st.caption(f"ํค์๋๋ณ๋ก {brand_label} ๋ธ๋๋๊ฐ ์ด๋ค ๋งฅ๋ฝ์์ ์ธ๊ธ๋๋์ง ๋ถ์ํฉ๋๋ค")
+
+ try:
+ client = ChainShiftClient(api_key=data.get("api_key"), access_token=data.get("access_token"))
+ response = client.get_keyword_brand_analysis(data["campaign_id"], competitor=competitor)
+ resp_data = response.get("data", {})
+ items = resp_data.get("items", [])
+ except Exception as e:
+ st.error(f"๋ธ๋๋xํค์๋ ๋ถ์ ๋ก๋ ์คํจ: {e}")
+ return
+
+ if not items:
+ st.info("๋ธ๋๋xํค์๋ ๊ต์ฐจ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค")
+ return
+
+ # Cross table
+ rows = []
+ for item in items:
+ keyword = item.get("keyword", "")
+ brand = item.get("brand", "")
+ total = item.get("total", 0)
+ sent = item.get("sentiment", {})
+ pos = sent.get("positive", 0)
+ neu = sent.get("neutral", 0)
+ neg = sent.get("negative", 0)
+ reason_tags = item.get("reason_tags", [])
+ top_tags = ", ".join(rt.get("tag", "") for rt in reason_tags[:3])
+
+ rows.append({
+ "ํค์๋": keyword,
+ "๋ธ๋๋": brand,
+ "์ด ๊ฑด์": total,
+ "๊ธ์ ": pos,
+ "์ค๋ฆฝ": neu,
+ "๋ถ์ ": neg,
+ "์ฃผ์ ์ด์ ํ๊ทธ": top_tags,
+ })
+
+ if rows:
+ df = pd.DataFrame(rows).sort_values("๋ถ์ ", ascending=False)
+ st.dataframe(df, use_container_width=True, hide_index=True)
+
+ # LLM Reason Tag Analysis
+ st.markdown("---")
+ st.markdown("**๐ท๏ธ LLM ์ด์ ํ๊ทธ ๋ถ์**")
+ st.caption("LLM์ด ์๋ ์์ฑํ ์ด์ ํ๊ทธ ๋น๋")
+
+ # Aggregate all reason tags
+ tag_counts: dict[str, int] = {}
+ tag_examples: dict[str, str] = {}
+ for item in items:
+ for rt in item.get("reason_tags", []):
+ tag = rt.get("tag", "")
+ count = rt.get("count", 0)
+ if tag:
+ tag_counts[tag] = tag_counts.get(tag, 0) + count
+ if tag not in tag_examples and rt.get("example_sentence"):
+ tag_examples[tag] = rt["example_sentence"]
+
+ if tag_counts:
+ # Bar chart for top tags
+ sorted_tags = sorted(tag_counts.items(), key=lambda x: -x[1])[:15]
+ tag_names = [t[0] for t in sorted_tags]
+ tag_vals = [t[1] for t in sorted_tags]
+
+ fig = go.Figure(go.Bar(
+ x=tag_vals,
+ y=tag_names,
+ orientation="h",
+ marker_color="#059669",
+ ))
+ fig.update_layout(
+ height=max(250, len(sorted_tags) * 30),
+ margin=dict(l=20, r=20, t=10, b=10),
+ yaxis=dict(autorange="reversed"),
+ )
+ st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
+
+ # Tag detail table
+ tag_rows = []
+ for tag, count in sorted_tags:
+ example = tag_examples.get(tag, "")
+ tag_rows.append({
+ "ํ๊ทธ": tag,
+ "๋น๋": count,
+ "์์ ๋ฌธ์ฅ": example[:100] if example else "",
+ })
+ st.dataframe(pd.DataFrame(tag_rows), use_container_width=True, hide_index=True)
+ else:
+ st.info("LLM ์ด์ ํ๊ทธ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค")
diff --git a/features/sentiment/keyword_analysis/detail.py b/features/sentiment/keyword_analysis/detail.py
new file mode 100644
index 0000000000000000000000000000000000000000..efc9545e7206a01e690ab6a4db0ce741e2856b1c
--- /dev/null
+++ b/features/sentiment/keyword_analysis/detail.py
@@ -0,0 +1,258 @@
+"""ํค์๋ ๊ฒฐ๊ณผ ์นด๋ & ์์ธ โ ๊ฐ๋ณ ๊ฒฐ๊ณผ ๋ ๋๋ง, ์ ์ฒด ๋ต๋ณ, ์ธ์ฉ, LLM ๊ฒ์ฆ."""
+import html
+
+import streamlit as st
+
+from core.api_client import ChainShiftClient
+from core.athena_client import fetch_full_answer
+
+
+def render_keyword_result_card(data: dict, item: dict, index: int, is_competitor: bool = False):
+ """ํค์๋ ๊ฒฐ๊ณผ ์นด๋ with rich detail."""
+ sentence = item.get("matched_sentence", "")
+ kw_sentiment = item.get("keyword_sentiment", "neutral")
+ kw_confidence = item.get("keyword_confidence", 0)
+ brand_name = item.get("brand_name")
+ brand_sentiment = item.get("brand_sentiment")
+ platform = item.get("platform", "N/A")
+ answer_id = item.get("answer_id", "N/A")
+
+ # Sentiment colors
+ sent_colors = {"positive": "#10B981", "neutral": "#6B7280", "negative": "#EF4444"}
+ sent_emoji = {"positive": "๐", "neutral": "๐", "negative": "๐"}
+ kw_color = sent_colors.get(kw_sentiment, "#6B7280")
+
+ # Brand badge
+ brand_badge = ""
+ brand_icon = "๐ข" if is_competitor else "๐ "
+ if brand_name:
+ br_color = sent_colors.get(brand_sentiment, "#6B7280")
+ br_emoji = sent_emoji.get(brand_sentiment, "")
+ brand_badge = (
+ f' {brand_icon} {html.escape(brand_name)} {br_emoji}'
+ )
+ else:
+ brand_badge = (
+ ' ๋น๋ธ๋๋'
+ )
+
+ # LLM badge (์ ํ/์คํ + reason tags) โ uses llm_is_negative column
+ llm_badge = ""
+ if item.get("llm_verified"):
+ llm_is_neg = item.get("llm_is_negative")
+ match_label = "์ ํ" if llm_is_neg else "์คํ"
+ match_color = "#DC2626" if llm_is_neg else "#059669"
+ tags = item.get("llm_reason_tags", [])
+ tags_str = ", ".join(tags[:2]) if tags else ""
+ tag_part = f" | {tags_str}" if tags_str else ""
+ llm_badge = (
+ f' ๐ค {match_label}{tag_part}'
+ )
+
+ st.markdown(f"""
+
+
+ #{answer_id} | {platform}
+
+
+ {sent_emoji.get(kw_sentiment, '')} {kw_sentiment} ({kw_confidence:.0%})
+ {brand_badge}{llm_badge}
+
+
+
{html.escape(sentence[:300])}
+
+ """, unsafe_allow_html=True)
+
+ # Rich detail expander
+ prefix = "sentiment:comp_kw" if is_competitor else "sentiment:kw"
+ with st.expander(f"๐ ์์ธ ๋ณด๊ธฐ โ #{answer_id}", expanded=False):
+ _render_keyword_detail(data, item, answer_id, sentence, index, prefix)
+
+
+def _render_keyword_detail(data: dict, item: dict, answer_id: int, sentence: str, index: int, prefix: str):
+ """Keyword result detail: question, full answer, LLM analysis, citations."""
+ sent_colors = {"positive": "#10B981", "neutral": "#6B7280", "negative": "#EF4444"}
+ sent_emoji = {"positive": "๐", "neutral": "๐", "negative": "๐"}
+
+ # 1. Question context
+ question = item.get("question_content", "")
+ if question:
+ st.markdown(f"""
+
+
๐ฌ ์ง๋ฌธ
+
{html.escape(question[:500])}
+
+""", unsafe_allow_html=True)
+
+ # 2. Full answer (lazy-load from Athena)
+ st.markdown("**๐ค AI ๋ต๋ณ**")
+ display_answer = _load_full_answer_kw(answer_id, sentence, index, prefix)
+
+ # 3. Keyword + brand sentiment badges
+ kw_sentiment = item.get("keyword_sentiment", "neutral")
+ kw_confidence = item.get("keyword_confidence", 0)
+ kw_color = sent_colors.get(kw_sentiment, "#6B7280")
+
+ badges_html = f'ํค์๋ {sent_emoji.get(kw_sentiment, "")} {kw_sentiment} ({kw_confidence:.0%})'
+
+ brand_name = item.get("brand_name")
+ brand_sentiment = item.get("brand_sentiment")
+ if brand_name and brand_sentiment:
+ br_color = sent_colors.get(brand_sentiment, "#6B7280")
+ br_emoji = sent_emoji.get(brand_sentiment, "")
+ badges_html += f' ๐ {html.escape(brand_name)} {br_emoji} {brand_sentiment}'
+
+ # LLM TP/FP badge โ uses llm_is_negative column
+ if item.get("llm_verified"):
+ llm_is_neg = item.get("llm_is_negative")
+ tp_badge = "์ ํ" if llm_is_neg else "์คํ"
+ tp_color = "#DC2626" if llm_is_neg else "#059669"
+ badges_html += f' {tp_badge}'
+
+ st.markdown(f"**๊ฐ์ฑ ๋ถ์:** {badges_html}", unsafe_allow_html=True)
+
+ # 4. ์ธ์ฉ ์ถ์ฒ
+ _render_citations_kw(answer_id, index, prefix, item=item)
+
+ # 5. LLM 2์ฐจ ๊ฒ์ฆ
+ if item.get("llm_verified"):
+ llm_sentiment = item.get("llm_sentiment", "")
+ llm_confidence = item.get("llm_confidence", 0) or 0
+ llm_color = sent_colors.get(llm_sentiment, "#6B7280")
+ reason_summary = item.get("llm_reason_summary", "")
+ reasoning = item.get("llm_reasoning", "")
+ reason_tags = item.get("llm_reason_tags", [])
+
+ st.markdown("---")
+ st.markdown(f"""
+
+
+ ๐ฌ LLM 2์ฐจ ๊ฒ์ฆ
+
+ {sent_emoji.get(llm_sentiment, '')} {llm_sentiment} ({llm_confidence:.0%})
+
+
+
+ {f'์์ฝ: {html.escape(reason_summary)}
' if reason_summary else ''}
+ ํ๋จ ๊ทผ๊ฑฐ: {html.escape(reasoning[:500]) if reasoning else 'N/A'}
+
+
+""", unsafe_allow_html=True)
+
+ # Reason tags
+ if reason_tags:
+ tags_html = " ".join(
+ f'{html.escape(t)}'
+ for t in reason_tags[:6]
+ )
+ st.markdown(f"**์ด์ ํ๊ทธ:** {tags_html}", unsafe_allow_html=True)
+
+ # Highlight matched sentence in full answer
+ if display_answer and sentence and display_answer != sentence:
+ escaped_sentence = sentence.replace("\\", "\\\\")
+ if escaped_sentence in display_answer:
+ highlighted = display_answer.replace(
+ escaped_sentence,
+ f'{html.escape(escaped_sentence)}',
+ 1,
+ )
+ st.markdown("**๐ ๊ทผ๊ฑฐ ๋ฌธ์ฅ (ํ์ด๋ผ์ดํธ)**")
+ st.markdown(
+ f'{highlighted}
',
+ unsafe_allow_html=True,
+ )
+ # Re-verify button
+ if st.button("๐ ์ฌ๊ฒ์ฆ ์์ฒญ", key=f"{prefix}_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 answer_id and answer_id != "N/A":
+ if st.button("๐ฌ LLM ๊ฒ์ฆ ์์ฒญ", key=f"{prefix}_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()
+
+
+def _load_full_answer_kw(answer_id: int, preview: str, index: int, prefix: str) -> str:
+ """Lazy-load full answer from Athena for keyword tab."""
+ display_answer = preview or "N/A"
+
+ if answer_id and answer_id != "N/A":
+ full_answer_key = f"{prefix}_full_{answer_id}_{index}"
+ load_key = f"{prefix}_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''
+ f'{html.escape(display_answer)}
',
+ unsafe_allow_html=True,
+ )
+
+ return display_answer
+
+
+def _render_citations_kw(answer_id: int, index: int, prefix: str, item: dict | None = None):
+ """Keyword tab citation rendering (Supabase citation_urls)."""
+ st.markdown("**๐ ์ธ์ฉ ์ถ์ฒ**")
+
+ citation_urls = (item.get("citation_urls") or []) if item else []
+
+ 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 _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
diff --git a/features/sentiment/keyword_analysis/drilldown.py b/features/sentiment/keyword_analysis/drilldown.py
new file mode 100644
index 0000000000000000000000000000000000000000..81d59ad650d10c05691d8c03ab030745a9b336fb
--- /dev/null
+++ b/features/sentiment/keyword_analysis/drilldown.py
@@ -0,0 +1,592 @@
+"""ํค์๋ ๋๋ฆด๋ค์ด โ ์์ฌ/๊ฒฝ์์ฌ ํค์๋ ์์ธ ๋ชฉ๋ก + ํ์ด์ง๋ค์ด์
."""
+import html
+
+import streamlit as st
+
+from core.api_client import ChainShiftClient
+from .detail import render_keyword_result_card
+from .export_kw import render_export
+
+
+def render_keyword_drilldown(data: dict, keywords_list: list[dict]):
+ """ํค์๋ ์ ํ -> ๋ฌธ์ฅ ๋ชฉ๋ก + ๊ฐ์ฑ + ๋ธ๋๋."""
+ st.markdown("**ํค์๋ ์์ธ ๋๋ฆด๋ค์ด**")
+
+ keyword_names = ["์ ์ฒด"] + [kw.get("keyword", "") for kw in keywords_list]
+ selected_keyword_raw = st.selectbox(
+ "ํค์๋ ์ ํ",
+ options=keyword_names,
+ key="sentiment:kw_drilldown_select",
+ )
+ selected_keyword = None if selected_keyword_raw == "์ ์ฒด" else selected_keyword_raw
+
+ # Filters โ Row 1: ๊ฐ์ฑ, ๋ธ๋๋, ํ๋ซํผ, 2์ฐจ ๊ฒ์ฆ
+ f1, f2, f3, f4 = st.columns(4)
+ with f1:
+ sentiment_filter = st.selectbox(
+ "๊ฐ์ฑ",
+ options=["์ ์ฒด", "positive", "neutral", "negative"],
+ format_func=lambda x: {"์ ์ฒด": "์ ์ฒด", "positive": "๊ธ์ ", "neutral": "์ค๋ฆฝ", "negative": "๋ถ์ "}.get(x, x),
+ key="sentiment:kw_drilldown_sentiment",
+ )
+ with f2:
+ brand_filter = st.selectbox(
+ "๋ธ๋๋ ๊ตฌ๋ถ",
+ options=["์ ์ฒด", "๋ธ๋๋ ํฌํจ", "๋น๋ธ๋๋"],
+ key="sentiment:kw_drilldown_brand",
+ )
+ with f3:
+ platform_filter = st.selectbox(
+ "ํ๋ซํผ",
+ options=["์ ์ฒด", "CHATGPT", "GEMINI", "PERPLEXITY", "CLAUDE"],
+ key="sentiment:kw_drilldown_platform",
+ )
+ with f4:
+ llm_status_filter = st.selectbox(
+ "2์ฐจ ๊ฒ์ฆ",
+ options=["์ ์ฒด", "์ ํ", "์คํ", "๋ฏธ๊ฒ์ฆ"],
+ key="sentiment:kw_drilldown_llm",
+ )
+
+ # Build server-side filter params
+ sentiment_param = sentiment_filter if sentiment_filter != "์ ์ฒด" else None
+ platform_param = platform_filter if platform_filter != "์ ์ฒด" else None
+ brand_only_param = brand_filter == "๋ธ๋๋ ํฌํจ"
+ no_brand_param = brand_filter == "๋น๋ธ๋๋"
+
+ # Map LLM filter -> server-side params (llm_is_negative column)
+ llm_verified_param = None
+ llm_is_negative_param = None # True (์ ํ) | False (์คํ) | None
+ if llm_status_filter == "์ ํ":
+ llm_verified_param = "verified"
+ llm_is_negative_param = True
+ elif llm_status_filter == "์คํ":
+ llm_verified_param = "verified"
+ llm_is_negative_param = False
+ elif llm_status_filter == "๋ฏธ๊ฒ์ฆ":
+ llm_verified_param = "unverified"
+
+ # Filters โ Row 2: ํ์ด์ง ํฌ๊ธฐ + Excel ๋ค์ด๋ก๋
+ dl_col, _, size_col = st.columns([2, 2, 1])
+ with dl_col:
+ render_export(
+ data,
+ keyword=selected_keyword,
+ sentiment=sentiment_param,
+ brand_only=brand_only_param,
+ no_brand=no_brand_param,
+ platform=platform_param,
+ llm_verified=llm_verified_param,
+ llm_is_negative=llm_is_negative_param,
+ )
+ with size_col:
+ page_size = st.selectbox("ํ์ด์ง ํฌ๊ธฐ", options=[20, 50, 100], index=1, key="sentiment:kw_page_size")
+
+ # Pagination state
+ if "sentiment:kw_drill_page" not in st.session_state:
+ st.session_state["sentiment:kw_drill_page"] = 1
+
+ # Reset page on filter change
+ kw_filter_key = f"{selected_keyword}_{sentiment_filter}_{brand_filter}_{platform_filter}_{llm_status_filter}_{page_size}"
+ if st.session_state.get("sentiment:kw_drill_last_filters") != kw_filter_key:
+ st.session_state["sentiment:kw_drill_page"] = 1
+ st.session_state["sentiment:kw_drill_last_filters"] = kw_filter_key
+
+ current_page = st.session_state["sentiment:kw_drill_page"]
+
+ # Fetch results โ direct Supabase (bypass Vercel 10s timeout)
+ try:
+ items, total = fetch_keyword_drilldown(
+ campaign_id=data["campaign_id"],
+ keyword=selected_keyword,
+ sentiment=sentiment_param,
+ llm_verified=llm_verified_param,
+ llm_is_negative=llm_is_negative_param,
+ platform=platform_param,
+ brand_only=brand_only_param,
+ no_brand=no_brand_param,
+ page=current_page,
+ page_size=page_size,
+ )
+
+ except Exception as e:
+ st.error(f"ํค์๋ ๊ฒฐ๊ณผ ๋ก๋ ์คํจ: {e}")
+ return
+
+ total_pages = max(1, (total + page_size - 1) // page_size)
+ has_more = len(items) == page_size # count="planned" may underestimate
+ start_idx = (current_page - 1) * page_size + 1
+ end_idx = min(current_page * page_size, total)
+
+ if total_pages > 1 or has_more:
+ col_info, col_prev, col_page, col_next = st.columns([3, 1, 1, 1])
+ with col_info:
+ st.markdown(f"**์ ์ฒด ~{total:,}๊ฑด** | ํ์ด์ง {current_page}/{total_pages} ({start_idx}-{end_idx}๊ฑด)")
+ with col_prev:
+ if st.button("โฌ
๏ธ ์ด์ ", disabled=current_page <= 1, key="sentiment:kw_drill_prev"):
+ st.session_state["sentiment:kw_drill_page"] = current_page - 1
+ st.rerun()
+ with col_page:
+ new_page = st.number_input(
+ "ํ์ด์ง", min_value=1, max_value=max(total_pages, current_page + 1),
+ value=current_page, label_visibility="collapsed", key="sentiment:kw_drill_page_input",
+ )
+ if new_page != current_page:
+ st.session_state["sentiment:kw_drill_page"] = new_page
+ st.rerun()
+ with col_next:
+ if st.button("๋ค์ โก๏ธ", disabled=not has_more, key="sentiment:kw_drill_next"):
+ st.session_state["sentiment:kw_drill_page"] = current_page + 1
+ st.rerun()
+ else:
+ st.markdown(f"**์ ์ฒด ~{total:,}๊ฑด**")
+
+ if not items:
+ st.info("์กฐ๊ฑด์ ๋ง๋ ๊ฒฐ๊ณผ๊ฐ ์์ต๋๋ค")
+ return
+
+ for i, item in enumerate(items):
+ unique_idx = (current_page - 1) * page_size + i
+ render_keyword_result_card(data, item, unique_idx)
+
+
+def render_competitor_summary(data: dict):
+ """๊ฒฝ์์ฌ ๋ธ๋๋ ์์ฝ ์นด๋."""
+ try:
+ client = ChainShiftClient(api_key=data.get("api_key"), access_token=data.get("access_token"))
+ response = client.get_keyword_brand_analysis(data["campaign_id"], competitor=True)
+ items = (response.get("data") or {}).get("items", [])
+ except Exception:
+ return
+
+ if not items:
+ return
+
+ # Aggregate by competitor brand
+ brand_stats: dict[str, dict] = {}
+ for item in items:
+ brand = item.get("brand", "")
+ if not brand:
+ continue
+ if brand not in brand_stats:
+ brand_stats[brand] = {"total": 0, "positive": 0, "neutral": 0, "negative": 0}
+ sent = item.get("sentiment", {})
+ brand_stats[brand]["total"] += item.get("total", 0)
+ brand_stats[brand]["positive"] += sent.get("positive", 0)
+ brand_stats[brand]["neutral"] += sent.get("neutral", 0)
+ brand_stats[brand]["negative"] += sent.get("negative", 0)
+
+ if not brand_stats:
+ return
+
+ total_mentions = sum(b["total"] for b in brand_stats.values())
+ brand_chips = []
+ for brand, stats in sorted(brand_stats.items(), key=lambda x: -x[1]["total"]):
+ neg_rate = (stats["negative"] / stats["total"] * 100) if stats["total"] > 0 else 0
+ pos_rate = (stats["positive"] / stats["total"] * 100) if stats["total"] > 0 else 0
+ brand_chips.append(
+ f''
+ f'๐ข {html.escape(brand)} {stats["total"]:,}๊ฑด '
+ f'๊ธ์ {pos_rate:.0f}% '
+ f'๋ถ์ {neg_rate:.0f}%'
+ )
+
+ st.markdown(f"""
+
+
+ ๊ฒฝ์์ฌ ๋ธ๋๋ ์์ฝ โ ์ด {total_mentions:,}๊ฑด ์ธ๊ธ, {len(brand_stats)}๊ฐ ๋ธ๋๋
+
+
+ {' '.join(brand_chips)}
+
+
+""", unsafe_allow_html=True)
+
+
+def render_competitor_drilldown(data: dict):
+ """๊ฒฝ์์ฌ ํค์๋ ๋๋ฆด๋ค์ด."""
+ st.markdown("**๊ฒฝ์์ฌ ํค์๋ ์์ธ ๋๋ฆด๋ค์ด**")
+
+ keyword_data = data.get("keyword_data", {})
+ keywords_list = keyword_data.get("keywords", [])
+ keyword_names = ["์ ์ฒด"] + [kw.get("keyword", "") for kw in keywords_list]
+
+ if len(keyword_names) <= 1:
+ st.info("ํค์๋ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค")
+ return
+
+ selected_raw = st.selectbox(
+ "ํค์๋ ์ ํ",
+ options=keyword_names,
+ key="sentiment:comp_kw_drilldown_select",
+ )
+ selected_keyword = None if selected_raw == "์ ์ฒด" else selected_raw
+
+ # Filters โ matching keyword drilldown pattern
+ f1, f2, f3 = st.columns(3)
+ with f1:
+ sentiment_filter = st.selectbox(
+ "๊ฐ์ฑ",
+ options=["์ ์ฒด", "positive", "neutral", "negative"],
+ format_func=lambda x: {"์ ์ฒด": "์ ์ฒด", "positive": "๊ธ์ ", "neutral": "์ค๋ฆฝ", "negative": "๋ถ์ "}.get(x, x),
+ key="sentiment:comp_drill_sentiment",
+ )
+ with f2:
+ platform_filter = st.selectbox(
+ "ํ๋ซํผ",
+ options=["์ ์ฒด", "CHATGPT", "GEMINI", "PERPLEXITY", "CLAUDE"],
+ key="sentiment:comp_drill_platform",
+ )
+ with f3:
+ llm_status_filter = st.selectbox(
+ "2์ฐจ ๊ฒ์ฆ",
+ options=["์ ์ฒด", "์ ํ", "์คํ", "๋ฏธ๊ฒ์ฆ"],
+ key="sentiment:comp_drill_llm",
+ )
+
+ # Build server-side filter params
+ sentiment_param = sentiment_filter if sentiment_filter != "์ ์ฒด" else None
+ platform_param = platform_filter if platform_filter != "์ ์ฒด" else None
+ llm_is_negative_param = None
+ llm_verified_param = None
+ if llm_status_filter == "์ ํ":
+ llm_is_negative_param = True
+ elif llm_status_filter == "์คํ":
+ llm_is_negative_param = False
+ elif llm_status_filter == "๋ฏธ๊ฒ์ฆ":
+ llm_verified_param = "unverified"
+
+ # Export + page size row
+ dl_col, _, size_col = st.columns([2, 2, 1])
+ with dl_col:
+ _render_competitor_export(
+ data,
+ keyword=selected_keyword,
+ sentiment=sentiment_param,
+ platform=platform_param,
+ llm_is_negative=llm_is_negative_param,
+ llm_verified=llm_verified_param,
+ )
+ with size_col:
+ page_size = st.selectbox("ํ์ด์ง ํฌ๊ธฐ", options=[20, 50, 100], index=1, key="sentiment:comp_drill_page_size")
+
+ # Pagination state
+ if "sentiment:comp_drill_page" not in st.session_state:
+ st.session_state["sentiment:comp_drill_page"] = 1
+
+ # Reset page on filter change
+ comp_filter_key = f"{selected_keyword}_{sentiment_filter}_{platform_filter}_{llm_status_filter}_{page_size}"
+ if st.session_state.get("sentiment:comp_drill_last_filters") != comp_filter_key:
+ st.session_state["sentiment:comp_drill_page"] = 1
+ st.session_state["sentiment:comp_drill_last_filters"] = comp_filter_key
+
+ current_page = st.session_state["sentiment:comp_drill_page"]
+
+ try:
+ items, total = fetch_competitor_drilldown(
+ campaign_id=data["campaign_id"],
+ keyword=selected_keyword,
+ sentiment=sentiment_param,
+ llm_verified=llm_verified_param,
+ llm_is_negative=llm_is_negative_param,
+ platform=platform_param,
+ page=current_page,
+ page_size=page_size,
+ )
+ except Exception as e:
+ st.error(f"๊ฒฝ์์ฌ ํค์๋ ๊ฒฐ๊ณผ ๋ก๋ ์คํจ: {e}")
+ return
+
+ total_pages = max(1, (total + page_size - 1) // page_size)
+ has_more = len(items) == page_size # count="planned" may underestimate
+ start_idx = (current_page - 1) * page_size + 1
+ end_idx = min(current_page * page_size, total)
+
+ if total_pages > 1 or has_more:
+ col_info, col_prev, col_page, col_next = st.columns([3, 1, 1, 1])
+ with col_info:
+ st.markdown(f"**์ ์ฒด ~{total:,}๊ฑด** | ํ์ด์ง {current_page}/{total_pages} ({start_idx}-{end_idx}๊ฑด)")
+ with col_prev:
+ if st.button("โฌ
๏ธ ์ด์ ", disabled=current_page <= 1, key="sentiment:comp_drill_prev"):
+ st.session_state["sentiment:comp_drill_page"] = current_page - 1
+ st.rerun()
+ with col_page:
+ new_page = st.number_input(
+ "ํ์ด์ง", min_value=1, max_value=max(total_pages, current_page + 1),
+ value=current_page, label_visibility="collapsed", key="sentiment:comp_drill_page_input",
+ )
+ if new_page != current_page:
+ st.session_state["sentiment:comp_drill_page"] = new_page
+ st.rerun()
+ with col_next:
+ if st.button("๋ค์ โก๏ธ", disabled=not has_more, key="sentiment:comp_drill_next"):
+ st.session_state["sentiment:comp_drill_page"] = current_page + 1
+ st.rerun()
+ else:
+ st.markdown(f"**์ ์ฒด ~{total:,}๊ฑด**")
+
+ if not items:
+ st.info("์กฐ๊ฑด์ ๋ง๋ ๊ฒฝ์์ฌ ํค์๋ ๊ฒฐ๊ณผ๊ฐ ์์ต๋๋ค.")
+ return
+
+ for i, item in enumerate(items):
+ unique_idx = (current_page - 1) * page_size + i
+ render_keyword_result_card(data, item, unique_idx, is_competitor=True)
+
+
+def fetch_competitor_drilldown(
+ campaign_id: int,
+ keyword: str | None = None,
+ sentiment: str | None = None,
+ llm_verified: str | None = None,
+ llm_is_negative: bool | None = None,
+ platform: str | None = None,
+ page: int = 1,
+ page_size: int = 50,
+) -> tuple[list[dict], int]:
+ """๊ฒฝ์์ฌ ํค์๋ ์ง์ Supabase ์ฟผ๋ฆฌ (Vercel ํ์์์ ์ฐํ).
+
+ Filters match fetch_keyword_drilldown for consistency.
+ """
+ from core.supabase_client import get_supabase_client
+
+ sb = get_supabase_client()
+ query = (
+ sb.table("keyword_sentiment_results")
+ .select("*", count="planned")
+ .eq("campaign_id", campaign_id)
+ .is_("brand_name", "null")
+ )
+ # competitor_llm_verified filter (was hardcoded True, now conditional)
+ if llm_verified == "unverified":
+ query = query.or_("competitor_llm_verified.is.null,competitor_llm_verified.eq.false")
+ else:
+ # ์ ์ฒด/์ ํ/์คํ: only show LLM-analyzed results
+ query = query.eq("competitor_llm_verified", True)
+
+ if keyword:
+ query = query.eq("keyword", keyword)
+ if sentiment:
+ query = query.eq("keyword_sentiment", sentiment)
+ if platform:
+ query = query.eq("platform", platform)
+ if llm_is_negative is not None:
+ query = query.eq("competitor_llm_is_negative", llm_is_negative)
+
+ offset = (page - 1) * page_size
+ query = query.order("created_at", desc=True).range(offset, offset + page_size - 1)
+ result = query.execute()
+ return result.data or [], result.count or 0
+
+
+def fetch_keyword_drilldown(
+ campaign_id: int,
+ keyword: str | None = None,
+ sentiment: str | None = None,
+ llm_verified: str | None = None,
+ llm_is_negative: bool | None = None,
+ platform: str | None = None,
+ brand_only: bool = False,
+ no_brand: bool = False,
+ page: int = 1,
+ page_size: int = 50,
+) -> tuple[list[dict], int]:
+ """์ง์ Supabase ํ์ด์ง๋ค์ด์
์ฟผ๋ฆฌ (Vercel 10s ํ์์์ ์ฐํ).
+
+ Args:
+ llm_is_negative: True=์ ํ(๋ถ์ ํ์ ), False=์คํ(๋ถ์ ์๋), None=์ ์ฒด
+
+ Returns:
+ (items, total_count)
+ """
+ from core.supabase_client import get_supabase_client
+
+ sb = get_supabase_client()
+ query = sb.table("keyword_sentiment_results").select("*", count="planned")
+ query = query.eq("campaign_id", campaign_id)
+
+ if keyword:
+ query = query.eq("keyword", keyword)
+ if sentiment:
+ query = query.eq("keyword_sentiment", sentiment)
+ if platform:
+ query = query.eq("platform", platform)
+ if brand_only:
+ query = query.not_.is_("brand_name", "null")
+ elif no_brand:
+ query = query.is_("brand_name", "null")
+ if llm_is_negative is not None:
+ query = query.eq("llm_is_negative", llm_is_negative)
+ elif llm_verified == "verified":
+ query = query.eq("llm_verified", True)
+ elif llm_verified == "unverified":
+ query = query.or_("llm_verified.is.null,llm_verified.eq.false")
+
+ offset = (page - 1) * page_size
+ query = query.order("created_at", desc=True).range(offset, offset + page_size - 1)
+
+ result = query.execute()
+ return result.data or [], result.count or 0
+
+
+def _render_competitor_export(
+ data: dict,
+ keyword: str | None = None,
+ sentiment: str | None = None,
+ platform: str | None = None,
+ llm_is_negative: bool | None = None,
+ llm_verified: str | None = None,
+):
+ """๊ฒฝ์์ฌ ๋๋ฆด๋ค์ด CSV export (ํค์๋ ํญ export_kw.py ํจํด ์ผ์น)."""
+ filter_parts = []
+ if keyword:
+ filter_parts.append(f"ํค์๋: {keyword}")
+ if sentiment:
+ label = {"positive": "๊ธ์ ", "neutral": "์ค๋ฆฝ", "negative": "๋ถ์ "}.get(sentiment, sentiment)
+ filter_parts.append(f"๊ฐ์ฑ: {label}")
+ if platform:
+ filter_parts.append(f"ํ๋ซํผ: {platform}")
+ if llm_verified == "unverified":
+ filter_parts.append("๋ฏธ๊ฒ์ฆ๋ง")
+ elif llm_is_negative is True:
+ filter_parts.append("์ ํ๋ง")
+ elif llm_is_negative is False:
+ filter_parts.append("์คํ๋ง")
+
+ if filter_parts:
+ st.caption(f"๐ฅ ํํฐ: {' | '.join(filter_parts)}")
+
+ if st.button("๐ฅ Excel ๋ค์ด๋ก๋", key="sentiment:comp_drill_export_btn"):
+ with st.spinner("Excel ์์ฑ ์ค..."):
+ try:
+ xlsx = _export_competitor_drilldown(
+ data["campaign_id"],
+ keyword=keyword,
+ sentiment=sentiment,
+ platform=platform,
+ llm_is_negative=llm_is_negative,
+ llm_verified=llm_verified,
+ )
+ st.session_state["sentiment:comp_drill_excel"] = xlsx
+ st.session_state["sentiment:comp_drill_excel_ready"] = True
+ except Exception as e:
+ st.error(f"๋ค์ด๋ก๋ ์คํจ: {e}")
+
+ if st.session_state.get("sentiment:comp_drill_excel_ready"):
+ st.download_button(
+ label="๐พ ํ์ผ ์ ์ฅ",
+ data=st.session_state["sentiment:comp_drill_excel"],
+ file_name=f"competitor_keyword_{data['campaign_id']}.xlsx",
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ key="sentiment:comp_drill_dl_btn",
+ )
+
+
+def _export_competitor_drilldown(
+ campaign_id: int,
+ keyword: str | None = None,
+ sentiment: str | None = None,
+ platform: str | None = None,
+ llm_is_negative: bool | None = None,
+ llm_verified: str | None = None,
+) -> bytes:
+ """๊ฒฝ์์ฌ ๋๋ฆด๋ค์ด ๋ฐ์ดํฐ๋ฅผ Excel๋ก ์ถ์ถ (cursor pagination)."""
+ from io import BytesIO
+ from core.supabase_client import get_supabase_client
+
+ sb = get_supabase_client()
+ batch_size = 1000
+ last_id = 0
+ all_rows: list[dict] = []
+
+ while True:
+ query = (
+ sb.table("keyword_sentiment_results")
+ .select(
+ "id, answer_id, keyword, matched_sentence, keyword_sentiment, keyword_confidence, "
+ "competitor_brand_name, competitor_llm_verified, competitor_llm_sentiment, "
+ "competitor_llm_is_negative, competitor_llm_confidence, "
+ "competitor_llm_reason_tags, competitor_llm_reason_summary, "
+ "citation_urls, citation_count, platform, question_content, created_at"
+ )
+ .eq("campaign_id", campaign_id)
+ .is_("brand_name", "null")
+ .gt("id", last_id)
+ )
+ # competitor_llm_verified filter (conditional, matching drilldown)
+ if llm_verified == "unverified":
+ query = query.or_("competitor_llm_verified.is.null,competitor_llm_verified.eq.false")
+ else:
+ query = query.eq("competitor_llm_verified", True)
+ if keyword:
+ query = query.eq("keyword", keyword)
+ if sentiment:
+ query = query.eq("keyword_sentiment", sentiment)
+ if platform:
+ query = query.eq("platform", platform)
+ if llm_is_negative is not None:
+ query = query.eq("competitor_llm_is_negative", llm_is_negative)
+
+ result = query.order("id").limit(batch_size).execute()
+ rows = result.data or []
+ if not rows:
+ break
+ all_rows.extend(rows)
+ last_id = rows[-1]["id"]
+ if len(rows) < batch_size:
+ break
+
+ from openpyxl import Workbook
+ from openpyxl.utils import get_column_letter
+
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "Competitor Keywords"
+
+ headers = [
+ "ํค์๋", "๋งค์นญ ๋ฌธ์ฅ", "ํค์๋ ๊ฐ์ฑ", "ํค์๋ ํ์ ๋",
+ "๊ฒฝ์์ฌ ๋ธ๋๋", "๊ฒฝ์์ฌ LLM ๊ฐ์ฑ", "๊ฒฝ์์ฌ ์ ํ/์คํ",
+ "๊ฒฝ์์ฌ LLM ํ์ ๋", "๊ฒฝ์์ฌ LLM ํ๊ทธ", "๊ฒฝ์์ฌ LLM ์์ฝ",
+ "์ธ์ฉ URL", "์ธ์ฉ ์", "ํ๋ซํผ", "์ง๋ฌธ", "Answer ID", "์์ฑ์ผ",
+ ]
+ ws.append(headers)
+
+ def _tags_str(tags):
+ if tags and isinstance(tags, list):
+ return ", ".join(str(t) for t in tags)
+ return ""
+
+ for row in all_rows:
+ cites = row.get("citation_urls")
+ cite_str = "\n".join(str(u) for u in cites[:10]) if cites and isinstance(cites, list) else ""
+
+ neg = row.get("competitor_llm_is_negative")
+ neg_label = "์ ํ" if neg is True else "์คํ" if neg is False else ""
+
+ ws.append([
+ row.get("keyword", ""),
+ row.get("matched_sentence", ""),
+ row.get("keyword_sentiment", ""),
+ row.get("keyword_confidence"),
+ row.get("competitor_brand_name", ""),
+ row.get("competitor_llm_sentiment", ""),
+ neg_label,
+ row.get("competitor_llm_confidence"),
+ _tags_str(row.get("competitor_llm_reason_tags")),
+ row.get("competitor_llm_reason_summary", ""),
+ cite_str,
+ row.get("citation_count"),
+ row.get("platform", ""),
+ row.get("question_content", ""),
+ row.get("answer_id"),
+ (row.get("created_at") or "")[:19].replace("T", " "),
+ ])
+
+ widths = [12, 50, 10, 8, 15, 10, 8, 8, 25, 30, 40, 6, 10, 40, 10, 16]
+ for i, w in enumerate(widths, 1):
+ ws.column_dimensions[get_column_letter(i)].width = w
+
+ buf = BytesIO()
+ wb.save(buf)
+ return buf.getvalue()
diff --git a/features/sentiment/keyword_analysis/export_kw.py b/features/sentiment/keyword_analysis/export_kw.py
new file mode 100644
index 0000000000000000000000000000000000000000..ac02f5ed8dfab11363d7a3ff51a9da5dcf05975a
--- /dev/null
+++ b/features/sentiment/keyword_analysis/export_kw.py
@@ -0,0 +1,256 @@
+"""ํค์๋ ๋ถ์ Excel ๋ด๋ณด๋ด๊ธฐ โ Supabase ์ง์ ์ฟผ๋ฆฌ + Athena Full context."""
+import streamlit as st
+
+
+def render_export(
+ data: dict,
+ keyword: str | None = None,
+ sentiment: str | None = None,
+ brand_only: bool = False,
+ no_brand: bool = False,
+ platform: str | None = None,
+ llm_verified: str | None = None,
+ llm_is_negative: bool | None = None,
+):
+ """ํค์๋ ๋ถ์ Excel ๋ค์ด๋ก๋ (์ง์ Supabase, Vercel ์ฐํ).
+
+ Args:
+ llm_is_negative: True=์ ํ, False=์คํ, None=์ ์ฒด
+ """
+ # Build filter description for UI
+ filter_parts = []
+ if keyword:
+ filter_parts.append(f"ํค์๋: {keyword}")
+ else:
+ filter_parts.append("ํค์๋: ์ ์ฒด")
+ if sentiment:
+ label = {"positive": "๊ธ์ ", "neutral": "์ค๋ฆฝ", "negative": "๋ถ์ "}.get(sentiment, sentiment)
+ filter_parts.append(f"๊ฐ์ฑ: {label}")
+ if brand_only:
+ filter_parts.append("๋ธ๋๋ ๋ฉ์
๋ง")
+ elif no_brand:
+ filter_parts.append("๋น๋ธ๋๋๋ง")
+ if platform:
+ filter_parts.append(f"ํ๋ซํผ: {platform}")
+ if llm_is_negative is True:
+ filter_parts.append("2์ฐจ๊ฒ์ฆ: ์ ํ")
+ elif llm_is_negative is False:
+ filter_parts.append("2์ฐจ๊ฒ์ฆ: ์คํ")
+ elif llm_verified:
+ label = {"verified": "๊ฒ์ฆ์๋ฃ", "unverified": "๋ฏธ๊ฒ์ฆ"}.get(llm_verified, llm_verified)
+ filter_parts.append(f"2์ฐจ๊ฒ์ฆ: {label}")
+
+ filter_desc = " | ".join(filter_parts) if filter_parts else "์ ์ฒด"
+ st.caption(f"๐ฅ ๋ค์ด๋ก๋ ํํฐ: {filter_desc}")
+
+ if st.button("๐ฅ Excel ๋ค์ด๋ก๋", key="sentiment:kw_export_btn"):
+ try:
+ xlsx = _export_keyword_direct(
+ data["campaign_id"],
+ keyword=keyword,
+ sentiment=sentiment,
+ brand_only=brand_only,
+ no_brand=no_brand,
+ platform=platform,
+ llm_verified=llm_verified,
+ llm_is_negative=llm_is_negative,
+ )
+ st.session_state["sentiment:kw_excel_data"] = xlsx
+ st.session_state["sentiment:kw_excel_ready"] = True
+ except Exception as e:
+ st.error(f"๋ค์ด๋ก๋ ์คํจ: {e}")
+
+ if st.session_state.get("sentiment:kw_excel_ready"):
+ st.download_button(
+ label="๐พ ํ์ผ ์ ์ฅ",
+ data=st.session_state["sentiment:kw_excel_data"],
+ file_name=f"keyword_analysis_{data['campaign_id']}.xlsx",
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ key="sentiment:kw_dl_btn",
+ )
+
+
+def _export_keyword_direct(
+ campaign_id: int,
+ keyword: str | None = None,
+ sentiment: str | None = None,
+ brand_only: bool = False,
+ no_brand: bool = False,
+ platform: str | None = None,
+ llm_verified: str | None = None,
+ llm_is_negative: bool | None = None,
+) -> bytes:
+ """์ง์ Supabase cursor ํ์ด์ง๋ค์ด์
์ผ๋ก ํํฐ๋ง๋ ํค์๋ ๋ฐ์ดํฐ ์ถ์ถ.
+
+ Vercel 10์ด ํ์์์ ์ฐํ. ํํฐ ์ ์ฉ์ผ๋ก ๋์ ํ ์ ๊ฐ์.
+ Full context๋ Athena์์ ๋ฐฐ์น fetchํ์ฌ ๋ณํฉ.
+
+ Args:
+ llm_is_negative: True=์ ํ(๋ถ์ ํ์ ), False=์คํ(๋ถ์ ์๋), None=์ ์ฒด
+ """
+ from io import BytesIO
+ from core.supabase_client import get_supabase_client
+ from core.athena_client import fetch_full_answers_batch
+
+ sb = get_supabase_client()
+ batch_size = 1000
+ export_warn_threshold = 100_000
+ warned = False
+ last_id = 0
+ all_rows: list[dict] = []
+
+ # Cursor-based pagination (id > last_id) โ O(1) per batch
+ progress = st.progress(0, text="๋ฐ์ดํฐ ๋ก๋ฉ ์ค...")
+ batch_num = 0
+ while True:
+ query = (
+ sb.table("keyword_sentiment_results")
+ .select(
+ "id, answer_id, keyword, matched_sentence, keyword_sentiment, keyword_confidence, "
+ "brand_name, brand_sentiment, brand_confidence, brand_mentions, "
+ "llm_verified, llm_sentiment, llm_is_negative, llm_confidence, llm_reason_tags, "
+ "llm_reason_summary, llm_reasoning, "
+ "competitor_brand_name, competitor_llm_verified, competitor_llm_sentiment, "
+ "competitor_llm_reason_tags, competitor_llm_reason_summary, "
+ "citation_urls, citation_count, platform, question_content, created_at"
+ )
+ .eq("campaign_id", campaign_id)
+ .gt("id", last_id)
+ )
+ # Apply filters
+ if keyword:
+ query = query.eq("keyword", keyword)
+ if sentiment:
+ query = query.eq("keyword_sentiment", sentiment)
+ if brand_only:
+ query = query.not_.is_("brand_name", "null")
+ elif no_brand:
+ query = query.is_("brand_name", "null")
+ if platform:
+ query = query.eq("platform", platform)
+ if llm_is_negative is not None:
+ query = query.eq("llm_is_negative", llm_is_negative)
+ elif llm_verified == "verified":
+ query = query.eq("llm_verified", True)
+ elif llm_verified == "unverified":
+ query = query.or_("llm_verified.is.null,llm_verified.eq.false")
+
+ result = query.order("id").limit(batch_size).execute()
+ rows = result.data or []
+ if not rows:
+ break
+ all_rows.extend(rows)
+ last_id = rows[-1]["id"]
+ batch_num += 1
+ pct = min(len(all_rows) / max(len(all_rows) + batch_size, 1), 0.99)
+ progress.progress(pct, text=f"๋ก๋ฉ ์ค... {len(all_rows):,}๊ฑด")
+ if len(rows) < batch_size:
+ break
+ if not warned and len(all_rows) >= export_warn_threshold:
+ st.warning(f"๋์ฉ๋ Export ({len(all_rows):,}๊ฑด+). ์๋ฃ๊น์ง ์๊ฐ์ด ๊ฑธ๋ฆด ์ ์์ต๋๋ค.")
+ warned = True
+
+ progress.progress(1.0, text=f"{len(all_rows):,}๊ฑด ๋ก๋ ์๋ฃ. Full context ๊ฐ์ ธ์ค๋ ์ค...")
+
+ # Batch fetch full answers from Athena
+ unique_ids = list({row["answer_id"] for row in all_rows if row.get("answer_id")})
+ full_answers: dict[int, str] = {}
+ athena_batch_size = 500
+ for i in range(0, len(unique_ids), athena_batch_size):
+ chunk = unique_ids[i:i + athena_batch_size]
+ full_answers.update(fetch_full_answers_batch(chunk))
+ pct = min((i + athena_batch_size) / max(len(unique_ids), 1), 0.99)
+ progress.progress(pct, text=f"Full context ๋ก๋ฉ... {min(i + athena_batch_size, len(unique_ids)):,}/{len(unique_ids):,} ๋ต๋ณ")
+
+ progress.progress(1.0, text=f"Full context {len(full_answers):,}๊ฑด ๋ก๋ ์๋ฃ!")
+
+ # Build Excel
+ from openpyxl import Workbook
+ from openpyxl.utils import get_column_letter
+
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "Keyword Sentiment"
+
+ headers = [
+ "ํค์๋", "๋งค์นญ ๋ฌธ์ฅ", "Full Context", "ํค์๋ ๊ฐ์ฑ", "ํค์๋ ํ์ ๋",
+ "๋ธ๋๋๋ช
", "๋ธ๋๋ ๊ฐ์ฑ", "๋ธ๋๋ ํ์ ๋", "๋ธ๋๋ ์ธ๊ธ",
+ "LLM ๊ฒ์ฆ", "LLM ๊ฐ์ฑ", "LLM ํ์ ๋", "LLM ํ๊ทธ", "LLM ์์ฝ", "LLM ๊ทผ๊ฑฐ",
+ "๊ฒฝ์์ฌ ๋ธ๋๋", "๊ฒฝ์์ฌ LLM ๊ฒ์ฆ", "๊ฒฝ์์ฌ LLM ๊ฐ์ฑ",
+ "๊ฒฝ์์ฌ LLM ํ๊ทธ", "๊ฒฝ์์ฌ LLM ์์ฝ",
+ "์ธ์ฉ URL", "์ธ์ฉ ์", "ํ๋ซํผ", "์ง๋ฌธ", "Answer ID", "์์ฑ์ผ",
+ ]
+ ws.append(headers)
+
+ progress.progress(0.0, text="Excel ์์ฑ ์ค...")
+ for i, row in enumerate(all_rows):
+ # Brand mentions
+ brand_mentions_str = ""
+ bm = row.get("brand_mentions")
+ if bm and isinstance(bm, dict):
+ parts = []
+ if bm.get("in_house"):
+ parts.append(f"์์ฌ: {', '.join(bm['in_house'])}")
+ if bm.get("competitor"):
+ parts.append(f"๊ฒฝ์์ฌ: {', '.join(bm['competitor'])}")
+ brand_mentions_str = "; ".join(parts)
+
+ # Tags (list -> comma-separated)
+ def _tags_str(tags):
+ if tags and isinstance(tags, list):
+ return ", ".join(str(t) for t in tags)
+ return ""
+
+ # Citations
+ cite_str = ""
+ cites = row.get("citation_urls")
+ if cites and isinstance(cites, list):
+ cite_str = "\n".join(str(u) for u in cites[:10])
+
+ # Full context from Athena
+ answer_id = row.get("answer_id")
+ full_text = full_answers.get(answer_id, "") if answer_id else ""
+
+ ws.append([
+ row.get("keyword", ""),
+ row.get("matched_sentence", ""),
+ full_text,
+ row.get("keyword_sentiment", ""),
+ row.get("keyword_confidence"),
+ row.get("brand_name", ""),
+ row.get("brand_sentiment", ""),
+ row.get("brand_confidence"),
+ brand_mentions_str,
+ "Y" if row.get("llm_verified") else "N",
+ row.get("llm_sentiment", ""),
+ row.get("llm_confidence"),
+ _tags_str(row.get("llm_reason_tags")),
+ row.get("llm_reason_summary", ""),
+ (row.get("llm_reasoning") or "")[:500],
+ row.get("competitor_brand_name", ""),
+ "Y" if row.get("competitor_llm_verified") else "N",
+ row.get("competitor_llm_sentiment", ""),
+ _tags_str(row.get("competitor_llm_reason_tags")),
+ row.get("competitor_llm_reason_summary", ""),
+ cite_str,
+ row.get("citation_count"),
+ row.get("platform", ""),
+ row.get("question_content", ""),
+ answer_id,
+ (row.get("created_at") or "")[:19].replace("T", " "),
+ ])
+
+ if i % 10000 == 0:
+ progress.progress(min(i / max(len(all_rows), 1), 0.99), text=f"Excel ์์ฑ ์ค... {i:,}/{len(all_rows):,}")
+
+ # Column widths
+ widths = [12, 50, 80, 10, 8, 12, 10, 8, 25, 6, 10, 8, 25, 30, 40, 12, 6, 10, 25, 30, 40, 6, 10, 40, 10, 16]
+ for i, w in enumerate(widths, 1):
+ if i <= len(widths):
+ ws.column_dimensions[get_column_letter(i)].width = w
+
+ progress.progress(1.0, text="ํ์ผ ์์ฑ ์๋ฃ!")
+
+ buf = BytesIO()
+ wb.save(buf)
+ return buf.getvalue()
diff --git a/features/sentiment/keyword_analysis/overview.py b/features/sentiment/keyword_analysis/overview.py
new file mode 100644
index 0000000000000000000000000000000000000000..d24b30c37b586fab12d2c183dede0dbd17433d6c
--- /dev/null
+++ b/features/sentiment/keyword_analysis/overview.py
@@ -0,0 +1,155 @@
+"""ํค์๋ ๋ถ์ ์ค๋ฒ๋ทฐ โ ์์ฝ ์นด๋, ํ
์ด๋ธ, ์ฐจํธ."""
+import html
+
+import pandas as pd
+import plotly.graph_objects as go
+import streamlit as st
+
+from core.charts import POLARITY_COLORS
+
+
+def render_keyword_overview(keywords_list: list[dict]):
+ """ํค์๋ ๋ถ์ ์ค๋ฒ๋ทฐ ์นด๋."""
+ total_keywords = len(keywords_list)
+ total_sentences = sum(kw.get("total_sentences", 0) for kw in keywords_list)
+ total_brand_mentions = sum(kw.get("brand_mentioned_count", 0) for kw in keywords_list)
+ total_no_brand = total_sentences - total_brand_mentions
+ brand_mention_rate = (total_brand_mentions / total_sentences * 100) if total_sentences > 0 else 0
+
+ # Brand sentiment aggregation
+ brand_pos = sum((kw.get("brand_sentiment") or {}).get("positive", 0) for kw in keywords_list)
+ brand_neu = sum((kw.get("brand_sentiment") or {}).get("neutral", 0) for kw in keywords_list)
+ brand_neg = sum((kw.get("brand_sentiment") or {}).get("negative", 0) for kw in keywords_list)
+ brand_total = brand_pos + brand_neu + brand_neg
+ brand_pos_pct = (brand_pos / brand_total * 100) if brand_total > 0 else 0
+ brand_neg_pct = (brand_neg / brand_total * 100) if brand_total > 0 else 0
+
+ # Top keyword-brand associations
+ top_associations = []
+ for kw in sorted(keywords_list, key=lambda x: x.get("brand_mentioned_count", 0), reverse=True)[:3]:
+ keyword = kw.get("keyword", "")
+ total = kw.get("total_sentences", 0)
+ brand_count = kw.get("brand_mentioned_count", 0)
+ if total > 0 and brand_count > 0:
+ rate = brand_count / total * 100
+ top_associations.append(f'"{keyword}" {brand_count:,}๊ฑด ({rate:.0f}%)')
+
+ assoc_text = " | ".join(top_associations) if top_associations else "๋ฐ์ดํฐ ์์"
+
+ # Brand sentiment bar
+ brand_sent_bar = ""
+ if brand_total > 0:
+ bp = brand_pos / brand_total * 100
+ bn = brand_neg / brand_total * 100
+ bne = 100 - bp - bn
+ brand_sent_bar = f"""
+ """
+
+ st.markdown(f"""
+
+
+ ๋ถ์ ํค์๋: {total_keywords}๊ฐ
+ ์ ์ฒด ๋ฌธ์ฅ: {total_sentences:,}๊ฑด
+
+
+ ๐ ๋ธ๋๋ ์ธ๊ธ: {total_brand_mentions:,}๊ฑด ({brand_mention_rate:.1f}%)
+ — ๊ธ์ {brand_pos_pct:.0f}%
+ / ๋ถ์ {brand_neg_pct:.0f}%
+
+ ๋น๋ธ๋๋: {total_no_brand:,}๊ฑด ({100 - brand_mention_rate:.1f}%)
+
{brand_sent_bar}
+
+ ํค์๋-๋ธ๋๋ ์ฐ๊ด ์์: {html.escape(assoc_text)}
+
+
+""", unsafe_allow_html=True)
+
+
+def render_summary_table(keywords_list: list[dict]):
+ """ํค์๋๋ณ ๊ฐ์ฑ ์์ฝ ํ
์ด๋ธ."""
+ st.markdown("**ํค์๋๋ณ ๊ฐ์ฑ ์์ฝ**")
+
+ rows = []
+ for kw in keywords_list:
+ keyword = kw.get("keyword", "")
+ total = kw.get("total_sentences", 0)
+ ks = kw.get("keyword_sentiment", {})
+ pos = ks.get("positive", 0)
+ neu = ks.get("neutral", 0)
+ neg = ks.get("negative", 0)
+ brand_count = kw.get("brand_mentioned_count", 0)
+
+ neg_rate = (neg / total * 100) if total > 0 else 0
+ pos_rate = (pos / total * 100) if total > 0 else 0
+
+ # Brand sentiment breakdown
+ brand_sent = kw.get("brand_sentiment") or {}
+ brand_pos = brand_sent.get("positive", 0)
+ brand_neg = brand_sent.get("negative", 0)
+ brand_neg_rate = (brand_neg / brand_count * 100) if brand_count > 0 else 0
+
+ rows.append({
+ "ํค์๋": keyword,
+ "์ด ๋ฌธ์ฅ": total,
+ "๊ธ์ ": pos,
+ "์ค๋ฆฝ": neu,
+ "๋ถ์ ": neg,
+ "๋ถ์ ๋ฅ ": f"{neg_rate:.1f}%",
+ "๊ธ์ ๋ฅ ": f"{pos_rate:.1f}%",
+ "๋ธ๋๋ ๋ฉ์
": brand_count,
+ "๋ธ๋๋ ๊ธ์ ": brand_pos if brand_count > 0 else "-",
+ "๋ธ๋๋ ๋ถ์ ": brand_neg if brand_count > 0 else "-",
+ "๋ธ๋๋ ๋ถ์ ๋ฅ ": f"{brand_neg_rate:.1f}%" if brand_count > 0 else "-",
+ })
+
+ if rows:
+ df = pd.DataFrame(rows)
+ df = df.sort_values("๋ถ์ ", ascending=False)
+ st.dataframe(df, use_container_width=True, hide_index=True)
+ else:
+ st.info("๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค")
+
+
+def render_sentiment_chart(keywords_list: list[dict]):
+ """ํค์๋ ๊ฐ์ฑ ๋น๊ต bar chart (๋ถ์ ๋น์จ ์)."""
+ st.markdown("**ํค์๋ ๊ฐ์ฑ ๋น๊ต ์ฐจํธ**")
+
+ # Sort by negative count descending
+ sorted_kws = sorted(
+ keywords_list,
+ key=lambda x: x.get("keyword_sentiment", {}).get("negative", 0),
+ reverse=True,
+ )[:20] # Top 20
+
+ keywords = [kw.get("keyword", "") for kw in sorted_kws]
+ positives = [kw.get("keyword_sentiment", {}).get("positive", 0) for kw in sorted_kws]
+ neutrals = [kw.get("keyword_sentiment", {}).get("neutral", 0) for kw in sorted_kws]
+ negatives = [kw.get("keyword_sentiment", {}).get("negative", 0) for kw in sorted_kws]
+
+ fig = go.Figure()
+ fig.add_trace(go.Bar(
+ name="๋ถ์ ", x=keywords, y=negatives,
+ marker_color=POLARITY_COLORS["negative"],
+ ))
+ fig.add_trace(go.Bar(
+ name="์ค๋ฆฝ", x=keywords, y=neutrals,
+ marker_color=POLARITY_COLORS["neutral"],
+ ))
+ fig.add_trace(go.Bar(
+ name="๊ธ์ ", x=keywords, y=positives,
+ marker_color=POLARITY_COLORS["positive"],
+ ))
+
+ fig.update_layout(
+ barmode="stack",
+ height=400,
+ margin=dict(l=20, r=20, t=30, b=80),
+ legend=dict(orientation="h", yanchor="bottom", y=1.02),
+ xaxis_tickangle=-45,
+ )
+ st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
diff --git a/features/sentiment/overview.py b/features/sentiment/overview.py
new file mode 100644
index 0000000000000000000000000000000000000000..975d0f51d058bdac917f2115df727732e3d825a2
--- /dev/null
+++ b/features/sentiment/overview.py
@@ -0,0 +1,241 @@
+"""๊ฐ์ฑ๋ถ์ ์ค๋ฒ๋ทฐ ํญ.
+
+์ ์ฒด ๊ฐ์ฑ ๋ถ์ + ๋ธ๋๋ ๋ฉ์
๋ถ์ + LLM 2์ฐจ ๊ฒ์ฆ ๊ฒฐ๊ณผ.
+"""
+import streamlit as st
+
+from core.charts import create_brand_sentiment_chart
+from core.supabase_client import get_polarity_stats, get_answers_by_polarity
+from core.utils import truncate_text
+
+
+def render(data: dict):
+ """์ค๋ฒ๋ทฐ ํญ ๋ ๋๋ง."""
+ # --- ์ ์ฒด ๊ฐ์ฑ ๋ถ์ ---
+ st.markdown("##### ๐ ์ ์ฒด ๊ฐ์ฑ ๋ถ์")
+ st.caption("AI ๋ต๋ณ์ ์ ์ฒด ๊ฐ์ฑ ๋ถํฌ๋ฅผ ํ์ธํฉ๋๋ค (๊ธ์ /์ค๋ฆฝ/๋ถ์ )")
+
+ polarity_in_house_only = st.checkbox(
+ "๐ ์์ฌ ๋ธ๋๋ ์ธ๊ธ ๋ต๋ณ๋ง ๋ณด๊ธฐ",
+ value=False,
+ key="sentiment:polarity_in_house_filter",
+ help="์ฒดํฌ ์ ์์ฌ ๋ธ๋๋๊ฐ ์ธ๊ธ๋ ๋ต๋ณ๋ง ํ์ํฉ๋๋ค.",
+ )
+
+ try:
+ polarity_stats = get_polarity_stats(data["campaign_id"], in_house_only=polarity_in_house_only)
+ positive_count = polarity_stats.get("positive", 0)
+ neutral_count = polarity_stats.get("neutral", 0)
+ negative_count = polarity_stats.get("negative", 0)
+ total_answers_pol = positive_count + neutral_count + negative_count
+
+ pol_col1, pol_col2, pol_col3, pol_col4 = st.columns(4)
+ with pol_col1:
+ st.metric("์ ์ฒด ๋ถ์", f"{total_answers_pol:,}๊ฑด")
+ with pol_col2:
+ pos_rate = (positive_count / total_answers_pol * 100) if total_answers_pol > 0 else 0
+ st.metric("๐ ๊ธ์ ", f"{positive_count:,}๊ฑด", f"{pos_rate:.1f}%")
+ with pol_col3:
+ neu_rate = (neutral_count / total_answers_pol * 100) if total_answers_pol > 0 else 0
+ st.metric("๐ ์ค๋ฆฝ", f"{neutral_count:,}๊ฑด", f"{neu_rate:.1f}%")
+ with pol_col4:
+ neg_rate = (negative_count / total_answers_pol * 100) if total_answers_pol > 0 else 0
+ st.metric("๐ ๋ถ์ ", f"{negative_count:,}๊ฑด", f"{neg_rate:.1f}%")
+
+ st.markdown("---")
+
+ polarity_filter = st.selectbox(
+ "๊ฐ์ฑ ๋ถ๋ฅ ์ ํ",
+ options=["positive", "neutral", "negative"],
+ format_func=lambda x: {"positive": "๐ ๊ธ์ ", "neutral": "๐ ์ค๋ฆฝ", "negative": "๐ ๋ถ์ "}[x],
+ key="sentiment:polarity_filter_tab6",
+ )
+
+ polarity_page = st.number_input("ํ์ด์ง", min_value=1, value=1, key="sentiment:polarity_page")
+ polarity_items, polarity_total = get_answers_by_polarity(
+ data["campaign_id"], polarity_filter, page=polarity_page, page_size=20,
+ in_house_only=polarity_in_house_only,
+ )
+
+ st.markdown(f"**{polarity_total:,}๊ฑด** ์ค {len(polarity_items)}๊ฑด ํ์")
+
+ for item in polarity_items:
+ _render_polarity_item(item)
+
+ except Exception as e:
+ st.error(f"๊ฐ์ฑ ๋ฐ์ดํฐ ๋ก๋ ์คํจ: {e}")
+ st.info("Supabase ์ฐ๊ฒฐ ์ค์ ์ ํ์ธํ์ธ์")
+
+ # --- LLM 2์ฐจ ๊ฒ์ฆ ๊ฒฐ๊ณผ ---
+ _render_llm_verification_summary(data)
+
+ # --- ๋ธ๋๋ ๋ถ์ ---
+ st.markdown("---")
+ st.markdown("##### ๐ท๏ธ ๋ธ๋๋ ๋ฉ์
๋ถ์")
+ st.caption("์์ฌ ๋ธ๋๋์ ๊ฒฝ์์ฌ ๋ธ๋๋๊ฐ AI ๋ต๋ณ์์ ์ด๋ป๊ฒ ์ธ๊ธ๋๋์ง ๋ถ์ํฉ๋๋ค")
+
+ brand_data = data["brand_data"] or {}
+ in_house_summary = brand_data.get("in_house_summary", [])
+ competitor_summary = brand_data.get("competitor_summary", [])
+ total_answers = brand_data.get("total_answers", 0)
+
+ st.markdown(f"**๋ถ์๋ AI ๋ต๋ณ**: {total_answers}๊ฑด")
+ st.markdown("---")
+
+ brand_col1, brand_col2 = st.columns(2)
+
+ with brand_col1:
+ st.markdown("##### ๐ ์์ฌ ๋ธ๋๋")
+ if in_house_summary:
+ for brand in in_house_summary[:5]:
+ _render_brand_card(brand, "in_house")
+ else:
+ st.info("์์ฌ ๋ธ๋๋ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค")
+
+ with brand_col2:
+ st.markdown("##### ๐ข ๊ฒฝ์์ฌ ๋ธ๋๋")
+ if competitor_summary:
+ for brand in competitor_summary[:5]:
+ _render_brand_card(brand, "competitor")
+ else:
+ st.info("๊ฒฝ์์ฌ ๋ธ๋๋ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค")
+
+ if in_house_summary or competitor_summary:
+ st.markdown("---")
+ st.markdown("##### ๐ ๋ธ๋๋๋ณ ๊ฐ์ฑ ๋น๊ต")
+ all_brands = in_house_summary + competitor_summary
+ if all_brands:
+ fig = create_brand_sentiment_chart(all_brands)
+ st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
+
+
+def _render_llm_verification_summary(data: dict):
+ """LLM 2์ฐจ ๊ฒ์ฆ ๊ฒฐ๊ณผ ์์ฝ ๋ ๋๋ง."""
+ st.markdown("---")
+ st.markdown("##### ๐ค LLM 2์ฐจ ๊ฒ์ฆ ๊ฒฐ๊ณผ")
+ st.caption(
+ "DeBERTa(1์ฐจ AI)๊ฐ ๋ถ์ ๊ฐ์งํ ๋ต๋ณ์ LLM(2์ฐจ AI)์ด ์ฌ๊ฒ์ฆํ ๊ฒฐ๊ณผ์
๋๋ค. "
+ "๐ด ์ ํ = ์ค์ ๋ถ์ ํ์ธ (๋ฆฌ์คํฌ) | ๐ข ์คํ = ๋ถ์ ์๋ ํ์ธ (์์ )"
+ )
+
+ llm_stats = data.get("llm_verification_stats") or {}
+ total_nudge = data.get("total_nudge", 0)
+ total_verified = llm_stats.get("total_verified", 0)
+ true_negatives = llm_stats.get("true_negatives", 0)
+ false_positives = llm_stats.get("false_positives", 0)
+ pending = total_nudge - total_verified
+
+ if total_nudge == 0:
+ st.info("๋ถ์ ๊ฐ์ง๋ ๋์ง ํ๋ณด๊ฐ ์์ต๋๋ค.")
+ return
+
+ # --- Metrics ---
+ llm_col1, llm_col2, llm_col3, llm_col4 = st.columns(4)
+ with llm_col1:
+ st.metric("๐ ๋์ง ํ๋ณด", f"{total_nudge:,}๊ฑด")
+ with llm_col2:
+ verify_rate = (total_verified / total_nudge * 100) if total_nudge > 0 else 0
+ st.metric("โ
๊ฒ์ฆ ์๋ฃ", f"{total_verified:,}๊ฑด", f"{verify_rate:.0f}%")
+ with llm_col3:
+ tp_rate = (true_negatives / total_verified * 100) if total_verified > 0 else 0
+ st.metric("๐ฏ ์ ํ", f"{true_negatives:,}๊ฑด", f"{tp_rate:.1f}%")
+ with llm_col4:
+ fp_rate = (false_positives / total_verified * 100) if total_verified > 0 else 0
+ st.metric("๐ซ ์คํ", f"{false_positives:,}๊ฑด", f"{fp_rate:.1f}%")
+
+ # --- Visual bar ---
+ if total_verified > 0:
+ tp_pct = true_negatives / total_nudge * 100
+ fp_pct = false_positives / total_nudge * 100
+ pending_pct = pending / total_nudge * 100
+
+ st.markdown(f"""
+
+
+ {'์ ํ' if tp_pct > 8 else ''}
+
+
+ {'์คํ' if fp_pct > 8 else ''}
+
+
+ {'๋ฏธ๊ฒ์ฆ' if pending_pct > 8 else ''}
+
+
+
+ ๐ด ์ ํ {tp_pct:.1f}%
+ ๐ข ์คํ {fp_pct:.1f}%
+ โช ๋ฏธ๊ฒ์ฆ {pending_pct:.1f}%
+
+ """, unsafe_allow_html=True)
+
+ # --- Confirmed negative tier distribution ---
+ candidates = data.get("candidates", [])
+ confirmed = [c for c in candidates if c.get("llm_verified") and c.get("llm_is_negative")]
+
+ if confirmed:
+ tier_dist: dict[str, int] = {}
+ for c in confirmed:
+ tier = c.get("llm_adjusted_tier") or "UNKNOWN"
+ tier_dist[tier] = tier_dist.get(tier, 0) + 1
+
+ tier_colors = {"HIGH": "#EF4444", "MEDIUM": "#F59E0B", "LOW": "#3B82F6", "NONE": "#10B981", "UNKNOWN": "#9CA3AF"}
+
+ st.markdown("**์ ํ ๋ต๋ณ์ LLM ๋ฑ๊ธ ๋ถํฌ**")
+ tier_cols = st.columns(len(tier_dist))
+ for i, (tier, count) in enumerate(sorted(tier_dist.items(), key=lambda x: -x[1])):
+ color = tier_colors.get(tier, "#9CA3AF")
+ pct = count / len(confirmed) * 100
+ with tier_cols[i]:
+ st.markdown(f"""
+
+
{count}
+
{tier} ({pct:.0f}%)
+
+ """, unsafe_allow_html=True)
+
+
+def _render_polarity_item(item: dict):
+ """๊ฐ์ฑ ํญ๋ชฉ ๋ ๋๋ง."""
+ polarity_emoji = {"positive": "๐", "neutral": "๐", "negative": "๐"}.get(item.get('overall_polarity'), "โ")
+ confidence = item.get('overall_confidence', 0) or 0
+
+ with st.expander(f"{polarity_emoji} {truncate_text(item.get('question_content', 'N/A'), 80)}", expanded=False):
+ st.markdown(f"**์ง๋ฌธ**: {item.get('question_content', 'N/A')}")
+ st.markdown(f"**๋ต๋ณ ๋ฏธ๋ฆฌ๋ณด๊ธฐ**: {item.get('answer_preview', 'N/A')}")
+ st.markdown("---")
+
+ info_col1, info_col2, info_col3 = st.columns(3)
+ with info_col1:
+ st.markdown(f"**๊ฐ์ฑ**: {item.get('overall_polarity', 'N/A')}")
+ st.markdown(f"**์ ๋ขฐ๋**: {confidence:.1%}")
+ with info_col2:
+ st.markdown(f"**ํ๋ซํผ**: {item.get('platform', 'N/A')}")
+ st.markdown(f"**CEJ**: {item.get('cej_depth1', 'N/A')} / {item.get('cej_depth2', 'N/A')}")
+ with info_col3:
+ st.markdown(f"**Tier**: {item.get('routing_tier', 'N/A')}")
+ st.markdown(f"**๊ฐ์ **: {item.get('dominant_emotion', 'N/A')}")
+
+ in_house = item.get('in_house_brands', []) or []
+ mentioned = item.get('mentioned_brands', []) or []
+ if in_house or mentioned:
+ st.markdown(f"**์์ฌ ๋ธ๋๋**: {', '.join(in_house) if in_house else 'N/A'}")
+ st.markdown(f"**์ธ๊ธ ๋ธ๋๋**: {', '.join(mentioned) if mentioned else 'N/A'}")
+
+
+def _render_brand_card(brand: dict, brand_type: str):
+ """๋ธ๋๋ ์นด๋ ๋ ๋๋ง."""
+ brand_name = brand.get("brand_name", "Unknown")
+ total_mentions = brand.get("total_mentions", 0)
+ positive_rate = brand.get("positive_rate", 0)
+ negative_rate = brand.get("negative_rate", 0)
+
+ bg_color = "#F0F9FF" if brand_type == "in_house" else "#FEF3C7"
+
+ brand_html = f''
+ brand_html += f'
{brand_name}
'
+ brand_html += f'
'
+ brand_html += f'๐ ์ธ๊ธ: {total_mentions}'
+ brand_html += f'โ
๊ธ์ : {positive_rate:.1f}%'
+ brand_html += f'โ ๋ถ์ : {negative_rate:.1f}%'
+ brand_html += '
'
+ st.markdown(brand_html, unsafe_allow_html=True)
diff --git a/features/sentiment/run.py b/features/sentiment/run.py
new file mode 100644
index 0000000000000000000000000000000000000000..0e3153cd7aff711abbb7de8987d5d37c8eee6e62
--- /dev/null
+++ b/features/sentiment/run.py
@@ -0,0 +1,319 @@
+"""๊ฐ์ฑ๋ถ์ ์คํ ํญ.
+
+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"""
+
+
๐ฅ
+
1. ๋ฐ์ดํฐ ์์ง
+
{overview_total:,}
+
AI ๋ต๋ณ
+
+ """, unsafe_allow_html=True)
+
+ with pipe_col2:
+ ih_rate = (overview_ih_neg / overview_total * 100) if overview_total > 0 else 0
+ st.markdown(f"""
+
+
๐
+
2. DeBERTa ๋ถ์
+
{overview_ih_neg:,}
+
๋ถ์ ๊ฐ์ง ({ih_rate:.1f}%)
+
+ """, 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"""
+
+
๐ค
+
3. LLM ๊ฒ์ฆ
+
{overview_llm_done:,}
+
์๋ฃ ({verify_rate:.0f}%)
+
+ """, 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"""
+
+
๐ฏ
+
4. ์ ํ
+
{overview_llm_confirmed:,}
+
์ ํ๋ฅ {confirm_rate:.1f}%
+
+ """, 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"""
+
+
+
+ {status_emoji}
+ {status_label}
+
+
+
{progress}%
+
์์์๊ฐ: {duration}
+
+
+
+ {message}
+
+
+ ์ฒ๋ฆฌ: {processed:,} / {total_answers:,} ๋ต๋ณ
+
+
+ """, 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)
diff --git a/features/sentiment/summary.py b/features/sentiment/summary.py
new file mode 100644
index 0000000000000000000000000000000000000000..a0eed885869d0dacb06af287a007eb56b196a00c
--- /dev/null
+++ b/features/sentiment/summary.py
@@ -0,0 +1,174 @@
+"""๊ฐ์ฑ๋ถ์ Feature ์์ฝ ์นด๋.
+
+๊ธฐ์กด sections/executive_summary.py + quick_overview.py + KPI ํตํฉ.
+"""
+import streamlit as st
+
+from core.charts import (
+ create_confidence_tier_pie_chart,
+ create_nudge_by_cej_bar_chart,
+ create_platform_bar_chart,
+)
+
+
+def get_risk_grade(high_nudges: int) -> tuple[str, str, str]:
+ """Get risk grade based on HIGH tier nudge count."""
+ if high_nudges == 0:
+ return "A", "grade-a", "์ฐ์ (๋ถ์ ์ธ๊ธ ์์)"
+ elif high_nudges <= 10:
+ return "B", "grade-b", "์ํธ"
+ elif high_nudges <= 30:
+ return "C", "grade-c", "์ฃผ์ ํ์"
+ else:
+ return "D", "grade-d", "์ฆ์ ๋์"
+
+
+def render_summary(data: dict):
+ """๊ฐ์ฑ๋ถ์ ์์ฝ ์นด๋ ๋ ๋๋ง."""
+ total_nudge = data.get("total_nudge", 0)
+ high_count = data.get("high_count", 0)
+ medium_count = data.get("medium_count", 0)
+ risk_score = data.get("risk_score", 0.0)
+ tier_stats = data.get("tier_stats") or {}
+ platform_stats = data.get("platform_stats") or {}
+ cej_stats = data.get("cej_stats") or {}
+ candidates = data.get("candidates") or []
+ campaign_overview = data.get("campaign_overview") or {}
+
+ # --- Sentiment Summary Card ---
+ grade, grade_class, grade_desc = get_risk_grade(high_count)
+ if total_nudge == 0:
+ nudge_insight = "๋ถ์ ์ธ๊ธ ์์"
+ elif high_count == 0:
+ nudge_insight = f"์ ์ฌ ๋ฆฌ์คํฌ {total_nudge}๊ฑด (ํ์ ๋ ๋ฎ์)"
+ else:
+ nudge_insight = f"HIGH {high_count}๊ฑด / ์ด {total_nudge}๊ฑด"
+
+ col1, col2, col3, col4 = st.columns(4)
+ with col1:
+ st.markdown(f"""
+
+
๊ฑด๊ฐ ๋ฑ๊ธ
+
{grade}
+
{grade_desc}
+
+ """, unsafe_allow_html=True)
+ with col2:
+ st.metric("๐ด HIGH", f"{high_count}๊ฑด", help="โฅ85% ํ์ ๋ - ์ฆ์ ๋์ ๊ถ์ฅ")
+ with col3:
+ st.metric("๋ฆฌ์คํฌ ์ ์", f"{risk_score:.1f}", help="๊ฐ์ค ํ๊ท ์ ์")
+ with col4:
+ citation_total = sum(c.get("citation_count", 0) or 0 for c in candidates)
+ st.metric("์ด ์ธ์ฉ ์์ค", f"{citation_total}๊ฐ")
+
+ # --- Pipeline Overview (collapsible) ---
+ with st.expander("๐ ๋ฐ์ดํฐ ํ์ดํ๋ผ์ธ ์์ธ", expanded=False):
+ _render_pipeline_overview(campaign_overview)
+
+ # --- Quick Charts ---
+ st.markdown("---")
+ chart_col1, chart_col2, chart_col3, chart_col4 = st.columns(4)
+
+ with chart_col1:
+ st.markdown("##### Confidence Tier ๋ถํฌ")
+ if tier_stats and sum(tier_stats.values()) > 0:
+ fig = create_confidence_tier_pie_chart(tier_stats)
+ st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
+ else:
+ st.info("๋ถ์ ์ธ๊ธ์ด ์์ต๋๋ค")
+
+ with chart_col2:
+ st.markdown("##### ํ๋ซํผ๋ณ ๋ถํฌ")
+ if platform_stats and sum(platform_stats.values()) > 0:
+ fig = create_platform_bar_chart(platform_stats)
+ st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
+ else:
+ st.info("ํ๋ซํผ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค")
+
+ with chart_col3:
+ st.markdown("##### CEJ ๋จ๊ณ๋ณ ๋ถํฌ")
+ if cej_stats and sum(cej_stats.values()) > 0:
+ fig = create_nudge_by_cej_bar_chart(cej_stats)
+ st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
+ else:
+ st.info("CEJ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค")
+
+ with chart_col4:
+ _render_llm_verification_summary(campaign_overview)
+
+
+def _render_pipeline_overview(campaign_overview: dict):
+ """๋ฐ์ดํฐ ํ์ดํ๋ผ์ธ ํํฉ."""
+ overview_total = campaign_overview.get("total_answers", 0)
+ overview_ih_neg = campaign_overview.get("in_house_negative_count", 0)
+ overview_llm_done = campaign_overview.get("llm_verified_in_house", 0)
+ overview_llm_pending = campaign_overview.get("llm_pending", 0)
+ overview_llm_confirmed = campaign_overview.get("llm_confirmed_negative", 0)
+
+ pipe1, pipe2, pipe3, pipe4, pipe5 = st.columns(5)
+
+ with pipe1:
+ st.metric(
+ label="์ ์ฒด AI ๋ต๋ณ",
+ value=f"{overview_total:,}๊ฑด",
+ help="๊ฐ์ฑ ๋ถ์์ด ์๋ฃ๋ ์ ์ฒด AI ๋ต๋ณ ์",
+ )
+ with pipe2:
+ ih_rate = (overview_ih_neg / overview_total * 100) if overview_total > 0 else 0
+ st.metric(
+ label="1์ฐจ ๋ถ์ ๊ฐ์ง (DeBERTa)",
+ value=f"{overview_ih_neg:,}๊ฑด",
+ delta=f"{ih_rate:.1f}%",
+ delta_color="inverse",
+ help="์์ฌ ๋ธ๋๋์ ๋ํ ๋ถ์ ๊ฐ์ฑ์ด ๊ฐ์ง๋ ๋ต๋ณ (ABSA ๊ธฐ๋ฐ)",
+ )
+ with pipe3:
+ verify_rate = (overview_llm_done / overview_ih_neg * 100) if overview_ih_neg > 0 else 0
+ st.metric(
+ label="2์ฐจ ๊ฒ์ฆ ์๋ฃ (LLM)",
+ value=f"{overview_llm_done:,}๊ฑด",
+ delta=f"{verify_rate:.0f}% ์๋ฃ",
+ delta_color="normal" if verify_rate >= 90 else "off",
+ help="LLM 2์ฐจ ๊ฒ์ฆ์ด ์๋ฃ๋ ๊ฑด์",
+ )
+ with pipe4:
+ st.metric(
+ label="2์ฐจ ๊ฒ์ฆ ๋๊ธฐ",
+ value=f"{overview_llm_pending:,}๊ฑด",
+ help="์์ง LLM 2์ฐจ ๊ฒ์ฆ์ด ์ ๋ ๊ฑด์",
+ )
+ with pipe5:
+ confirm_rate = (overview_llm_confirmed / overview_llm_done * 100) if overview_llm_done > 0 else 0
+ st.metric(
+ label="์ต์ข
์ ํ",
+ value=f"{overview_llm_confirmed:,}๊ฑด",
+ delta=f"์ ํ๋ฅ {confirm_rate:.1f}%",
+ help="1์ฐจ + 2์ฐจ ๊ฒ์ฆ ๋ชจ๋์์ ๋ถ์ ์ผ๋ก ํ์ ๋ ๊ฑด์",
+ )
+
+
+def _render_llm_verification_summary(campaign_overview: dict):
+ """LLM 2์ฐจ ๊ฒ์ฆ ์์ฝ."""
+ st.markdown("##### ๐ค LLM 2์ฐจ ๊ฒ์ฆ")
+
+ overview_llm_done = campaign_overview.get("llm_verified_in_house", 0)
+ overview_llm_pending = campaign_overview.get("llm_pending", 0)
+ overview_llm_confirmed = campaign_overview.get("llm_confirmed_negative", 0)
+
+ if overview_llm_done > 0:
+ fp_count = overview_llm_done - overview_llm_confirmed
+ fp_rate = (fp_count / overview_llm_done * 100) if overview_llm_done > 0 else 0
+ st.metric(
+ label="๊ฒ์ฆ ์๋ฃ",
+ value=f"{overview_llm_done}๊ฑด",
+ delta=f"์คํ {fp_count}๊ฑด ({fp_rate:.0f}%)",
+ delta_color="inverse",
+ )
+ st.caption(f"โ
์ ํ: {overview_llm_confirmed}๊ฑด | โ ์คํ: {fp_count}๊ฑด")
+ if overview_llm_pending > 0:
+ st.caption(f"โณ ๋๊ธฐ: {overview_llm_pending}๊ฑด")
+ elif overview_llm_pending > 0:
+ st.info(f"โณ {overview_llm_pending}๊ฑด ๊ฒ์ฆ ๋๊ธฐ ์ค")
+ else:
+ st.info("๊ฒ์ฆ ๋ฐ์ดํฐ ์์")
diff --git a/registry.py b/registry.py
new file mode 100644
index 0000000000000000000000000000000000000000..d65f8371473b32aa3c40e38e1fa9b2040474e03d
--- /dev/null
+++ b/registry.py
@@ -0,0 +1,60 @@
+"""Feature ์๋ ํ์ + ๋ฑ๋ก.
+
+features/ ํ์ ๋๋ ํ ๋ฆฌ๋ฅผ ์ค์บํ์ฌ FEATURE_CONFIG๊ฐ ์๋ ๋ชจ๋์ ์์งํ๊ณ order๋ก ์ ๋ ฌ.
+"""
+
+import importlib
+from pathlib import Path
+
+_REQUIRED_CONFIG_FIELDS = {"name", "icon", "order"}
+
+
+def discover_features() -> list[dict]:
+ """features/ ํ์ ๋๋ ํ ๋ฆฌ๋ฅผ ์ค์บ, FEATURE_CONFIG ์๋ ๋ชจ๋ ์์ง, order ์ ๋ ฌ.
+
+ Returns:
+ List of dicts with 'config' and 'module' keys, sorted by order.
+ """
+ features_dir = Path(__file__).parent / "features"
+ if not features_dir.exists():
+ return []
+
+ results = []
+ for child in sorted(features_dir.iterdir()):
+ if not child.is_dir() or child.name.startswith("_"):
+ continue
+
+ init_file = child / "__init__.py"
+ if not init_file.exists():
+ continue
+
+ try:
+ module = importlib.import_module(f"features.{child.name}")
+ config = getattr(module, "FEATURE_CONFIG", None)
+ if not config or not isinstance(config, dict):
+ continue
+
+ missing = _REQUIRED_CONFIG_FIELDS - set(config.keys())
+ if missing:
+ import streamlit as st
+ st.warning(f"Feature '{child.name}': ํ์ ํ๋ ๋๋ฝ {missing}")
+ continue
+
+ if not hasattr(module, "render") or not callable(module.render):
+ import streamlit as st
+ st.warning(f"Feature '{child.name}': render() ํจ์ ์์")
+ continue
+
+ results.append({
+ "config": config,
+ "module": module,
+ })
+ except Exception as e:
+ import streamlit as st
+ st.warning(
+ f"Feature '{child.name}' ๋ก๋ ์คํจ: "
+ f"{type(e).__name__}: {e}"
+ )
+
+ results.sort(key=lambda x: x["config"].get("order", 99))
+ return results
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..03dd4ece329b4563aa9495b05e74f4f64578ff77
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,7 @@
+streamlit>=1.30.0
+requests>=2.31.0
+plotly>=5.18.0
+pandas>=2.0.0
+python-dotenv>=1.0.0
+supabase>=2.0.0
+openpyxl>=3.1.0
diff --git a/sidebar.py b/sidebar.py
new file mode 100644
index 0000000000000000000000000000000000000000..4f7dcf743350ce37b007432ee2b41c551b9fe569
--- /dev/null
+++ b/sidebar.py
@@ -0,0 +1,109 @@
+"""์ฌ์ด๋๋ฐ ์ค์ ๋ชจ๋.
+
+API Key ์ธ์ฆ + ์บ ํ์ธ ์ ํ UI.
+"""
+import os
+
+import requests
+import streamlit as st
+
+from core.api_client import ChainShiftClient
+
+
+def render_sidebar() -> tuple[dict, int, str] | None:
+ """์ฌ์ด๋๋ฐ ๋ ๋๋ง.
+
+ Returns:
+ (auth_info, campaign_id, campaign_display) ๋๋ None (์ธ์ฆ ์คํจ ์)
+ auth_info = {"api_key": str}
+ """
+ with st.sidebar:
+ st.header("์ค์ ")
+
+ auth_info = _render_api_key_section()
+ if not auth_info:
+ return None
+
+ st.divider()
+
+ # ์บ ํ์ธ ์ ํ
+ result = _render_campaign_selector(auth_info)
+ if not result:
+ return None
+
+ campaign_id, campaign_display = result
+ return auth_info, campaign_id, campaign_display
+
+
+def _render_api_key_section() -> dict | None:
+ """API Key ์ธ์ฆ (env var ์๋ ์ธ์ฆ ๋๋ ์ง์ ์
๋ ฅ)."""
+ env_api_key = os.environ.get("CHAINSHIFT_API_KEY", "")
+
+ api_key = st.text_input(
+ "API Key",
+ type="password",
+ placeholder="sk_live_xxx...",
+ value=env_api_key,
+ help="API Key ์ง์ ์
๋ ฅ (ํ๊ฒฝ๋ณ์ CHAINSHIFT_API_KEY ์ง์)",
+ key="direct_api_key",
+ )
+ if not api_key:
+ st.info("API Key๋ฅผ ์
๋ ฅํ์ธ์")
+ return None
+
+ return {
+ "api_key": api_key,
+ "access_token": None,
+ "email": None,
+ }
+
+
+# =============================================================================
+# Campaign selector
+# =============================================================================
+
+
+def _render_campaign_selector(auth_info: dict) -> tuple[int, str] | None:
+ """์บ ํ์ธ ์ ํ ๋๋กญ๋ค์ด."""
+ try:
+ from core.data_fetchers import get_campaigns
+
+ api_key = auth_info.get("api_key") or ""
+ access_token = auth_info.get("access_token") or ""
+ campaigns_response = get_campaigns(
+ api_key=api_key, access_token=access_token
+ )
+ campaigns = campaigns_response.get("data", {}).get("items", [])
+
+ if not campaigns:
+ st.error("์บ ํ์ธ์ ์ฐพ์ ์ ์์ต๋๋ค")
+ return None
+
+ # Sort campaigns by ID descending (latest first)
+ campaigns_sorted = sorted(campaigns, key=lambda c: c.get("id", 0), reverse=True)
+
+ def format_campaign(c):
+ return f"{c['name']} (#{c['id']})"
+
+ st.markdown("**์บ ํ์ธ**")
+ selected = st.selectbox(
+ "์บ ํ์ธ ์ ํ",
+ options=campaigns_sorted,
+ format_func=format_campaign,
+ label_visibility="collapsed",
+ )
+ selected_campaign_id = selected["id"]
+ selected_campaign_display = format_campaign(selected)
+
+ st.caption(f"Campaign #{selected_campaign_id}")
+ return selected_campaign_id, selected_campaign_display
+
+ except requests.exceptions.ConnectionError:
+ st.error("์๋ฒ ์ฐ๊ฒฐ์ ์คํจํ์ต๋๋ค. ์ ์ ํ ๋ค์ ์๋ํด์ฃผ์ธ์.")
+ return None
+ except requests.exceptions.Timeout:
+ st.error("์๋ฒ ์๋ต ์๊ฐ์ด ์ด๊ณผ๋์์ต๋๋ค. ์ ์ ํ ๋ค์ ์๋ํด์ฃผ์ธ์.")
+ return None
+ except Exception as e:
+ st.error(f"API ์ค๋ฅ: {str(e)}")
+ return None