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