Spaces:
Sleeping
Sleeping
GitHub Action commited on
Commit ยท
ef78361
0
Parent(s):
Sync from GitHub
Browse filesThis view is limited to 50 files because it contains too many changes. ย See raw diff
- .streamlit/config.toml +14 -0
- .streamlit/secrets.toml.example +7 -0
- Dockerfile +9 -0
- README.md +8 -0
- app.py +77 -0
- components/__init__.py +31 -0
- components/cards.py +188 -0
- components/expanders.py +244 -0
- components/metrics.py +175 -0
- core/__init__.py +45 -0
- core/api_client.py +150 -0
- core/api_client_hierarchy.py +102 -0
- core/api_client_reports.py +196 -0
- core/api_client_sentiment.py +279 -0
- core/athena_client.py +331 -0
- core/charts.py +361 -0
- core/data_fetchers.py +71 -0
- core/export_utils.py +216 -0
- core/job_realtime.py +264 -0
- core/styles.py +93 -0
- core/supabase_action_items.py +206 -0
- core/supabase_client.py +76 -0
- core/supabase_research.py +153 -0
- core/supabase_sentiment.py +373 -0
- core/utils.py +143 -0
- features/__init__.py +4 -0
- features/action_items/__init__.py +24 -0
- features/action_items/analysis.py +131 -0
- features/action_items/overview.py +339 -0
- features/action_items/trend_charts.py +329 -0
- features/action_items/triggers.py +158 -0
- features/action_items/utils.py +44 -0
- features/hierarchy/__init__.py +41 -0
- features/hierarchy/monitor.py +208 -0
- features/hierarchy/request.py +221 -0
- features/reports/__init__.py +25 -0
- features/reports/full_report.py +325 -0
- features/reports/overview.py +220 -0
- features/reports/summary.py +31 -0
- features/reports/utils.py +469 -0
- features/research/__init__.py +189 -0
- features/research/content_actions.py +217 -0
- features/research/cross_model.py +652 -0
- features/research/distribution.py +186 -0
- features/research/guide.py +143 -0
- features/research/keyword_suggest.py +195 -0
- features/research/opportunities.py +173 -0
- features/research/summary.py +48 -0
- features/research/topic_map.py +108 -0
- features/research/unified_scoring.py +178 -0
.streamlit/config.toml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[theme]
|
| 2 |
+
primaryColor = "#3B82F6"
|
| 3 |
+
backgroundColor = "#FFFFFF"
|
| 4 |
+
secondaryBackgroundColor = "#F8FAFC"
|
| 5 |
+
textColor = "#1E293B"
|
| 6 |
+
font = "sans serif"
|
| 7 |
+
|
| 8 |
+
[server]
|
| 9 |
+
headless = true
|
| 10 |
+
enableCORS = false
|
| 11 |
+
enableXsrfProtection = true
|
| 12 |
+
|
| 13 |
+
[client]
|
| 14 |
+
showSidebarNavigation = false
|
.streamlit/secrets.toml.example
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Streamlit Cloud Secrets Template
|
| 2 |
+
# Copy this to Streamlit Cloud Dashboard > App Settings > Secrets
|
| 3 |
+
|
| 4 |
+
# Optional: Default API settings
|
| 5 |
+
[api]
|
| 6 |
+
base_url = "https://chainshift-service-api.vercel.app"
|
| 7 |
+
# api_key = "sk_live_xxx" # Optional: pre-fill API key
|
Dockerfile
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY requirements.txt .
|
| 4 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 5 |
+
COPY . .
|
| 6 |
+
EXPOSE 7860
|
| 7 |
+
ENV STREAMLIT_SERVER_PORT=7860
|
| 8 |
+
ENV STREAMLIT_SERVER_ADDRESS=0.0.0.0
|
| 9 |
+
CMD ["streamlit", "run", "app.py"]
|
README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: ChainShift Sentiment Dashboard
|
| 3 |
+
emoji: ๐
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
app.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ChainShift Brand Risk Monitoring Dashboard v10.0.
|
| 2 |
+
|
| 3 |
+
Feature Plugin Architecture: ์ ๊ธฐ๋ฅ = features/ ๋๋ ํ ๋ฆฌ ์์ฑ โ ์๋ ๋ฑ๋ก.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import streamlit as st
|
| 7 |
+
|
| 8 |
+
from core.styles import DASHBOARD_CSS
|
| 9 |
+
from sidebar import render_sidebar
|
| 10 |
+
from registry import discover_features
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# =============================================================================
|
| 14 |
+
# Page Config
|
| 15 |
+
# =============================================================================
|
| 16 |
+
|
| 17 |
+
st.set_page_config(
|
| 18 |
+
page_title="ChainShift Dashboard",
|
| 19 |
+
page_icon="โก",
|
| 20 |
+
layout="wide",
|
| 21 |
+
initial_sidebar_state="expanded",
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
st.markdown(DASHBOARD_CSS, unsafe_allow_html=True)
|
| 25 |
+
|
| 26 |
+
st.title("ChainShift")
|
| 27 |
+
st.caption("AI ๊ฒ์ ํ๋ซํผ์์ ๋ธ๋๋๊ฐ ์ด๋ป๊ฒ ์ธ๊ธ๋๊ณ ์ธ์ฉ๋๋์ง ๋ถ์ํฉ๋๋ค")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# =============================================================================
|
| 31 |
+
# Sidebar (์ธ์ฆ + ์บ ํ์ธ ์ ํ)
|
| 32 |
+
# =============================================================================
|
| 33 |
+
|
| 34 |
+
sidebar_result = render_sidebar()
|
| 35 |
+
if not sidebar_result:
|
| 36 |
+
st.stop()
|
| 37 |
+
|
| 38 |
+
auth_info, selected_campaign_id, selected_campaign_display = sidebar_result
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# =============================================================================
|
| 42 |
+
# Feature Discovery + Rendering
|
| 43 |
+
# =============================================================================
|
| 44 |
+
|
| 45 |
+
features = discover_features()
|
| 46 |
+
|
| 47 |
+
if not features:
|
| 48 |
+
st.error("๋ฑ๋ก๋ Feature๊ฐ ์์ต๋๋ค. features/ ๋๋ ํ ๋ฆฌ๋ฅผ ํ์ธํ์ธ์.")
|
| 49 |
+
st.stop()
|
| 50 |
+
|
| 51 |
+
# Base context shared by all features
|
| 52 |
+
base_ctx = {
|
| 53 |
+
"api_key": auth_info.get("api_key"),
|
| 54 |
+
"access_token": auth_info.get("access_token"),
|
| 55 |
+
"campaign_id": selected_campaign_id,
|
| 56 |
+
"campaign_name": selected_campaign_display,
|
| 57 |
+
"user_email": auth_info.get("email"),
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
# Create tabs from discovered features
|
| 61 |
+
tab_labels = [f"{f['config']['icon']} {f['config']['name']}" for f in features]
|
| 62 |
+
tabs = st.tabs(tab_labels)
|
| 63 |
+
|
| 64 |
+
for tab, feature in zip(tabs, features):
|
| 65 |
+
with tab:
|
| 66 |
+
try:
|
| 67 |
+
feature["module"].render(base_ctx)
|
| 68 |
+
except Exception as e:
|
| 69 |
+
st.error(f"{feature['config']['name']} ๋ก๋ฉ ์คํจ: {e}")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# =============================================================================
|
| 73 |
+
# Footer
|
| 74 |
+
# =============================================================================
|
| 75 |
+
|
| 76 |
+
st.markdown("---")
|
| 77 |
+
st.caption("ChainShift v10.0")
|
components/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dashboard UI components."""
|
| 2 |
+
|
| 3 |
+
from .cards import (
|
| 4 |
+
render_nudge_card,
|
| 5 |
+
render_brand_card,
|
| 6 |
+
render_verification_item,
|
| 7 |
+
render_polarity_item,
|
| 8 |
+
)
|
| 9 |
+
from .expanders import (
|
| 10 |
+
render_nudge_expander,
|
| 11 |
+
render_feedback_section,
|
| 12 |
+
render_llm_verification_section,
|
| 13 |
+
)
|
| 14 |
+
from .metrics import (
|
| 15 |
+
render_kpi_row,
|
| 16 |
+
render_verification_stats,
|
| 17 |
+
render_polarity_stats,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
__all__ = [
|
| 21 |
+
"render_nudge_card",
|
| 22 |
+
"render_brand_card",
|
| 23 |
+
"render_verification_item",
|
| 24 |
+
"render_polarity_item",
|
| 25 |
+
"render_nudge_expander",
|
| 26 |
+
"render_feedback_section",
|
| 27 |
+
"render_llm_verification_section",
|
| 28 |
+
"render_kpi_row",
|
| 29 |
+
"render_verification_stats",
|
| 30 |
+
"render_polarity_stats",
|
| 31 |
+
]
|
components/cards.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dashboard card components."""
|
| 2 |
+
|
| 3 |
+
import html
|
| 4 |
+
import streamlit as st
|
| 5 |
+
|
| 6 |
+
from core.charts import CONFIDENCE_TIER_COLORS
|
| 7 |
+
from core.styles import TIER_BORDER_COLORS
|
| 8 |
+
from core.utils import get_confidence_tier, truncate_text, format_brands_list
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def render_nudge_card(
|
| 12 |
+
item: dict,
|
| 13 |
+
tier: str,
|
| 14 |
+
emoji: str,
|
| 15 |
+
tier_desc: str,
|
| 16 |
+
confidence: float,
|
| 17 |
+
) -> None:
|
| 18 |
+
"""Render nudge candidate card with summary info.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
item: Nudge candidate data dict
|
| 22 |
+
tier: Confidence tier (HIGH/MEDIUM/LOW)
|
| 23 |
+
emoji: Tier emoji
|
| 24 |
+
tier_desc: Tier description
|
| 25 |
+
confidence: Confidence score (0-1)
|
| 26 |
+
"""
|
| 27 |
+
cej_stage = item.get("cej_depth2") or item.get("cej_depth1") or "N/A"
|
| 28 |
+
platform = item.get("platform", "N/A")
|
| 29 |
+
|
| 30 |
+
in_house = item.get("in_house_brands", [])
|
| 31 |
+
mentioned = item.get("mentioned_brands", [])
|
| 32 |
+
|
| 33 |
+
question = item.get("question_content", "")
|
| 34 |
+
answer = item.get("answer_preview", "")
|
| 35 |
+
|
| 36 |
+
tier_color = CONFIDENCE_TIER_COLORS.get(tier, "#6B7280")
|
| 37 |
+
border_color = TIER_BORDER_COLORS.get(tier, "#6B7280")
|
| 38 |
+
|
| 39 |
+
question_display = html.escape(truncate_text(question, 200))
|
| 40 |
+
answer_short = html.escape(truncate_text(answer, 150))
|
| 41 |
+
in_house_display = html.escape(format_brands_list(in_house))
|
| 42 |
+
mentioned_display = html.escape(format_brands_list(mentioned))
|
| 43 |
+
|
| 44 |
+
header_html = f"""
|
| 45 |
+
<div style="border: 2px solid {border_color}; border-radius: 12px; padding: 16px; margin: 12px 0; background: #FAFAFA;">
|
| 46 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
| 47 |
+
<span style="background: #E0E7FF; padding: 4px 12px; border-radius: 20px; font-size: 13px;">
|
| 48 |
+
๐ CEJ: <strong>{cej_stage}</strong>
|
| 49 |
+
</span>
|
| 50 |
+
<span style="background: {tier_color}; color: white; padding: 4px 12px; border-radius: 20px; font-size: 13px;" title="{tier_desc}">
|
| 51 |
+
{emoji} ๋ต๋ณ ์ ์ฒด: {tier} ({confidence:.0%})
|
| 52 |
+
</span>
|
| 53 |
+
</div>
|
| 54 |
+
<div style="background: #F0F9FF; border-left: 4px solid #3B82F6; padding: 10px; margin-bottom: 10px; border-radius: 0 8px 8px 0;">
|
| 55 |
+
<div style="font-size: 11px; color: #3B82F6; margin-bottom: 2px;">๐ฌ ์ง๋ฌธ</div>
|
| 56 |
+
<div style="font-size: 14px;">{question_display}</div>
|
| 57 |
+
</div>
|
| 58 |
+
<div style="font-size: 13px; color: #6B7280; margin-bottom: 8px;">{answer_short}...</div>
|
| 59 |
+
<div style="display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: #6B7280;">
|
| 60 |
+
<span>๐ท๏ธ <strong>{in_house_display}</strong></span>
|
| 61 |
+
<span>๐ข {mentioned_display}</span>
|
| 62 |
+
<span>๐ฅ๏ธ {platform}</span>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
"""
|
| 66 |
+
st.markdown(header_html, unsafe_allow_html=True)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def render_brand_card(
|
| 70 |
+
brand_name: str,
|
| 71 |
+
brand_type: str,
|
| 72 |
+
sentiment_data: dict,
|
| 73 |
+
) -> None:
|
| 74 |
+
"""Render brand mention card.
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
brand_name: Brand name
|
| 78 |
+
brand_type: 'in_house' or 'competitor'
|
| 79 |
+
sentiment_data: Dict with sentiment, confidence, count
|
| 80 |
+
"""
|
| 81 |
+
sentiment = sentiment_data.get("sentiment", "neutral")
|
| 82 |
+
confidence = sentiment_data.get("confidence", 0)
|
| 83 |
+
mention_count = sentiment_data.get("count", 0)
|
| 84 |
+
|
| 85 |
+
type_badge = "๐ ์์ฌ" if brand_type == "in_house" else "๐ข ๊ฒฝ์์ฌ"
|
| 86 |
+
type_bg = "#DBEAFE" if brand_type == "in_house" else "#FEE2E2"
|
| 87 |
+
|
| 88 |
+
sentiment_colors = {
|
| 89 |
+
"positive": "#10B981",
|
| 90 |
+
"negative": "#EF4444",
|
| 91 |
+
"neutral": "#6B7280",
|
| 92 |
+
}
|
| 93 |
+
sent_color = sentiment_colors.get(sentiment, "#6B7280")
|
| 94 |
+
sentiment_ko = {"positive": "๊ธ์ ", "negative": "๋ถ์ ", "neutral": "์ค๋ฆฝ"}.get(sentiment, sentiment)
|
| 95 |
+
|
| 96 |
+
card_html = f"""
|
| 97 |
+
<div style="border: 1px solid #E5E7EB; border-radius: 8px; padding: 12px; margin: 8px 0; background: white;">
|
| 98 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
| 99 |
+
<span style="font-weight: 600; font-size: 15px;">{html.escape(brand_name)}</span>
|
| 100 |
+
<span style="background: {type_bg}; padding: 2px 8px; border-radius: 12px; font-size: 11px;">{type_badge}</span>
|
| 101 |
+
</div>
|
| 102 |
+
<div style="display: flex; gap: 12px; font-size: 12px;">
|
| 103 |
+
<span style="background: {sent_color}; color: white; padding: 2px 8px; border-radius: 4px;">{sentiment_ko}</span>
|
| 104 |
+
<span>์ ๋ขฐ๋: {confidence:.0%}</span>
|
| 105 |
+
<span>์ธ๊ธ: {mention_count}ํ</span>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
"""
|
| 109 |
+
st.markdown(card_html, unsafe_allow_html=True)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def render_verification_item(item: dict, is_false_positive: bool = True) -> None:
|
| 113 |
+
"""Render LLM verification item info (used inside expander).
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
item: Verification result dict
|
| 117 |
+
is_false_positive: True for FP, False for TN
|
| 118 |
+
"""
|
| 119 |
+
st.markdown(f"**์ง๋ฌธ**: {item.get('question_content', 'N/A')}")
|
| 120 |
+
st.markdown(f"**๋ต๋ณ ๋ฏธ๋ฆฌ๋ณด๊ธฐ**: {item.get('answer_preview', 'N/A')}")
|
| 121 |
+
st.markdown("---")
|
| 122 |
+
|
| 123 |
+
info_col1, info_col2, info_col3 = st.columns(3)
|
| 124 |
+
with info_col1:
|
| 125 |
+
st.markdown(f"**ํ๋ซํผ**: {item.get('platform', 'N/A')}")
|
| 126 |
+
st.markdown(f"**CEJ**: {item.get('cej_depth1', 'N/A')} / {item.get('cej_depth2', 'N/A')}")
|
| 127 |
+
with info_col2:
|
| 128 |
+
st.markdown(f"**1์ฐจ ํ์ **: {item.get('routing_tier', 'N/A')}")
|
| 129 |
+
st.markdown(f"**1์ฐจ ๊ฐ์ฑ**: {item.get('overall_polarity', 'N/A')}")
|
| 130 |
+
with info_col3:
|
| 131 |
+
llm_conf = item.get('llm_confidence', 0) or 0
|
| 132 |
+
st.markdown(f"**LLM ์ ๋ขฐ๋**: {llm_conf:.1%}")
|
| 133 |
+
st.markdown(f"**LLM ์กฐ์ Tier**: {item.get('llm_adjusted_tier', 'N/A')}")
|
| 134 |
+
|
| 135 |
+
# LLM reasoning
|
| 136 |
+
if item.get('llm_reasoning'):
|
| 137 |
+
st.markdown("**LLM ํ๋จ ๊ทผ๊ฑฐ**:")
|
| 138 |
+
if is_false_positive:
|
| 139 |
+
st.info(item.get('llm_reasoning'))
|
| 140 |
+
else:
|
| 141 |
+
st.warning(item.get('llm_reasoning'))
|
| 142 |
+
|
| 143 |
+
# Evidence spans
|
| 144 |
+
if item.get('llm_evidence_spans'):
|
| 145 |
+
label = "**๊ทผ๊ฑฐ ๋ฌธ์ฅ**:" if is_false_positive else "**๋ถ์ ๊ทผ๊ฑฐ ๋ฌธ์ฅ**:"
|
| 146 |
+
st.markdown(label)
|
| 147 |
+
for span in (item.get('llm_evidence_spans') or []):
|
| 148 |
+
st.markdown(f"- _{span}_")
|
| 149 |
+
|
| 150 |
+
# Brands
|
| 151 |
+
in_house = item.get('in_house_brands', []) or []
|
| 152 |
+
mentioned = item.get('mentioned_brands', []) or []
|
| 153 |
+
if in_house or mentioned:
|
| 154 |
+
st.markdown(f"**์์ฌ ๋ธ๋๋**: {', '.join(in_house) if in_house else 'N/A'}")
|
| 155 |
+
st.markdown(f"**์ธ๊ธ ๋ธ๋๋**: {', '.join(mentioned) if mentioned else 'N/A'}")
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def render_polarity_item(item: dict) -> None:
|
| 159 |
+
"""Render polarity (sentiment) item info (used inside expander).
|
| 160 |
+
|
| 161 |
+
Args:
|
| 162 |
+
item: Sentiment summary dict
|
| 163 |
+
"""
|
| 164 |
+
confidence = item.get('overall_confidence', 0) or 0
|
| 165 |
+
|
| 166 |
+
st.markdown(f"**์ง๋ฌธ**: {item.get('question_content', 'N/A')}")
|
| 167 |
+
st.markdown(f"**๋ต๋ณ ๋ฏธ๋ฆฌ๋ณด๊ธฐ**: {item.get('answer_preview', 'N/A')}")
|
| 168 |
+
st.markdown("---")
|
| 169 |
+
|
| 170 |
+
info_col1, info_col2, info_col3 = st.columns(3)
|
| 171 |
+
with info_col1:
|
| 172 |
+
st.markdown(f"**๊ฐ์ฑ**: {item.get('overall_polarity', 'N/A')}")
|
| 173 |
+
st.markdown(f"**์ ๋ขฐ๋**: {confidence:.1%}")
|
| 174 |
+
with info_col2:
|
| 175 |
+
st.markdown(f"**ํ๋ซํผ**: {item.get('platform', 'N/A')}")
|
| 176 |
+
st.markdown(f"**CEJ**: {item.get('cej_depth1', 'N/A')} / {item.get('cej_depth2', 'N/A')}")
|
| 177 |
+
with info_col3:
|
| 178 |
+
tier = item.get('routing_tier', 'N/A')
|
| 179 |
+
st.markdown(f"**๋ผ์ฐํ
Tier**: {tier}")
|
| 180 |
+
emotion = item.get('dominant_emotion', 'N/A')
|
| 181 |
+
st.markdown(f"**๊ฐ์ **: {emotion}")
|
| 182 |
+
|
| 183 |
+
# Brands
|
| 184 |
+
in_house = item.get('in_house_brands', []) or []
|
| 185 |
+
mentioned = item.get('mentioned_brands', []) or []
|
| 186 |
+
if in_house or mentioned:
|
| 187 |
+
st.markdown(f"**์์ฌ ๋ธ๋๋**: {', '.join(in_house) if in_house else 'N/A'}")
|
| 188 |
+
st.markdown(f"**์ธ๊ธ ๋ธ๋๋**: {', '.join(mentioned) if mentioned else 'N/A'}")
|
components/expanders.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dashboard expander and section components."""
|
| 2 |
+
|
| 3 |
+
import html
|
| 4 |
+
import streamlit as st
|
| 5 |
+
|
| 6 |
+
from core.charts import EMOTION_KO
|
| 7 |
+
from core.utils import get_confidence_tier, truncate_text
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# Content type labels for citations
|
| 11 |
+
CONTENT_TYPE_LABELS = {
|
| 12 |
+
"EDITORIAL": "๐ฐ ์๋ํ ๋ฆฌ์ผ",
|
| 13 |
+
"TUTORIAL_REVIEW": "๐ ๋ฆฌ๋ทฐ/ํํ ๋ฆฌ์ผ",
|
| 14 |
+
"COMPARISON": "โ๏ธ ๋น๊ต ๋ถ์",
|
| 15 |
+
"RANKED_LIST": "๐ ์์ ๋ชฉ๋ก",
|
| 16 |
+
"FORUM_THREAD": "๐ฌ ํฌ๋ผ/์ปค๋ฎค๋ํฐ",
|
| 17 |
+
"HOMEPAGE": "๐ ํํ์ด์ง",
|
| 18 |
+
"CATALOG": "๐ฆ ์นดํ๋ก๊ทธ",
|
| 19 |
+
"DOCUMENTATION": "๐ ๋ฌธ์",
|
| 20 |
+
"FAQ": "โ FAQ",
|
| 21 |
+
"WHITEPAPER": "๐ ๋ฐฑ์",
|
| 22 |
+
"PRESS_RELEASE": "๐ข ๋ณด๋์๋ฃ",
|
| 23 |
+
"CASE_STUDY": "๐ผ ์ฌ๋ก์ฐ๊ตฌ",
|
| 24 |
+
"PRICING": "๐ฐ ๊ฐ๊ฒฉ์ ๋ณด",
|
| 25 |
+
"DETAIL": "๐ ์์ธํ์ด์ง",
|
| 26 |
+
"DIRECTORY_ENTRY": "๐ ๋๋ ํ ๋ฆฌ",
|
| 27 |
+
"SUBSTITUTE": "๐ ๋์ฒด์ ",
|
| 28 |
+
"OTHERS": "๐ ๊ธฐํ",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def render_citation(cit: dict) -> None:
|
| 33 |
+
"""Render a single citation item.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
cit: Citation dict with source_url, content_type, page_title
|
| 37 |
+
"""
|
| 38 |
+
url = cit.get("source_url", "")
|
| 39 |
+
ctype = cit.get("content_type") or "OTHERS"
|
| 40 |
+
title = cit.get("page_title") or ""
|
| 41 |
+
type_label = CONTENT_TYPE_LABELS.get(ctype, f"๐ {ctype}")
|
| 42 |
+
display_url = url[:50] + "..." if len(url) > 50 else url
|
| 43 |
+
display_title = f' "{title[:30]}..."' if title and len(title) > 30 else f' "{title}"' if title else ""
|
| 44 |
+
st.markdown(
|
| 45 |
+
f'<span style="background: #E0E7FF; color: #3730A3; padding: 2px 6px; '
|
| 46 |
+
f'border-radius: 4px; font-size: 11px; margin-right: 4px;">{type_label}</span> '
|
| 47 |
+
f'<a href="{url}" target="_blank">{display_url}</a>{display_title}',
|
| 48 |
+
unsafe_allow_html=True
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def render_nudge_expander(
|
| 53 |
+
item: dict,
|
| 54 |
+
answer_id: int | None,
|
| 55 |
+
index: int,
|
| 56 |
+
fetch_full_answer_fn,
|
| 57 |
+
fetch_citations_fn,
|
| 58 |
+
) -> None:
|
| 59 |
+
"""Render nudge candidate expander with full details.
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
item: Nudge candidate data dict
|
| 63 |
+
answer_id: Answer ID for Athena fetch
|
| 64 |
+
index: Item index for display
|
| 65 |
+
fetch_full_answer_fn: Function to fetch full answer from Athena
|
| 66 |
+
fetch_citations_fn: Function to fetch citations (Supabase fallback)
|
| 67 |
+
"""
|
| 68 |
+
confidence = item.get("overall_confidence", 0) or 0
|
| 69 |
+
tier, _, _ = get_confidence_tier(confidence)
|
| 70 |
+
emotion = item.get("dominant_emotion", "N/A")
|
| 71 |
+
emotion_ko = EMOTION_KO.get(emotion, emotion) if emotion else "N/A"
|
| 72 |
+
answer = item.get("answer_preview", "")
|
| 73 |
+
brand_detail = item.get("brand_sentiment_detail", {})
|
| 74 |
+
|
| 75 |
+
with st.expander(f"๐ ์์ธ ๋ณด๊ธฐ (๋ต๋ณ #{answer_id or index+1})"):
|
| 76 |
+
# Analysis explanation box
|
| 77 |
+
st.markdown(f"""
|
| 78 |
+
<div style="background: #FFF7ED; border-left: 4px solid #F59E0B; padding: 12px; margin-bottom: 12px; border-radius: 0 8px 8px 0; font-size: 13px;">
|
| 79 |
+
<strong>๐ ๋ถ์ ๊ฒฐ๊ณผ ํด์</strong><br><br>
|
| 80 |
+
<strong>๐ ๋ต๋ณ ์ ์ฒด ๋ถ์ ํ์ ๋: {confidence:.0%} ({tier})</strong><br>
|
| 81 |
+
๋ต๋ณ ์ ์ฒด๊ฐ ๋ถ์ ์ ์ธ ํค์ธ์ง ํ๋จํ ์ ์์
๋๋ค. (์ฌ๋ฌ ๋ธ๋๋๊ฐ ์ธ๊ธ๋๋ฉด ํผํฉ๋จ)<br><br>
|
| 82 |
+
<strong>๐ ๋ธ๋๋๋ณ ๋ถ์ ํ์ ๋</strong> (์๋ ABSA ์ฐธ์กฐ)<br>
|
| 83 |
+
ํน์ ๋ธ๋๋์ ๋ํ ์ธ๊ธ๋ง ์ถ์ถํ์ฌ ๊ทธ ์ธ๊ธ์ด ๋ถ์ ์ ์ธ์ง ํ๋จํ ์ ์์
๋๋ค.<br>
|
| 84 |
+
<em style="color: #9CA3AF;">์: ๋ต๋ณ ์ ์ฒด๋ 64%(LOW)์ฌ๋, ํน์ ๋ธ๋๋ ์ธ๊ธ์ 91%(HIGH)์ผ ์ ์์</em><br><br>
|
| 85 |
+
<strong>๋ต๋ณ ํค: {emotion_ko}</strong><br>
|
| 86 |
+
๋ต๋ณ ์ ์ฒด์ ๊ฐ์ ์ ๋ถ์๊ธฐ์
๋๋ค.
|
| 87 |
+
</div>
|
| 88 |
+
""", unsafe_allow_html=True)
|
| 89 |
+
|
| 90 |
+
# Full answer from Athena
|
| 91 |
+
st.markdown("**๐ค AI ๋ต๋ณ ์ ๋ฌธ**")
|
| 92 |
+
|
| 93 |
+
if answer_id:
|
| 94 |
+
full_answer_key = f"full_answer_{answer_id}"
|
| 95 |
+
load_full_key = f"load_full_{answer_id}"
|
| 96 |
+
if full_answer_key not in st.session_state:
|
| 97 |
+
st.session_state[full_answer_key] = None
|
| 98 |
+
|
| 99 |
+
load_full = st.checkbox(
|
| 100 |
+
"๐ฅ ์ ์ฒด ๋ต๋ณ ๋ถ๋ฌ์ค๊ธฐ",
|
| 101 |
+
key=load_full_key,
|
| 102 |
+
value=st.session_state.get(full_answer_key) is not None
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
if load_full and st.session_state.get(full_answer_key) is None:
|
| 106 |
+
with st.spinner("์ ์ฒด ๋ต๋ณ์ ๊ฐ์ ธ์ค๋ ์ค..."):
|
| 107 |
+
full_content = fetch_full_answer_fn(answer_id)
|
| 108 |
+
if isinstance(full_content, str) and len(full_content) > 0:
|
| 109 |
+
st.session_state[full_answer_key] = full_content
|
| 110 |
+
st.rerun()
|
| 111 |
+
else:
|
| 112 |
+
# Store empty string to prevent infinite re-fetch loop
|
| 113 |
+
st.session_state[full_answer_key] = ""
|
| 114 |
+
|
| 115 |
+
cached = st.session_state.get(full_answer_key)
|
| 116 |
+
display_answer = cached if (isinstance(cached, str) and len(cached) > 0) else answer or "N/A"
|
| 117 |
+
is_full = isinstance(cached, str) and len(cached) > 0
|
| 118 |
+
label = "โ
์ ์ฒด ๋ต๋ณ ๋ก๋๋จ" if is_full else f"๐ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ({len(answer or '')}์)"
|
| 119 |
+
st.caption(label)
|
| 120 |
+
else:
|
| 121 |
+
display_answer = answer or "N/A"
|
| 122 |
+
|
| 123 |
+
st.markdown(
|
| 124 |
+
f'<div style="background: #FEF2F2; padding: 12px; border-radius: 8px; '
|
| 125 |
+
f'font-size: 14px; white-space: pre-wrap; word-break: break-word; '
|
| 126 |
+
f'max-height: 400px; overflow-y: auto;">{html.escape(display_answer)}</div>',
|
| 127 |
+
unsafe_allow_html=True
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
# Brand sentiment detail
|
| 131 |
+
if brand_detail and isinstance(brand_detail, dict):
|
| 132 |
+
st.markdown("**๐ ๋ธ๋๋๋ณ ๊ฐ์ฑ ๋ถ์ (ABSA) - ๋ธ๋๋๋ณ ๋ถ์ ํ์ ๋**")
|
| 133 |
+
_render_brand_absa(brand_detail)
|
| 134 |
+
|
| 135 |
+
# Citations
|
| 136 |
+
st.markdown("**๐ ์ธ์ฉ ์ถ์ฒ (Citation Sources)**")
|
| 137 |
+
_render_citations_section(answer_id, item.get("citation_urls", []), fetch_citations_fn)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _render_brand_absa(brand_detail: dict) -> None:
|
| 141 |
+
"""Render brand ABSA results."""
|
| 142 |
+
in_house_data = brand_detail.get("in_house", {})
|
| 143 |
+
in_house_absa = in_house_data.get("absa_results", [])
|
| 144 |
+
for absa in in_house_absa:
|
| 145 |
+
if isinstance(absa, dict):
|
| 146 |
+
brand_name = absa.get("brand", "Unknown")
|
| 147 |
+
sentiment = absa.get("sentiment", "N/A")
|
| 148 |
+
conf = absa.get("confidence", 0)
|
| 149 |
+
absa_tier, absa_emoji, _ = get_confidence_tier(conf)
|
| 150 |
+
sent_color = "#10B981" if sentiment == "positive" else "#EF4444" if sentiment == "negative" else "#6B7280"
|
| 151 |
+
st.markdown(
|
| 152 |
+
f'<span style="background: {sent_color}; color: white; padding: 2px 8px; '
|
| 153 |
+
f'border-radius: 4px; font-size: 12px; margin-right: 8px;">{sentiment}</span> '
|
| 154 |
+
f'<strong>{brand_name}</strong> (๐ ์์ฌ) - {absa_emoji} ๋ธ๋๋ ํ์ ๋ {conf:.0%} ({absa_tier})',
|
| 155 |
+
unsafe_allow_html=True
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
competitor_data = brand_detail.get("competitor", {})
|
| 159 |
+
competitor_brands = competitor_data.get("brands", [])
|
| 160 |
+
competitor_absa = competitor_data.get("absa_results", [])
|
| 161 |
+
|
| 162 |
+
if competitor_absa:
|
| 163 |
+
for absa in competitor_absa:
|
| 164 |
+
if isinstance(absa, dict):
|
| 165 |
+
brand_name = absa.get("brand", "Unknown")
|
| 166 |
+
sentiment = absa.get("sentiment", "N/A")
|
| 167 |
+
conf = absa.get("confidence", 0)
|
| 168 |
+
absa_tier, absa_emoji, _ = get_confidence_tier(conf)
|
| 169 |
+
sent_color = "#10B981" if sentiment == "positive" else "#EF4444" if sentiment == "negative" else "#6B7280"
|
| 170 |
+
st.markdown(
|
| 171 |
+
f'<span style="background: {sent_color}; color: white; padding: 2px 8px; '
|
| 172 |
+
f'border-radius: 4px; font-size: 12px; margin-right: 8px;">{sentiment}</span> '
|
| 173 |
+
f'<strong>{brand_name}</strong> (๐ข ๊ฒฝ์์ฌ) - {absa_emoji} ๋ธ๋๋ ํ์ ๋ {conf:.0%} ({absa_tier})',
|
| 174 |
+
unsafe_allow_html=True
|
| 175 |
+
)
|
| 176 |
+
elif competitor_brands:
|
| 177 |
+
st.markdown(
|
| 178 |
+
f'<span style="background: #6B7280; color: white; padding: 2px 8px; '
|
| 179 |
+
f'border-radius: 4px; font-size: 12px;">์ธ๊ธ๋จ</span> '
|
| 180 |
+
f'<strong>{", ".join(competitor_brands)}</strong> (๐ข ๊ฒฝ์์ฌ)',
|
| 181 |
+
unsafe_allow_html=True
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _render_citations_section(answer_id: int | None, citation_urls: list, fetch_citations_fn) -> None:
|
| 186 |
+
"""Render citations section."""
|
| 187 |
+
citations_key = f"citations_{answer_id}"
|
| 188 |
+
if citations_key not in st.session_state:
|
| 189 |
+
st.session_state[citations_key] = None
|
| 190 |
+
|
| 191 |
+
if st.session_state.get(citations_key) is None and answer_id:
|
| 192 |
+
citations = fetch_citations_fn(answer_id)
|
| 193 |
+
st.session_state[citations_key] = citations if citations else []
|
| 194 |
+
|
| 195 |
+
citations = st.session_state.get(citations_key, [])
|
| 196 |
+
if citations:
|
| 197 |
+
if len(citations) <= 5:
|
| 198 |
+
for cit in citations:
|
| 199 |
+
render_citation(cit)
|
| 200 |
+
else:
|
| 201 |
+
for cit in citations[:5]:
|
| 202 |
+
render_citation(cit)
|
| 203 |
+
with st.expander(f"๐ ๋๋จธ์ง {len(citations) - 5}๊ฐ ๋ ๋ณด๊ธฐ"):
|
| 204 |
+
for cit in citations[5:]:
|
| 205 |
+
render_citation(cit)
|
| 206 |
+
elif citation_urls:
|
| 207 |
+
if len(citation_urls) <= 5:
|
| 208 |
+
for url in citation_urls:
|
| 209 |
+
st.markdown(f"โข [{url[:60]}...]({url})" if len(url) > 60 else f"โข [{url}]({url})")
|
| 210 |
+
else:
|
| 211 |
+
for url in citation_urls[:5]:
|
| 212 |
+
st.markdown(f"โข [{url[:60]}...]({url})" if len(url) > 60 else f"โข [{url}]({url})")
|
| 213 |
+
with st.expander(f"๐ ๋๋จธ์ง {len(citation_urls) - 5}๊ฐ ๋ ๋ณด๊ธฐ"):
|
| 214 |
+
for url in citation_urls[5:]:
|
| 215 |
+
st.markdown(f"โข [{url[:60]}...]({url})" if len(url) > 60 else f"โข [{url}]({url})")
|
| 216 |
+
else:
|
| 217 |
+
st.caption("์ธ์ฉ ์์ค ์์")
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def render_feedback_section(feedback_stats: dict) -> None:
|
| 221 |
+
"""Render feedback statistics expander section.
|
| 222 |
+
|
| 223 |
+
Args:
|
| 224 |
+
feedback_stats: Dict with feedback counts and accuracy
|
| 225 |
+
"""
|
| 226 |
+
from .metrics import render_feedback_stats
|
| 227 |
+
|
| 228 |
+
fb_total = feedback_stats.get("total_feedback", 0)
|
| 229 |
+
if fb_total > 0:
|
| 230 |
+
with st.expander("๐ **ํผ๋๋ฐฑ ๋ถ์** - ์ฌ์ฉ์ ๊ฒ์ฆ ํํฉ", expanded=False):
|
| 231 |
+
render_feedback_stats(feedback_stats)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def render_llm_verification_section(item: dict, is_false_positive: bool = True) -> None:
|
| 235 |
+
"""Render LLM verification item section (used inside expander).
|
| 236 |
+
|
| 237 |
+
This is a wrapper that calls render_verification_item from cards module.
|
| 238 |
+
|
| 239 |
+
Args:
|
| 240 |
+
item: Verification result dict
|
| 241 |
+
is_false_positive: True for FP, False for TN
|
| 242 |
+
"""
|
| 243 |
+
from .cards import render_verification_item
|
| 244 |
+
render_verification_item(item, is_false_positive)
|
components/metrics.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dashboard metrics and KPI components."""
|
| 2 |
+
|
| 3 |
+
import streamlit as st
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def render_kpi_row(
|
| 7 |
+
total_nudge: int,
|
| 8 |
+
high_count: int,
|
| 9 |
+
medium_count: int,
|
| 10 |
+
risk_score: float,
|
| 11 |
+
citation_total: int,
|
| 12 |
+
) -> None:
|
| 13 |
+
"""Render key metrics row with 5 KPIs.
|
| 14 |
+
|
| 15 |
+
Args:
|
| 16 |
+
total_nudge: Total negative mentions
|
| 17 |
+
high_count: HIGH tier count
|
| 18 |
+
medium_count: MEDIUM tier count
|
| 19 |
+
risk_score: Calculated risk score
|
| 20 |
+
citation_total: Total citation sources
|
| 21 |
+
"""
|
| 22 |
+
kpi1, kpi2, kpi3, kpi4, kpi5 = st.columns(5)
|
| 23 |
+
|
| 24 |
+
with kpi1:
|
| 25 |
+
st.metric(
|
| 26 |
+
label="์ด ๋ถ์ ์ธ๊ธ",
|
| 27 |
+
value=f"{total_nudge}๊ฑด",
|
| 28 |
+
help="AI๊ฐ ์์ฌ ๋ธ๋๋๋ฅผ ๋ถ์ ์ ์ผ๋ก ์ธ๊ธํ ๋ต๋ณ ์",
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
with kpi2:
|
| 32 |
+
st.metric(
|
| 33 |
+
label="๐ด HIGH (์ฆ์ ๋์)",
|
| 34 |
+
value=f"{high_count}๊ฑด",
|
| 35 |
+
help="โฅ85% ํ์ ๋ - ์ฆ์ ๋์ ๊ถ์ฅ",
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
with kpi3:
|
| 39 |
+
st.metric(
|
| 40 |
+
label="๐ก MEDIUM (๊ฒํ )",
|
| 41 |
+
value=f"{medium_count}๊ฑด",
|
| 42 |
+
help="70-85% ํ์ ๋ - ๊ฒํ ํ์",
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
with kpi4:
|
| 46 |
+
st.metric(
|
| 47 |
+
label="๋ฆฌ์คํฌ ์ ์",
|
| 48 |
+
value=f"{risk_score:.1f}",
|
| 49 |
+
help="HIGH=100%, MEDIUM=50%, LOW=20% ๊ฐ์ค ํ๊ท ",
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
with kpi5:
|
| 53 |
+
st.metric(
|
| 54 |
+
label="์ด ์ธ์ฉ ์์ค",
|
| 55 |
+
value=f"{citation_total}๊ฐ",
|
| 56 |
+
help="AI ๋ต๋ณ์์ ์ธ์ฉ๋ ์ด ์์ค ์",
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def render_verification_stats(
|
| 61 |
+
total_verified: int,
|
| 62 |
+
false_positives_count: int,
|
| 63 |
+
true_negatives_count: int,
|
| 64 |
+
) -> None:
|
| 65 |
+
"""Render LLM verification statistics row.
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
total_verified: Total verified items
|
| 69 |
+
false_positives_count: False positive count
|
| 70 |
+
true_negatives_count: True negative count
|
| 71 |
+
"""
|
| 72 |
+
stat_col1, stat_col2, stat_col3, stat_col4 = st.columns(4)
|
| 73 |
+
|
| 74 |
+
with stat_col1:
|
| 75 |
+
st.metric("๊ฒ์ฆ ์๋ฃ", f"{total_verified}๊ฑด")
|
| 76 |
+
|
| 77 |
+
with stat_col2:
|
| 78 |
+
fp_rate = (false_positives_count / total_verified * 100) if total_verified > 0 else 0
|
| 79 |
+
st.metric("์คํ (False Positive)", f"{false_positives_count}๊ฑด", f"{fp_rate:.1f}%")
|
| 80 |
+
|
| 81 |
+
with stat_col3:
|
| 82 |
+
tn_rate = (true_negatives_count / total_verified * 100) if total_verified > 0 else 0
|
| 83 |
+
st.metric("์ง์์ฑ (True Negative)", f"{true_negatives_count}๊ฑด", f"{tn_rate:.1f}%")
|
| 84 |
+
|
| 85 |
+
with stat_col4:
|
| 86 |
+
if total_verified > 0:
|
| 87 |
+
st.metric("์คํ๋ฅ ", f"{fp_rate:.1f}%", delta=None)
|
| 88 |
+
else:
|
| 89 |
+
st.metric("์คํ๋ฅ ", "N/A")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def render_polarity_stats(
|
| 93 |
+
positive_count: int,
|
| 94 |
+
neutral_count: int,
|
| 95 |
+
negative_count: int,
|
| 96 |
+
) -> None:
|
| 97 |
+
"""Render polarity distribution statistics.
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
positive_count: Positive sentiment count
|
| 101 |
+
neutral_count: Neutral sentiment count
|
| 102 |
+
negative_count: Negative sentiment count
|
| 103 |
+
"""
|
| 104 |
+
total_answers = positive_count + neutral_count + negative_count
|
| 105 |
+
|
| 106 |
+
pol_col1, pol_col2, pol_col3, pol_col4 = st.columns(4)
|
| 107 |
+
|
| 108 |
+
with pol_col1:
|
| 109 |
+
st.metric("์ ์ฒด ๋ถ์", f"{total_answers:,}๊ฑด")
|
| 110 |
+
|
| 111 |
+
with pol_col2:
|
| 112 |
+
pos_rate = (positive_count / total_answers * 100) if total_answers > 0 else 0
|
| 113 |
+
st.metric("๐ ๊ธ์ ", f"{positive_count:,}๊ฑด", f"{pos_rate:.1f}%")
|
| 114 |
+
|
| 115 |
+
with pol_col3:
|
| 116 |
+
neu_rate = (neutral_count / total_answers * 100) if total_answers > 0 else 0
|
| 117 |
+
st.metric("๐ ์ค๋ฆฝ", f"{neutral_count:,}๊ฑด", f"{neu_rate:.1f}%")
|
| 118 |
+
|
| 119 |
+
with pol_col4:
|
| 120 |
+
neg_rate = (negative_count / total_answers * 100) if total_answers > 0 else 0
|
| 121 |
+
st.metric("๐ ๋ถ์ ", f"{negative_count:,}๊ฑด", f"{neg_rate:.1f}%")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def render_feedback_stats(feedback_stats: dict) -> None:
|
| 125 |
+
"""Render feedback statistics row.
|
| 126 |
+
|
| 127 |
+
Args:
|
| 128 |
+
feedback_stats: Dict with feedback counts and accuracy
|
| 129 |
+
"""
|
| 130 |
+
fb_total = feedback_stats.get("total_feedback", 0)
|
| 131 |
+
fb_correct = feedback_stats.get("correct_count", 0)
|
| 132 |
+
fb_wrong = feedback_stats.get("wrong_count", 0)
|
| 133 |
+
fb_ambiguous = feedback_stats.get("ambiguous_count", 0)
|
| 134 |
+
accuracy = feedback_stats.get("accuracy_rate", 0)
|
| 135 |
+
|
| 136 |
+
fb_col1, fb_col2, fb_col3, fb_col4, fb_col5 = st.columns(5)
|
| 137 |
+
|
| 138 |
+
with fb_col1:
|
| 139 |
+
st.metric(
|
| 140 |
+
label="์ด ํผ๋๋ฐฑ",
|
| 141 |
+
value=f"{fb_total}๊ฑด",
|
| 142 |
+
help="์ฌ์ฉ์๊ฐ ์ ์ถํ ์ด ํผ๋๋ฐฑ ์",
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
with fb_col2:
|
| 146 |
+
st.metric(
|
| 147 |
+
label="๐ ์ ํ",
|
| 148 |
+
value=f"{fb_correct}๊ฑด",
|
| 149 |
+
delta=f"{fb_correct/fb_total*100:.0f}%" if fb_total > 0 else None,
|
| 150 |
+
delta_color="normal",
|
| 151 |
+
help="์ ํํ๋ค๊ณ ํ๊ฐ๋ ๋ถ์ ์",
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
with fb_col3:
|
| 155 |
+
st.metric(
|
| 156 |
+
label="๐ ์ค๋ฅ",
|
| 157 |
+
value=f"{fb_wrong}๊ฑด",
|
| 158 |
+
delta=f"{fb_wrong/fb_total*100:.0f}%" if fb_total > 0 else None,
|
| 159 |
+
delta_color="inverse",
|
| 160 |
+
help="ํ๋ ธ๋ค๊ณ ํ๊ฐ๋ ๋ถ์ ์",
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
with fb_col4:
|
| 164 |
+
st.metric(
|
| 165 |
+
label="๐ค ์ ๋งค",
|
| 166 |
+
value=f"{fb_ambiguous}๊ฑด",
|
| 167 |
+
help="ํ๋จํ๊ธฐ ์ด๋ ค์ด ๊ฒฝ์ฐ",
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
with fb_col5:
|
| 171 |
+
st.metric(
|
| 172 |
+
label="์ ํ๋",
|
| 173 |
+
value=f"{accuracy:.1f}%",
|
| 174 |
+
help="์ ํ / (์ ํ + ์ค๋ฅ) ๋น์จ",
|
| 175 |
+
)
|
core/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core infrastructure modules.
|
| 2 |
+
|
| 3 |
+
Re-exports for backward compatibility and convenience.
|
| 4 |
+
"""
|
| 5 |
+
from .api_client import ChainShiftClient
|
| 6 |
+
from .supabase_client import (
|
| 7 |
+
get_campaign_overview,
|
| 8 |
+
get_campaign_date_range,
|
| 9 |
+
get_false_positives,
|
| 10 |
+
get_llm_verification_stats,
|
| 11 |
+
get_polarity_stats,
|
| 12 |
+
get_answers_by_polarity,
|
| 13 |
+
get_topic_clusters,
|
| 14 |
+
get_topic_map_snapshot,
|
| 15 |
+
get_true_negatives,
|
| 16 |
+
)
|
| 17 |
+
from .athena_client import fetch_full_answer
|
| 18 |
+
from .data_fetchers import get_campaigns
|
| 19 |
+
from .utils import (
|
| 20 |
+
format_brands_list,
|
| 21 |
+
get_confidence_tier,
|
| 22 |
+
get_feedback_reason_label,
|
| 23 |
+
get_feedback_type_emoji,
|
| 24 |
+
get_llm_tier_badge,
|
| 25 |
+
highlight_evidence_spans,
|
| 26 |
+
truncate_text,
|
| 27 |
+
)
|
| 28 |
+
from .charts import (
|
| 29 |
+
CONFIDENCE_TIER_COLORS,
|
| 30 |
+
EMOTION_KO,
|
| 31 |
+
create_brand_sentiment_chart,
|
| 32 |
+
create_confidence_tier_pie_chart,
|
| 33 |
+
create_domain_bar_chart,
|
| 34 |
+
create_nudge_by_cej_bar_chart,
|
| 35 |
+
create_platform_bar_chart,
|
| 36 |
+
)
|
| 37 |
+
from .export_utils import render_export_component
|
| 38 |
+
from .job_realtime import (
|
| 39 |
+
get_active_jobs,
|
| 40 |
+
get_recent_jobs,
|
| 41 |
+
format_job_duration,
|
| 42 |
+
get_status_emoji,
|
| 43 |
+
get_status_label,
|
| 44 |
+
)
|
| 45 |
+
from .styles import DASHBOARD_CSS, TIER_BORDER_COLORS
|
core/api_client.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ChainShift Gen3 API Client - Nudge Detection Focus.
|
| 2 |
+
|
| 3 |
+
Domain methods are in mixin files:
|
| 4 |
+
- api_client_sentiment.py: SentimentApiMixin (Gen3, Verification, Keyword)
|
| 5 |
+
- api_client_reports.py: ReportsApiMixin (Reports, Action Items)
|
| 6 |
+
- api_client_hierarchy.py: HierarchyApiMixin (Analysis Jobs, Hierarchy)
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
from typing import Any
|
| 11 |
+
from urllib.parse import urlparse
|
| 12 |
+
|
| 13 |
+
import requests
|
| 14 |
+
from dotenv import load_dotenv
|
| 15 |
+
from requests.adapters import HTTPAdapter
|
| 16 |
+
from urllib3.util.retry import Retry
|
| 17 |
+
|
| 18 |
+
from core.api_client_sentiment import SentimentApiMixin
|
| 19 |
+
from core.api_client_reports import ReportsApiMixin
|
| 20 |
+
from core.api_client_hierarchy import HierarchyApiMixin
|
| 21 |
+
|
| 22 |
+
load_dotenv()
|
| 23 |
+
|
| 24 |
+
BASE_URL = os.getenv(
|
| 25 |
+
"CHAINSHIFT_API_URL",
|
| 26 |
+
"https://chainshift-service-api.vercel.app"
|
| 27 |
+
)
|
| 28 |
+
API_KEY = os.getenv("CHAINSHIFT_API_KEY", "")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _create_session() -> requests.Session:
|
| 32 |
+
"""Create requests session with retry for transient errors."""
|
| 33 |
+
session = requests.Session()
|
| 34 |
+
retry = Retry(
|
| 35 |
+
total=3,
|
| 36 |
+
backoff_factor=0.5,
|
| 37 |
+
status_forcelist=[502, 503, 504],
|
| 38 |
+
)
|
| 39 |
+
session.mount("https://", HTTPAdapter(max_retries=retry))
|
| 40 |
+
session.mount("http://", HTTPAdapter(max_retries=retry))
|
| 41 |
+
return session
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class ChainShiftClient(SentimentApiMixin, ReportsApiMixin, HierarchyApiMixin):
|
| 45 |
+
"""Gen3 API client for ChainShift Nudge Detection."""
|
| 46 |
+
|
| 47 |
+
def __init__(
|
| 48 |
+
self,
|
| 49 |
+
api_key: str | None = None,
|
| 50 |
+
access_token: str | None = None,
|
| 51 |
+
base_url: str | None = None,
|
| 52 |
+
):
|
| 53 |
+
self.api_key = api_key or API_KEY
|
| 54 |
+
self.base_url = base_url or BASE_URL
|
| 55 |
+
self.headers = {"X-API-Key": self.api_key} if self.api_key else {}
|
| 56 |
+
self._session = _create_session()
|
| 57 |
+
|
| 58 |
+
def set_api_key(self, api_key: str):
|
| 59 |
+
"""Set or update API key."""
|
| 60 |
+
self.api_key = api_key
|
| 61 |
+
self.headers = {"X-API-Key": self.api_key}
|
| 62 |
+
|
| 63 |
+
def _get(self, endpoint: str, params: dict | None = None) -> dict[str, Any]:
|
| 64 |
+
"""Make GET request to API."""
|
| 65 |
+
url = f"{self.base_url}{endpoint}"
|
| 66 |
+
response = self._session.get(url, headers=self.headers, params=params, timeout=120)
|
| 67 |
+
response.raise_for_status()
|
| 68 |
+
return response.json()
|
| 69 |
+
|
| 70 |
+
def _post(self, endpoint: str, data: dict | None = None) -> dict[str, Any]:
|
| 71 |
+
"""Make POST request to API."""
|
| 72 |
+
url = f"{self.base_url}{endpoint}"
|
| 73 |
+
response = self._session.post(url, headers=self.headers, json=data, timeout=300)
|
| 74 |
+
response.raise_for_status()
|
| 75 |
+
return response.json()
|
| 76 |
+
|
| 77 |
+
def _patch(self, endpoint: str, data: dict | None = None) -> dict[str, Any]:
|
| 78 |
+
"""Make PATCH request to API."""
|
| 79 |
+
url = f"{self.base_url}{endpoint}"
|
| 80 |
+
response = self._session.patch(url, headers=self.headers, json=data, timeout=120)
|
| 81 |
+
response.raise_for_status()
|
| 82 |
+
return response.json()
|
| 83 |
+
|
| 84 |
+
def _delete(self, endpoint: str) -> dict[str, Any]:
|
| 85 |
+
"""Make DELETE request to API."""
|
| 86 |
+
url = f"{self.base_url}{endpoint}"
|
| 87 |
+
response = self._session.delete(url, headers=self.headers, timeout=120)
|
| 88 |
+
response.raise_for_status()
|
| 89 |
+
return response.json()
|
| 90 |
+
|
| 91 |
+
# ========================================================================
|
| 92 |
+
# Campaign APIs
|
| 93 |
+
# ========================================================================
|
| 94 |
+
|
| 95 |
+
def get_campaigns(self, page: int = 1, page_size: int = 100) -> dict:
|
| 96 |
+
"""Get list of campaigns."""
|
| 97 |
+
return self._get("/api/v1/campaigns", {"page": page, "page_size": page_size})
|
| 98 |
+
|
| 99 |
+
def get_campaign(self, campaign_id: int) -> dict:
|
| 100 |
+
"""Get campaign details."""
|
| 101 |
+
return self._get(f"/api/v1/campaigns/{campaign_id}")
|
| 102 |
+
|
| 103 |
+
def get_campaign_brands(self, campaign_id: int) -> list[dict]:
|
| 104 |
+
"""Get brands for a campaign."""
|
| 105 |
+
resp = self._get(f"/api/v1/campaigns/{campaign_id}/brands")
|
| 106 |
+
return (resp or {}).get("data") or []
|
| 107 |
+
|
| 108 |
+
# ========================================================================
|
| 109 |
+
# Utility Methods
|
| 110 |
+
# ========================================================================
|
| 111 |
+
|
| 112 |
+
@staticmethod
|
| 113 |
+
def extract_domain(url: str) -> str:
|
| 114 |
+
"""Extract domain from URL."""
|
| 115 |
+
try:
|
| 116 |
+
parsed = urlparse(url)
|
| 117 |
+
return parsed.netloc or url
|
| 118 |
+
except Exception:
|
| 119 |
+
return url
|
| 120 |
+
|
| 121 |
+
@staticmethod
|
| 122 |
+
def aggregate_citation_domains(candidates: list[dict]) -> dict[str, int]:
|
| 123 |
+
"""Aggregate citation URLs by domain.
|
| 124 |
+
|
| 125 |
+
Returns: {domain: count}
|
| 126 |
+
"""
|
| 127 |
+
domain_counts: dict[str, int] = {}
|
| 128 |
+
for candidate in candidates:
|
| 129 |
+
urls = candidate.get("citation_urls", []) or []
|
| 130 |
+
for url in urls:
|
| 131 |
+
domain = ChainShiftClient.extract_domain(url)
|
| 132 |
+
if domain:
|
| 133 |
+
domain_counts[domain] = domain_counts.get(domain, 0) + 1
|
| 134 |
+
return dict(sorted(domain_counts.items(), key=lambda x: x[1], reverse=True))
|
| 135 |
+
|
| 136 |
+
@staticmethod
|
| 137 |
+
def calculate_risk_score(tier_stats: dict) -> float:
|
| 138 |
+
"""Calculate risk score (0-100) based on confidence tiers.
|
| 139 |
+
|
| 140 |
+
Formula: (HIGH * 1.0 + MEDIUM * 0.5 + LOW * 0.2) / total * 100
|
| 141 |
+
"""
|
| 142 |
+
high = tier_stats.get("HIGH", 0)
|
| 143 |
+
medium = tier_stats.get("MEDIUM", 0)
|
| 144 |
+
low = tier_stats.get("LOW", 0)
|
| 145 |
+
total = high + medium + low
|
| 146 |
+
if total == 0:
|
| 147 |
+
return 0.0
|
| 148 |
+
weighted = high * 1.0 + medium * 0.5 + low * 0.2
|
| 149 |
+
return min(100.0, (weighted / total) * 100)
|
| 150 |
+
|
core/api_client_hierarchy.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hierarchy & Analysis Job API methods for ChainShiftClient.
|
| 2 |
+
|
| 3 |
+
Mixin class extracted from api_client.py.
|
| 4 |
+
Methods access self._get, self._post from the parent class.
|
| 5 |
+
"""
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class HierarchyApiMixin:
|
| 10 |
+
"""Hierarchy Analysis and Analysis Job APIs."""
|
| 11 |
+
|
| 12 |
+
# ========================================================================
|
| 13 |
+
# Analysis Job APIs
|
| 14 |
+
# ========================================================================
|
| 15 |
+
|
| 16 |
+
def start_analysis_job(self, campaign_id: int, options: dict | None = None) -> dict:
|
| 17 |
+
"""Start a sentiment analysis job. POST /api/v1/sentiment/campaigns/{campaign_id}/run"""
|
| 18 |
+
body: dict = {}
|
| 19 |
+
if options:
|
| 20 |
+
body["options"] = options
|
| 21 |
+
return self._post(
|
| 22 |
+
f"/api/v1/sentiment/campaigns/{campaign_id}/run",
|
| 23 |
+
body,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
def list_analysis_jobs(
|
| 27 |
+
self,
|
| 28 |
+
campaign_id: int | None = None,
|
| 29 |
+
status: str | None = None,
|
| 30 |
+
page: int = 1,
|
| 31 |
+
page_size: int = 20,
|
| 32 |
+
) -> dict:
|
| 33 |
+
"""List analysis jobs. GET /api/v1/sentiment/jobs"""
|
| 34 |
+
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
| 35 |
+
if campaign_id is not None:
|
| 36 |
+
params["campaign_id"] = campaign_id
|
| 37 |
+
if status:
|
| 38 |
+
params["status"] = status
|
| 39 |
+
return self._get("/api/v1/sentiment/jobs", params)
|
| 40 |
+
|
| 41 |
+
def get_analysis_job(self, job_id: str) -> dict:
|
| 42 |
+
"""Get analysis job details. GET /api/v1/sentiment/jobs/{job_id}"""
|
| 43 |
+
return self._get(f"/api/v1/sentiment/jobs/{job_id}")
|
| 44 |
+
|
| 45 |
+
def cancel_analysis_job(self, job_id: str) -> dict:
|
| 46 |
+
"""Cancel a queued or running analysis job. POST /api/v1/sentiment/jobs/{job_id}/cancel"""
|
| 47 |
+
return self._post(f"/api/v1/sentiment/jobs/{job_id}/cancel")
|
| 48 |
+
|
| 49 |
+
# ========================================================================
|
| 50 |
+
# Hierarchy Analysis APIs
|
| 51 |
+
# ========================================================================
|
| 52 |
+
|
| 53 |
+
def create_hierarchy_job(
|
| 54 |
+
self,
|
| 55 |
+
prompt: str,
|
| 56 |
+
title: str | None = None,
|
| 57 |
+
processor_config: dict | None = None,
|
| 58 |
+
settings: dict | None = None,
|
| 59 |
+
) -> dict:
|
| 60 |
+
"""Create a hierarchy analysis job. POST /api/v1/hierarchy/jobs"""
|
| 61 |
+
body: dict = {"prompt": prompt, "processor_config": processor_config or {}}
|
| 62 |
+
if title:
|
| 63 |
+
body["title"] = title
|
| 64 |
+
if settings:
|
| 65 |
+
body["settings"] = settings
|
| 66 |
+
return self._post("/api/v1/hierarchy/jobs", body)
|
| 67 |
+
|
| 68 |
+
def list_hierarchy_jobs(
|
| 69 |
+
self,
|
| 70 |
+
page: int = 1,
|
| 71 |
+
page_size: int = 20,
|
| 72 |
+
status: str | None = None,
|
| 73 |
+
) -> dict:
|
| 74 |
+
"""List hierarchy analysis jobs. GET /api/v1/hierarchy/jobs"""
|
| 75 |
+
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
| 76 |
+
if status:
|
| 77 |
+
params["status"] = status
|
| 78 |
+
return self._get("/api/v1/hierarchy/jobs", params)
|
| 79 |
+
|
| 80 |
+
def get_hierarchy_job(self, job_id: str) -> dict:
|
| 81 |
+
"""Get hierarchy job details. GET /api/v1/hierarchy/jobs/{job_id}"""
|
| 82 |
+
return self._get(f"/api/v1/hierarchy/jobs/{job_id}")
|
| 83 |
+
|
| 84 |
+
def get_hierarchy_questions(
|
| 85 |
+
self,
|
| 86 |
+
job_id: str,
|
| 87 |
+
page: int = 1,
|
| 88 |
+
page_size: int = 50,
|
| 89 |
+
journey_depth1: str | None = None,
|
| 90 |
+
export: str | None = None,
|
| 91 |
+
) -> dict:
|
| 92 |
+
"""Get questions for a hierarchy job. GET /api/v1/hierarchy/jobs/{job_id}/questions"""
|
| 93 |
+
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
| 94 |
+
if journey_depth1:
|
| 95 |
+
params["journey_depth1"] = journey_depth1
|
| 96 |
+
if export:
|
| 97 |
+
params["export"] = export
|
| 98 |
+
return self._get(f"/api/v1/hierarchy/jobs/{job_id}/questions", params)
|
| 99 |
+
|
| 100 |
+
def get_hierarchy_question_stats(self, job_id: str) -> dict:
|
| 101 |
+
"""Get question statistics. GET /api/v1/hierarchy/jobs/{job_id}/questions/stats"""
|
| 102 |
+
return self._get(f"/api/v1/hierarchy/jobs/{job_id}/questions/stats")
|
core/api_client_reports.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reports & Action Items API methods for ChainShiftClient.
|
| 2 |
+
|
| 3 |
+
Mixin class extracted from api_client.py.
|
| 4 |
+
Methods access self._get, self._post, self._patch, self._delete, self.base_url from the parent class.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ReportsApiMixin:
|
| 9 |
+
"""Reports (HTML, data) and Action Items APIs."""
|
| 10 |
+
|
| 11 |
+
# ========================================================================
|
| 12 |
+
# Reports API (HTML Report Generation)
|
| 13 |
+
# ========================================================================
|
| 14 |
+
|
| 15 |
+
def get_report_overview(
|
| 16 |
+
self, campaign_id: int, start_date: str | None = None, end_date: str | None = None,
|
| 17 |
+
) -> dict:
|
| 18 |
+
"""GET /api/v1/reports/{campaign_id}/overview"""
|
| 19 |
+
params = {}
|
| 20 |
+
if start_date:
|
| 21 |
+
params["start_date"] = start_date
|
| 22 |
+
if end_date:
|
| 23 |
+
params["end_date"] = end_date
|
| 24 |
+
return self._get(f"/api/v1/reports/{campaign_id}/overview", params or None)
|
| 25 |
+
|
| 26 |
+
def get_report_visibility(
|
| 27 |
+
self, campaign_id: int, start_date: str | None = None, end_date: str | None = None,
|
| 28 |
+
) -> dict:
|
| 29 |
+
"""GET /api/v1/reports/{campaign_id}/visibility"""
|
| 30 |
+
params = {}
|
| 31 |
+
if start_date:
|
| 32 |
+
params["start_date"] = start_date
|
| 33 |
+
if end_date:
|
| 34 |
+
params["end_date"] = end_date
|
| 35 |
+
return self._get(f"/api/v1/reports/{campaign_id}/visibility", params or None)
|
| 36 |
+
|
| 37 |
+
def get_report_citations(
|
| 38 |
+
self, campaign_id: int, start_date: str | None = None,
|
| 39 |
+
end_date: str | None = None, limit: int = 30,
|
| 40 |
+
) -> dict:
|
| 41 |
+
"""GET /api/v1/reports/{campaign_id}/citations"""
|
| 42 |
+
params: dict = {"limit": limit}
|
| 43 |
+
if start_date:
|
| 44 |
+
params["start_date"] = start_date
|
| 45 |
+
if end_date:
|
| 46 |
+
params["end_date"] = end_date
|
| 47 |
+
return self._get(f"/api/v1/reports/{campaign_id}/citations", params)
|
| 48 |
+
|
| 49 |
+
def get_report_citation_trends(
|
| 50 |
+
self, campaign_id: int, start_date: str | None = None,
|
| 51 |
+
end_date: str | None = None, limit: int = 20,
|
| 52 |
+
) -> dict:
|
| 53 |
+
"""GET /api/v1/reports/{campaign_id}/citation-trends"""
|
| 54 |
+
params: dict = {"limit": limit}
|
| 55 |
+
if start_date:
|
| 56 |
+
params["start_date"] = start_date
|
| 57 |
+
if end_date:
|
| 58 |
+
params["end_date"] = end_date
|
| 59 |
+
return self._get(f"/api/v1/reports/{campaign_id}/citation-trends", params)
|
| 60 |
+
|
| 61 |
+
def get_report_content_types(
|
| 62 |
+
self, campaign_id: int, start_date: str | None = None, end_date: str | None = None,
|
| 63 |
+
) -> dict:
|
| 64 |
+
"""GET /api/v1/reports/{campaign_id}/content-types"""
|
| 65 |
+
params = {}
|
| 66 |
+
if start_date:
|
| 67 |
+
params["start_date"] = start_date
|
| 68 |
+
if end_date:
|
| 69 |
+
params["end_date"] = end_date
|
| 70 |
+
return self._get(f"/api/v1/reports/{campaign_id}/content-types", params or None)
|
| 71 |
+
|
| 72 |
+
def get_report_sentiment(self, campaign_id: int) -> dict:
|
| 73 |
+
"""GET /api/v1/reports/{campaign_id}/sentiment"""
|
| 74 |
+
return self._get(f"/api/v1/reports/{campaign_id}/sentiment")
|
| 75 |
+
|
| 76 |
+
def get_report_homepage_citations(
|
| 77 |
+
self, campaign_id: int, start_date: str | None = None,
|
| 78 |
+
end_date: str | None = None, homepage_urls: str | None = None,
|
| 79 |
+
) -> dict:
|
| 80 |
+
"""GET /api/v1/reports/{campaign_id}/homepage-citations"""
|
| 81 |
+
params = {}
|
| 82 |
+
if start_date:
|
| 83 |
+
params["start_date"] = start_date
|
| 84 |
+
if end_date:
|
| 85 |
+
params["end_date"] = end_date
|
| 86 |
+
if homepage_urls:
|
| 87 |
+
params["homepage_urls"] = homepage_urls
|
| 88 |
+
return self._get(f"/api/v1/reports/{campaign_id}/homepage-citations", params or None)
|
| 89 |
+
|
| 90 |
+
def generate_html_report(
|
| 91 |
+
self,
|
| 92 |
+
campaign_id: int,
|
| 93 |
+
start_date: str | None = None,
|
| 94 |
+
end_date: str | None = None,
|
| 95 |
+
features: list[str] | None = None,
|
| 96 |
+
enable_insights: bool = False,
|
| 97 |
+
output_mode: str = "url",
|
| 98 |
+
) -> dict:
|
| 99 |
+
"""Generate HTML report. POST /api/v1/reports/{campaign_id}/html"""
|
| 100 |
+
body: dict = {"output_mode": output_mode, "enable_insights": enable_insights}
|
| 101 |
+
if start_date:
|
| 102 |
+
body["start_date"] = start_date
|
| 103 |
+
if end_date:
|
| 104 |
+
body["end_date"] = end_date
|
| 105 |
+
if features:
|
| 106 |
+
body["features"] = features
|
| 107 |
+
return self._post(f"/api/v1/reports/{campaign_id}/html", body)
|
| 108 |
+
|
| 109 |
+
def build_html_feature(
|
| 110 |
+
self,
|
| 111 |
+
campaign_id: int,
|
| 112 |
+
feature: str,
|
| 113 |
+
start_date: str | None = None,
|
| 114 |
+
end_date: str | None = None,
|
| 115 |
+
enable_insights: bool = False,
|
| 116 |
+
enable_action_items: bool = False,
|
| 117 |
+
homepage_urls: list[str] | None = None,
|
| 118 |
+
period: str = "1w",
|
| 119 |
+
) -> dict:
|
| 120 |
+
"""Build a single report feature. POST /api/v1/reports/{campaign_id}/html/build-feature"""
|
| 121 |
+
body: dict = {"feature": feature, "enable_insights": enable_insights, "period": period}
|
| 122 |
+
if start_date:
|
| 123 |
+
body["start_date"] = start_date
|
| 124 |
+
if end_date:
|
| 125 |
+
body["end_date"] = end_date
|
| 126 |
+
if enable_action_items:
|
| 127 |
+
body["enable_action_items"] = enable_action_items
|
| 128 |
+
if homepage_urls:
|
| 129 |
+
body["homepage_urls"] = homepage_urls
|
| 130 |
+
return self._post(f"/api/v1/reports/{campaign_id}/html/build-feature", body)
|
| 131 |
+
|
| 132 |
+
def render_html_report(
|
| 133 |
+
self,
|
| 134 |
+
campaign_id: int,
|
| 135 |
+
features_data: list[dict],
|
| 136 |
+
title: str | None = None,
|
| 137 |
+
subtitle: str | None = None,
|
| 138 |
+
start_date: str | None = None,
|
| 139 |
+
end_date: str | None = None,
|
| 140 |
+
enable_insights: bool = False,
|
| 141 |
+
enable_action_items: bool = False,
|
| 142 |
+
output_mode: str = "url",
|
| 143 |
+
) -> dict:
|
| 144 |
+
"""Assemble and render final HTML report. POST /api/v1/reports/{campaign_id}/html/render"""
|
| 145 |
+
body: dict = {
|
| 146 |
+
"features_data": features_data,
|
| 147 |
+
"output_mode": output_mode,
|
| 148 |
+
"enable_insights": enable_insights,
|
| 149 |
+
}
|
| 150 |
+
if title:
|
| 151 |
+
body["title"] = title
|
| 152 |
+
if subtitle:
|
| 153 |
+
body["subtitle"] = subtitle
|
| 154 |
+
if start_date:
|
| 155 |
+
body["start_date"] = start_date
|
| 156 |
+
if end_date:
|
| 157 |
+
body["end_date"] = end_date
|
| 158 |
+
if enable_action_items:
|
| 159 |
+
body["enable_action_items"] = enable_action_items
|
| 160 |
+
return self._post(f"/api/v1/reports/{campaign_id}/html/render", body)
|
| 161 |
+
|
| 162 |
+
def get_html_report_history(self, campaign_id: int, page: int = 1, page_size: int = 20) -> dict:
|
| 163 |
+
"""Get HTML report history. GET /api/v1/reports/{campaign_id}/html/history"""
|
| 164 |
+
return self._get(f"/api/v1/reports/{campaign_id}/html/history", {"page": page, "page_size": page_size})
|
| 165 |
+
|
| 166 |
+
def get_html_report_detail(self, campaign_id: int, report_id: str) -> dict:
|
| 167 |
+
"""Get HTML report detail. GET /api/v1/reports/{campaign_id}/html/{report_id}"""
|
| 168 |
+
return self._get(f"/api/v1/reports/{campaign_id}/html/{report_id}")
|
| 169 |
+
|
| 170 |
+
# ========================================================================
|
| 171 |
+
# Action Items APIs (ADR-018 Phase 2)
|
| 172 |
+
# ========================================================================
|
| 173 |
+
|
| 174 |
+
def save_action_items(
|
| 175 |
+
self,
|
| 176 |
+
campaign_id: int,
|
| 177 |
+
items: list[dict],
|
| 178 |
+
report_id: str | None = None,
|
| 179 |
+
) -> dict:
|
| 180 |
+
"""Save action items from report. POST /api/v1/action-items"""
|
| 181 |
+
body: dict = {"campaign_id": campaign_id, "items": items}
|
| 182 |
+
if report_id:
|
| 183 |
+
body["report_id"] = report_id
|
| 184 |
+
return self._post("/api/v1/action-items", body)
|
| 185 |
+
|
| 186 |
+
def update_action_item(self, item_id: str, data: dict) -> dict:
|
| 187 |
+
"""Update action item. PATCH /api/v1/action-items/{item_id}"""
|
| 188 |
+
return self._patch(f"/api/v1/action-items/{item_id}", data)
|
| 189 |
+
|
| 190 |
+
def delete_action_item(self, item_id: str) -> dict:
|
| 191 |
+
"""Delete action item. DELETE /api/v1/action-items/{item_id}"""
|
| 192 |
+
return self._delete(f"/api/v1/action-items/{item_id}")
|
| 193 |
+
|
| 194 |
+
def export_action_item_html(self, item_id: str) -> dict:
|
| 195 |
+
"""Export action item as HTML. GET /api/v1/action-items/{item_id}/export/html"""
|
| 196 |
+
return self._get(f"/api/v1/action-items/{item_id}/export/html")
|
core/api_client_sentiment.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sentiment API methods for ChainShiftClient.
|
| 2 |
+
|
| 3 |
+
Mixin class extracted from api_client.py.
|
| 4 |
+
Methods access self._get, self._post, self._session, self.base_url, self.headers from the parent class.
|
| 5 |
+
"""
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class SentimentApiMixin:
|
| 10 |
+
"""Gen3 Nudge, Brand Mentions, LLM Verification, Feedback, Keyword APIs."""
|
| 11 |
+
|
| 12 |
+
# ========================================================================
|
| 13 |
+
# Gen3 APIs - Nudge Detection
|
| 14 |
+
# ========================================================================
|
| 15 |
+
|
| 16 |
+
def get_nudge_candidates(
|
| 17 |
+
self,
|
| 18 |
+
campaign_id: int,
|
| 19 |
+
page: int = 1,
|
| 20 |
+
page_size: int = 50,
|
| 21 |
+
bit_quadrant: str | None = None,
|
| 22 |
+
platform: str | None = None,
|
| 23 |
+
) -> dict:
|
| 24 |
+
"""Get answers flagged for nudge (in-house brand negative mentions).
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
- total_nudge_candidates: int
|
| 28 |
+
- by_confidence_tier: {HIGH: n, MEDIUM: n, LOW: n}
|
| 29 |
+
- by_platform: {CHATGPT: n, GOOGLE_AI: n, ...}
|
| 30 |
+
- by_cej: {AWARENESS_COMPARISON: n, PURCHASE: n, ...}
|
| 31 |
+
- by_bit_quadrant: {neutral: n, ...}
|
| 32 |
+
- candidates: list of nudge items
|
| 33 |
+
"""
|
| 34 |
+
params = {"page": page, "page_size": page_size}
|
| 35 |
+
if bit_quadrant:
|
| 36 |
+
params["bit_quadrant"] = bit_quadrant
|
| 37 |
+
if platform:
|
| 38 |
+
params["platform"] = platform
|
| 39 |
+
return self._get(f"/api/v1/sentiment/campaigns/{campaign_id}/nudge-candidates", params)
|
| 40 |
+
|
| 41 |
+
def get_brand_mentions(
|
| 42 |
+
self,
|
| 43 |
+
campaign_id: int,
|
| 44 |
+
page: int = 1,
|
| 45 |
+
page_size: int = 50,
|
| 46 |
+
brand_type: str | None = None,
|
| 47 |
+
polarity: str | None = None,
|
| 48 |
+
llm_verified: str | None = None,
|
| 49 |
+
brand_name: str | None = None,
|
| 50 |
+
sort_by: str | None = "analyzed_at",
|
| 51 |
+
sort_desc: bool = True,
|
| 52 |
+
) -> dict:
|
| 53 |
+
"""Get brand mention analysis."""
|
| 54 |
+
params = {"page": page, "page_size": page_size, "sort_desc": sort_desc}
|
| 55 |
+
if brand_type:
|
| 56 |
+
params["brand_type"] = brand_type
|
| 57 |
+
if polarity:
|
| 58 |
+
params["polarity"] = polarity
|
| 59 |
+
if llm_verified:
|
| 60 |
+
params["llm_verified"] = llm_verified
|
| 61 |
+
if brand_name:
|
| 62 |
+
params["brand_name"] = brand_name
|
| 63 |
+
if sort_by:
|
| 64 |
+
params["sort_by"] = sort_by
|
| 65 |
+
return self._get(f"/api/v1/sentiment/campaigns/{campaign_id}/brand-mentions", params)
|
| 66 |
+
|
| 67 |
+
def export_brand_mentions(
|
| 68 |
+
self,
|
| 69 |
+
campaign_id: int,
|
| 70 |
+
brand_type: str | None = None,
|
| 71 |
+
polarity: str | None = None,
|
| 72 |
+
llm_verified: str | None = None,
|
| 73 |
+
brand_name: str | None = None,
|
| 74 |
+
include_full_answers: bool = False,
|
| 75 |
+
) -> bytes:
|
| 76 |
+
"""Export brand mentions to Excel."""
|
| 77 |
+
params = {}
|
| 78 |
+
if brand_type:
|
| 79 |
+
params["brand_type"] = brand_type
|
| 80 |
+
if polarity:
|
| 81 |
+
params["polarity"] = polarity
|
| 82 |
+
if llm_verified:
|
| 83 |
+
params["llm_verified"] = llm_verified
|
| 84 |
+
if brand_name:
|
| 85 |
+
params["brand_name"] = brand_name
|
| 86 |
+
if include_full_answers:
|
| 87 |
+
params["include_full_answers"] = "true"
|
| 88 |
+
|
| 89 |
+
url = f"{self.base_url}/api/v1/sentiment/campaigns/{campaign_id}/brand-mentions/export"
|
| 90 |
+
response = self._session.get(url, params=params, headers=self.headers, timeout=120)
|
| 91 |
+
response.raise_for_status()
|
| 92 |
+
return response.content
|
| 93 |
+
|
| 94 |
+
# ========================================================================
|
| 95 |
+
# LLM Verification & Human Feedback APIs
|
| 96 |
+
# ========================================================================
|
| 97 |
+
|
| 98 |
+
def verify_answer(self, answer_id: int, force: bool = False) -> dict:
|
| 99 |
+
"""Request LLM 2์ฐจ ๊ฒ์ฆ for an answer."""
|
| 100 |
+
return self._post("/api/v1/sentiment/verify", {
|
| 101 |
+
"answer_id": answer_id,
|
| 102 |
+
"force": force,
|
| 103 |
+
})
|
| 104 |
+
|
| 105 |
+
def submit_feedback(
|
| 106 |
+
self,
|
| 107 |
+
answer_id: int,
|
| 108 |
+
campaign_id: int,
|
| 109 |
+
feedback_type: str,
|
| 110 |
+
corrected_polarity: str | None = None,
|
| 111 |
+
wrong_reason: str | None = None,
|
| 112 |
+
comment: str | None = None,
|
| 113 |
+
) -> dict:
|
| 114 |
+
"""Submit human feedback for an answer sentiment."""
|
| 115 |
+
data = {
|
| 116 |
+
"answer_id": answer_id,
|
| 117 |
+
"campaign_id": campaign_id,
|
| 118 |
+
"feedback_type": feedback_type,
|
| 119 |
+
}
|
| 120 |
+
if corrected_polarity:
|
| 121 |
+
data["corrected_polarity"] = corrected_polarity
|
| 122 |
+
if wrong_reason:
|
| 123 |
+
data["wrong_reason"] = wrong_reason
|
| 124 |
+
if comment:
|
| 125 |
+
data["comment"] = comment
|
| 126 |
+
return self._post("/api/v1/sentiment/feedback", data)
|
| 127 |
+
|
| 128 |
+
def get_feedback_stats(self, campaign_id: int) -> dict:
|
| 129 |
+
"""Get feedback statistics for a campaign."""
|
| 130 |
+
return self._get(f"/api/v1/sentiment/campaigns/{campaign_id}/feedback/stats")
|
| 131 |
+
|
| 132 |
+
def get_full_answers(self, answer_ids: list[int]) -> dict:
|
| 133 |
+
"""Get full answer text for multiple answers."""
|
| 134 |
+
return self._post("/api/v1/sentiment/answers/full", {"answer_ids": answer_ids})
|
| 135 |
+
|
| 136 |
+
def export_nudge_candidates(
|
| 137 |
+
self,
|
| 138 |
+
campaign_id: int,
|
| 139 |
+
include_full_answers: bool = False,
|
| 140 |
+
include_evidence: bool = True,
|
| 141 |
+
llm_verified_only: bool = False,
|
| 142 |
+
platform: str | None = None,
|
| 143 |
+
llm_is_negative: bool | None = None,
|
| 144 |
+
) -> bytes:
|
| 145 |
+
"""Export nudge candidates to Excel."""
|
| 146 |
+
url = f"{self.base_url}/api/v1/sentiment/campaigns/{campaign_id}/nudge-candidates/export"
|
| 147 |
+
params = {
|
| 148 |
+
"include_full_answers": str(include_full_answers).lower(),
|
| 149 |
+
"include_evidence": str(include_evidence).lower(),
|
| 150 |
+
"llm_verified_only": str(llm_verified_only).lower(),
|
| 151 |
+
}
|
| 152 |
+
if platform:
|
| 153 |
+
params["platform"] = platform
|
| 154 |
+
if llm_is_negative is not None:
|
| 155 |
+
params["llm_is_negative"] = str(llm_is_negative).lower()
|
| 156 |
+
response = self._session.get(url, headers=self.headers, params=params, timeout=120)
|
| 157 |
+
response.raise_for_status()
|
| 158 |
+
return response.content
|
| 159 |
+
|
| 160 |
+
# ========================================================================
|
| 161 |
+
# Keyword Sentiment APIs
|
| 162 |
+
# ========================================================================
|
| 163 |
+
|
| 164 |
+
def start_keyword_analysis(self, campaign_id: int, keywords: list[str]) -> dict:
|
| 165 |
+
"""Start a keyword sentiment analysis job."""
|
| 166 |
+
return self._post(
|
| 167 |
+
f"/api/v1/sentiment/campaigns/{campaign_id}/keyword-analysis",
|
| 168 |
+
{"keywords": keywords},
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
def get_keyword_summary(
|
| 172 |
+
self,
|
| 173 |
+
campaign_id: int,
|
| 174 |
+
keyword: str | None = None,
|
| 175 |
+
page: int = 1,
|
| 176 |
+
page_size: int = 50,
|
| 177 |
+
) -> dict:
|
| 178 |
+
"""Get keyword sentiment summary with optional filter and pagination."""
|
| 179 |
+
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
| 180 |
+
if keyword:
|
| 181 |
+
params["keyword"] = keyword
|
| 182 |
+
return self._get(f"/api/v1/sentiment/campaigns/{campaign_id}/keyword-summary", params)
|
| 183 |
+
|
| 184 |
+
def get_keyword_results(
|
| 185 |
+
self,
|
| 186 |
+
campaign_id: int,
|
| 187 |
+
keyword: str | None = None,
|
| 188 |
+
sentiment: str | None = None,
|
| 189 |
+
brand_name: str | None = None,
|
| 190 |
+
competitor: bool = False,
|
| 191 |
+
llm_verified: str | None = None,
|
| 192 |
+
llm_is_negative: bool | None = None,
|
| 193 |
+
platform: str | None = None,
|
| 194 |
+
brand_only: bool = False,
|
| 195 |
+
no_brand: bool = False,
|
| 196 |
+
page: int = 1,
|
| 197 |
+
page_size: int = 50,
|
| 198 |
+
) -> dict:
|
| 199 |
+
"""Get keyword results with filters."""
|
| 200 |
+
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
| 201 |
+
if keyword:
|
| 202 |
+
params["keyword"] = keyword
|
| 203 |
+
if sentiment:
|
| 204 |
+
params["sentiment"] = sentiment
|
| 205 |
+
if brand_name:
|
| 206 |
+
params["brand_name"] = brand_name
|
| 207 |
+
if competitor:
|
| 208 |
+
params["competitor"] = "true"
|
| 209 |
+
if llm_verified:
|
| 210 |
+
params["llm_verified"] = llm_verified
|
| 211 |
+
if llm_is_negative is not None:
|
| 212 |
+
params["llm_is_negative"] = str(llm_is_negative).lower()
|
| 213 |
+
if platform:
|
| 214 |
+
params["platform"] = platform
|
| 215 |
+
if brand_only:
|
| 216 |
+
params["brand_only"] = "true"
|
| 217 |
+
if no_brand:
|
| 218 |
+
params["no_brand"] = "true"
|
| 219 |
+
return self._get(
|
| 220 |
+
f"/api/v1/sentiment/campaigns/{campaign_id}/keyword-results", params
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
def get_keyword_brand_analysis(
|
| 224 |
+
self,
|
| 225 |
+
campaign_id: int,
|
| 226 |
+
keyword: str | None = None,
|
| 227 |
+
brand_name: str | None = None,
|
| 228 |
+
competitor: bool = False,
|
| 229 |
+
) -> dict:
|
| 230 |
+
"""Get keyword x brand cross analysis."""
|
| 231 |
+
params: dict[str, Any] = {}
|
| 232 |
+
if keyword:
|
| 233 |
+
params["keyword"] = keyword
|
| 234 |
+
if brand_name:
|
| 235 |
+
params["brand_name"] = brand_name
|
| 236 |
+
if competitor:
|
| 237 |
+
params["competitor"] = "true"
|
| 238 |
+
return self._get(
|
| 239 |
+
f"/api/v1/sentiment/campaigns/{campaign_id}/keyword-brand-analysis", params
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
def export_keyword_results(
|
| 243 |
+
self,
|
| 244 |
+
campaign_id: int,
|
| 245 |
+
keyword: str | None = None,
|
| 246 |
+
sentiment: str | None = None,
|
| 247 |
+
brand_name: str | None = None,
|
| 248 |
+
competitor: bool = False,
|
| 249 |
+
llm_verified: str | None = None,
|
| 250 |
+
llm_is_negative: bool | None = None,
|
| 251 |
+
platform: str | None = None,
|
| 252 |
+
brand_only: bool = False,
|
| 253 |
+
no_brand: bool = False,
|
| 254 |
+
format: str = "xlsx",
|
| 255 |
+
) -> bytes:
|
| 256 |
+
"""Export keyword results to Excel or CSV with same filters as list endpoint."""
|
| 257 |
+
params: dict[str, Any] = {"format": format}
|
| 258 |
+
if keyword:
|
| 259 |
+
params["keyword"] = keyword
|
| 260 |
+
if sentiment:
|
| 261 |
+
params["sentiment"] = sentiment
|
| 262 |
+
if brand_name:
|
| 263 |
+
params["brand_name"] = brand_name
|
| 264 |
+
if competitor:
|
| 265 |
+
params["competitor"] = "true"
|
| 266 |
+
if llm_verified:
|
| 267 |
+
params["llm_verified"] = llm_verified
|
| 268 |
+
if llm_is_negative is not None:
|
| 269 |
+
params["llm_is_negative"] = str(llm_is_negative).lower()
|
| 270 |
+
if platform:
|
| 271 |
+
params["platform"] = platform
|
| 272 |
+
if brand_only:
|
| 273 |
+
params["brand_only"] = "true"
|
| 274 |
+
if no_brand:
|
| 275 |
+
params["no_brand"] = "true"
|
| 276 |
+
url = f"{self.base_url}/api/v1/sentiment/campaigns/{campaign_id}/keyword-results/export"
|
| 277 |
+
response = self._session.get(url, params=params, headers=self.headers, timeout=120)
|
| 278 |
+
response.raise_for_status()
|
| 279 |
+
return response.content
|
core/athena_client.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Standalone Athena client for dashboard.
|
| 2 |
+
|
| 3 |
+
Uses requests + AWS SigV4 to query Athena directly. No dependency on
|
| 4 |
+
app/ modules โ compatible with HuggingFace Space deployment.
|
| 5 |
+
|
| 6 |
+
Public API:
|
| 7 |
+
- fetch_full_answer(answer_id) -> str | None
|
| 8 |
+
- fetch_full_answers_batch(answer_ids) -> dict[int, str]
|
| 9 |
+
- query_athena(sql, params) -> list[dict]
|
| 10 |
+
- is_athena_configured() -> bool
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import hashlib
|
| 14 |
+
import hmac
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import re
|
| 18 |
+
import time
|
| 19 |
+
import uuid
|
| 20 |
+
from datetime import datetime, timezone, date as date_type
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import requests
|
| 24 |
+
from dotenv import load_dotenv
|
| 25 |
+
|
| 26 |
+
# Load environment variables (project root, APP_ENV-aware)
|
| 27 |
+
_project_root = Path(__file__).parent.parent.parent.parent
|
| 28 |
+
_app_env = os.environ.get("APP_ENV")
|
| 29 |
+
if _app_env:
|
| 30 |
+
_candidates = [f".env.{_app_env}", ".env.dev", ".env.prod"]
|
| 31 |
+
else:
|
| 32 |
+
_candidates = [".env.dev", ".env.prod"]
|
| 33 |
+
for _env_name in _candidates:
|
| 34 |
+
_env_path = _project_root / _env_name
|
| 35 |
+
if _env_path.exists():
|
| 36 |
+
load_dotenv(_env_path, override=True)
|
| 37 |
+
break
|
| 38 |
+
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
# Config from env vars (HuggingFace Secrets compatible)
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
|
| 43 |
+
_ACCESS_KEY_ID = os.environ.get("ATHENA_ACCESS_KEY_ID", "")
|
| 44 |
+
_SECRET_ACCESS_KEY = os.environ.get("ATHENA_SECRET_ACCESS_KEY", "")
|
| 45 |
+
_REGION = os.environ.get("ATHENA_REGION", "ap-northeast-2")
|
| 46 |
+
_DATABASE = os.environ.get("ATHENA_DATABASE", "fde-chainshift-prod")
|
| 47 |
+
_S3_OUTPUT = os.environ.get("ATHENA_S3_OUTPUT", "s3://chainshift-prod-rds-snapshots/athena-results/")
|
| 48 |
+
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
# AWS SigV4 signing
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
|
| 53 |
+
_ALGORITHM = "AWS4-HMAC-SHA256"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _sign(key: bytes, msg: str) -> bytes:
|
| 57 |
+
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _get_signature_key(secret: str, date_stamp: str, region: str, service: str) -> bytes:
|
| 61 |
+
k_date = _sign(("AWS4" + secret).encode("utf-8"), date_stamp)
|
| 62 |
+
k_region = _sign(k_date, region)
|
| 63 |
+
k_service = _sign(k_region, service)
|
| 64 |
+
return _sign(k_service, "aws4_request")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _sigv4_headers(action: str, body: str) -> dict[str, str]:
|
| 68 |
+
"""Build SigV4-signed headers for an Athena API call."""
|
| 69 |
+
now = datetime.now(timezone.utc)
|
| 70 |
+
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
| 71 |
+
date_stamp = now.strftime("%Y%m%d")
|
| 72 |
+
region = _REGION
|
| 73 |
+
service = "athena"
|
| 74 |
+
host = f"athena.{region}.amazonaws.com"
|
| 75 |
+
|
| 76 |
+
payload_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
| 77 |
+
canonical_headers = (
|
| 78 |
+
f"content-type:application/x-amz-json-1.1\n"
|
| 79 |
+
f"host:{host}\n"
|
| 80 |
+
f"x-amz-date:{amz_date}\n"
|
| 81 |
+
f"x-amz-target:AmazonAthena.{action}\n"
|
| 82 |
+
)
|
| 83 |
+
signed_headers = "content-type;host;x-amz-date;x-amz-target"
|
| 84 |
+
canonical_request = (
|
| 85 |
+
f"POST\n/\n\n"
|
| 86 |
+
f"{canonical_headers}\n{signed_headers}\n{payload_hash}"
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
|
| 90 |
+
string_to_sign = (
|
| 91 |
+
f"{_ALGORITHM}\n{amz_date}\n{credential_scope}\n"
|
| 92 |
+
f"{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
signing_key = _get_signature_key(_SECRET_ACCESS_KEY, date_stamp, region, service)
|
| 96 |
+
signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
| 97 |
+
|
| 98 |
+
authorization = (
|
| 99 |
+
f"{_ALGORITHM} Credential={_ACCESS_KEY_ID}/{credential_scope}, "
|
| 100 |
+
f"SignedHeaders={signed_headers}, Signature={signature}"
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
return {
|
| 104 |
+
"Content-Type": "application/x-amz-json-1.1",
|
| 105 |
+
"Host": host,
|
| 106 |
+
"X-Amz-Date": amz_date,
|
| 107 |
+
"X-Amz-Target": f"AmazonAthena.{action}",
|
| 108 |
+
"Authorization": authorization,
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ---------------------------------------------------------------------------
|
| 113 |
+
# Athena API helpers
|
| 114 |
+
# ---------------------------------------------------------------------------
|
| 115 |
+
|
| 116 |
+
_POLL_INTERVAL = 1.0
|
| 117 |
+
_POLL_TIMEOUT = 120.0
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _athena_api_call(action: str, body: dict) -> dict:
|
| 121 |
+
"""POST to the Athena JSON API and return parsed response."""
|
| 122 |
+
payload = json.dumps(body)
|
| 123 |
+
url = f"https://athena.{_REGION}.amazonaws.com/"
|
| 124 |
+
headers = _sigv4_headers(action, payload)
|
| 125 |
+
|
| 126 |
+
resp = requests.post(url, data=payload, headers=headers, timeout=30)
|
| 127 |
+
if resp.status_code != 200:
|
| 128 |
+
raise RuntimeError(
|
| 129 |
+
f"Athena {action} failed ({resp.status_code}): {resp.text}"
|
| 130 |
+
)
|
| 131 |
+
return resp.json()
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _start_query(sql: str) -> str:
|
| 135 |
+
"""Submit a query and return the QueryExecutionId."""
|
| 136 |
+
body: dict = {
|
| 137 |
+
"QueryString": sql,
|
| 138 |
+
"ClientRequestToken": str(uuid.uuid4()),
|
| 139 |
+
"QueryExecutionContext": {"Database": _DATABASE},
|
| 140 |
+
"ResultConfiguration": {"OutputLocation": _S3_OUTPUT},
|
| 141 |
+
}
|
| 142 |
+
result = _athena_api_call("StartQueryExecution", body)
|
| 143 |
+
return result["QueryExecutionId"]
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _wait_for_query(query_id: str, timeout: float | None = None) -> None:
|
| 147 |
+
"""Poll until the query completes or times out."""
|
| 148 |
+
deadline = time.monotonic() + (timeout or _POLL_TIMEOUT)
|
| 149 |
+
while time.monotonic() < deadline:
|
| 150 |
+
result = _athena_api_call(
|
| 151 |
+
"GetQueryExecution", {"QueryExecutionId": query_id},
|
| 152 |
+
)
|
| 153 |
+
state = result["QueryExecution"]["Status"]["State"]
|
| 154 |
+
if state == "SUCCEEDED":
|
| 155 |
+
return
|
| 156 |
+
if state in ("FAILED", "CANCELLED"):
|
| 157 |
+
reason = result["QueryExecution"]["Status"].get(
|
| 158 |
+
"StateChangeReason", "unknown"
|
| 159 |
+
)
|
| 160 |
+
raise RuntimeError(f"Athena query {state}: {reason}")
|
| 161 |
+
time.sleep(_POLL_INTERVAL)
|
| 162 |
+
|
| 163 |
+
raise TimeoutError(f"Athena query {query_id} timed out after {_POLL_TIMEOUT}s")
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _convert_value(raw: str | None, athena_type: str) -> object:
|
| 167 |
+
"""Convert a string value from Athena to the appropriate Python type."""
|
| 168 |
+
if raw is None:
|
| 169 |
+
return None
|
| 170 |
+
athena_type = athena_type.lower()
|
| 171 |
+
if athena_type in ("integer", "int", "bigint", "smallint", "tinyint"):
|
| 172 |
+
return int(raw)
|
| 173 |
+
if athena_type in ("double", "float", "decimal", "real"):
|
| 174 |
+
return float(raw)
|
| 175 |
+
if athena_type == "boolean":
|
| 176 |
+
return raw.lower() == "true"
|
| 177 |
+
if athena_type == "date":
|
| 178 |
+
return date_type.fromisoformat(raw)
|
| 179 |
+
return raw
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _get_results(query_id: str) -> list[dict]:
|
| 183 |
+
"""Fetch all result pages and return as list[dict]."""
|
| 184 |
+
rows: list[dict] = []
|
| 185 |
+
next_token: str | None = None
|
| 186 |
+
|
| 187 |
+
while True:
|
| 188 |
+
body: dict = {"QueryExecutionId": query_id, "MaxResults": 1000}
|
| 189 |
+
if next_token:
|
| 190 |
+
body["NextToken"] = next_token
|
| 191 |
+
|
| 192 |
+
result = _athena_api_call("GetQueryResults", body)
|
| 193 |
+
result_set = result["ResultSet"]
|
| 194 |
+
|
| 195 |
+
columns = result_set["ResultSetMetadata"]["ColumnInfo"]
|
| 196 |
+
col_names = [c["Name"] for c in columns]
|
| 197 |
+
col_types = [c["Type"] for c in columns]
|
| 198 |
+
|
| 199 |
+
data_rows = result_set.get("Rows", [])
|
| 200 |
+
start = 1 if not next_token and data_rows else 0
|
| 201 |
+
|
| 202 |
+
for row in data_rows[start:]:
|
| 203 |
+
values = row.get("Data", [])
|
| 204 |
+
record: dict = {}
|
| 205 |
+
for i, col_name in enumerate(col_names):
|
| 206 |
+
if i < len(values):
|
| 207 |
+
cell = values[i]
|
| 208 |
+
raw = cell.get("VarCharValue")
|
| 209 |
+
record[col_name] = _convert_value(raw, col_types[i])
|
| 210 |
+
else:
|
| 211 |
+
record[col_name] = None
|
| 212 |
+
rows.append(record)
|
| 213 |
+
|
| 214 |
+
next_token = result.get("NextToken")
|
| 215 |
+
if not next_token:
|
| 216 |
+
break
|
| 217 |
+
|
| 218 |
+
return rows
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# ---------------------------------------------------------------------------
|
| 222 |
+
# Parameter substitution
|
| 223 |
+
# ---------------------------------------------------------------------------
|
| 224 |
+
|
| 225 |
+
def _substitute_params(sql: str, params: dict) -> str:
|
| 226 |
+
"""Replace %(name)s placeholders with escaped values."""
|
| 227 |
+
def _replace(match: re.Match) -> str:
|
| 228 |
+
name = match.group(1)
|
| 229 |
+
if name not in params:
|
| 230 |
+
raise KeyError(f"Parameter '{name}' not found in params dict")
|
| 231 |
+
val = params[name]
|
| 232 |
+
if val is None:
|
| 233 |
+
return "NULL"
|
| 234 |
+
if isinstance(val, (int, float)):
|
| 235 |
+
return str(val)
|
| 236 |
+
escaped = str(val).replace("'", "''")
|
| 237 |
+
return f"'{escaped}'"
|
| 238 |
+
|
| 239 |
+
return re.sub(r"%\((\w+)\)s", _replace, sql)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
# ---------------------------------------------------------------------------
|
| 243 |
+
# Public API
|
| 244 |
+
# ---------------------------------------------------------------------------
|
| 245 |
+
|
| 246 |
+
def is_athena_configured() -> bool:
|
| 247 |
+
"""Check if Athena credentials are configured."""
|
| 248 |
+
return bool(_ACCESS_KEY_ID and _SECRET_ACCESS_KEY and _S3_OUTPUT)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def query_athena(
|
| 252 |
+
sql: str,
|
| 253 |
+
params: dict | None = None,
|
| 254 |
+
timeout: float | None = None,
|
| 255 |
+
) -> list[dict]:
|
| 256 |
+
"""Execute Athena SQL and return results as list of dicts.
|
| 257 |
+
|
| 258 |
+
Args:
|
| 259 |
+
sql: SQL query string. Use %(name)s for parameter placeholders.
|
| 260 |
+
params: Optional dict of parameters for the query.
|
| 261 |
+
timeout: Max seconds to wait for query completion.
|
| 262 |
+
|
| 263 |
+
Returns:
|
| 264 |
+
List of dicts (one per row), column names as keys.
|
| 265 |
+
"""
|
| 266 |
+
if not is_athena_configured():
|
| 267 |
+
raise ValueError("Athena credentials not configured")
|
| 268 |
+
|
| 269 |
+
if params:
|
| 270 |
+
sql = _substitute_params(sql, params)
|
| 271 |
+
|
| 272 |
+
query_id = _start_query(sql)
|
| 273 |
+
_wait_for_query(query_id, timeout=timeout)
|
| 274 |
+
return _get_results(query_id)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
# ---------------------------------------------------------------------------
|
| 278 |
+
# Dashboard-specific functions
|
| 279 |
+
# ---------------------------------------------------------------------------
|
| 280 |
+
|
| 281 |
+
def fetch_full_answer(answer_id: int) -> str | None:
|
| 282 |
+
"""Fetch full answer content from Athena answers table.
|
| 283 |
+
|
| 284 |
+
Args:
|
| 285 |
+
answer_id: The answer ID to fetch
|
| 286 |
+
|
| 287 |
+
Returns:
|
| 288 |
+
Full answer content or None if not found
|
| 289 |
+
"""
|
| 290 |
+
if not is_athena_configured():
|
| 291 |
+
print(f"[Athena] Not configured, cannot fetch answer {answer_id}")
|
| 292 |
+
return None
|
| 293 |
+
|
| 294 |
+
try:
|
| 295 |
+
rows = query_athena(
|
| 296 |
+
"SELECT content FROM answers WHERE id = %(answer_id)s AND deleted_at IS NULL",
|
| 297 |
+
{"answer_id": answer_id},
|
| 298 |
+
)
|
| 299 |
+
return rows[0]["content"] if rows else None
|
| 300 |
+
except Exception as e:
|
| 301 |
+
print(f"[Athena Error] Failed to fetch answer {answer_id}: {e}")
|
| 302 |
+
return None
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def fetch_full_answers_batch(answer_ids: list[int]) -> dict[int, str]:
|
| 306 |
+
"""Fetch multiple full answers from Athena in a single query.
|
| 307 |
+
|
| 308 |
+
Args:
|
| 309 |
+
answer_ids: List of answer IDs to fetch
|
| 310 |
+
|
| 311 |
+
Returns:
|
| 312 |
+
Dict mapping answer_id to content
|
| 313 |
+
"""
|
| 314 |
+
if not answer_ids:
|
| 315 |
+
return {}
|
| 316 |
+
|
| 317 |
+
if not is_athena_configured():
|
| 318 |
+
print("[Athena] Not configured, cannot fetch answers batch")
|
| 319 |
+
return {}
|
| 320 |
+
|
| 321 |
+
try:
|
| 322 |
+
# Athena doesn't support array params like PostgreSQL's ANY(%s).
|
| 323 |
+
# Use IN clause with comma-separated IDs (all integers, safe).
|
| 324 |
+
ids_str = ", ".join(str(int(aid)) for aid in answer_ids)
|
| 325 |
+
rows = query_athena(
|
| 326 |
+
f"SELECT id, content FROM answers WHERE id IN ({ids_str}) AND deleted_at IS NULL",
|
| 327 |
+
)
|
| 328 |
+
return {row["id"]: row["content"] for row in rows if row.get("content")}
|
| 329 |
+
except Exception as e:
|
| 330 |
+
print(f"[Athena Error] Failed to fetch answers batch: {e}")
|
| 331 |
+
return {}
|
core/charts.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chart components for Gen3 Nudge Detection Dashboard."""
|
| 2 |
+
|
| 3 |
+
import plotly.graph_objects as go
|
| 4 |
+
import pandas as pd
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
# =============================================================================
|
| 8 |
+
# Color Constants
|
| 9 |
+
# =============================================================================
|
| 10 |
+
|
| 11 |
+
CONFIDENCE_TIER_COLORS = {
|
| 12 |
+
"HIGH": "#EF4444", # Red
|
| 13 |
+
"MEDIUM": "#F59E0B", # Amber
|
| 14 |
+
"LOW": "#10B981", # Green
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
PLATFORM_COLORS = {
|
| 18 |
+
"CHATGPT": "#10A37F",
|
| 19 |
+
"GOOGLE_AI": "#4285F4",
|
| 20 |
+
"GOOGLE_OVERVIEW": "#34A853",
|
| 21 |
+
"PERPLEXITY": "#6366F1",
|
| 22 |
+
"GEMINI": "#8B5CF6",
|
| 23 |
+
"BING": "#00A4EF",
|
| 24 |
+
"CLAUDE": "#D97706",
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
POLARITY_COLORS = {
|
| 28 |
+
"positive": "#10B981",
|
| 29 |
+
"negative": "#EF4444",
|
| 30 |
+
"neutral": "#6B7280",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
# English to Korean emotion mapping
|
| 34 |
+
EMOTION_KO = {
|
| 35 |
+
"admiration": "๊ฐํ",
|
| 36 |
+
"amusement": "์ฌ๋ฏธ",
|
| 37 |
+
"anger": "๋ถ๋
ธ",
|
| 38 |
+
"annoyance": "์ง์ฆ",
|
| 39 |
+
"approval": "์ธ์ ",
|
| 40 |
+
"caring": "๋ฐฐ๋ ค",
|
| 41 |
+
"confusion": "ํผ๋",
|
| 42 |
+
"curiosity": "ํธ๊ธฐ์ฌ",
|
| 43 |
+
"desire": "์๊ตฌ",
|
| 44 |
+
"disappointment": "์ค๋ง",
|
| 45 |
+
"disapproval": "๋ฐ๋",
|
| 46 |
+
"disgust": "ํ์ค",
|
| 47 |
+
"embarrassment": "๋นํน",
|
| 48 |
+
"excitement": "ํฅ๋ถ",
|
| 49 |
+
"fear": "๋๋ ค์",
|
| 50 |
+
"gratitude": "๊ฐ์ฌ",
|
| 51 |
+
"grief": "์ฌํ",
|
| 52 |
+
"joy": "๊ธฐ์จ",
|
| 53 |
+
"love": "์ฌ๋",
|
| 54 |
+
"nervousness": "๋ถ์",
|
| 55 |
+
"optimism": "๋๊ด",
|
| 56 |
+
"pride": "์๋ถ์ฌ",
|
| 57 |
+
"realization": "๊นจ๋ฌ์",
|
| 58 |
+
"relief": "์๋",
|
| 59 |
+
"remorse": "ํํ",
|
| 60 |
+
"sadness": "์ฌํ",
|
| 61 |
+
"surprise": "๋๋ผ์",
|
| 62 |
+
"neutral": "์ค๋ฆฝ",
|
| 63 |
+
"trust": "์ ๋ขฐ",
|
| 64 |
+
"anticipation": "๊ธฐ๋",
|
| 65 |
+
"interest": "๊ด์ฌ",
|
| 66 |
+
"satisfaction": "๋ง์กฑ",
|
| 67 |
+
"frustration": "์ข์ ",
|
| 68 |
+
"hope": "ํฌ๋ง",
|
| 69 |
+
"worry": "๊ฑฑ์ ",
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
# CEJ Korean labels
|
| 73 |
+
CEJ_LABELS = {
|
| 74 |
+
"VERIFICATION": "๊ฒ์ฆ ์ง๋ฌธ",
|
| 75 |
+
"INFORMATION_DISCOVERY": "์ ๋ณด ํ์",
|
| 76 |
+
"HOW_TO": "์ฌ์ฉ๋ฒ",
|
| 77 |
+
"WHERE_TO_BUY": "๊ตฌ๋งค์ฒ",
|
| 78 |
+
"RECOMMENDATION": "์ถ์ฒ ์์ฒญ",
|
| 79 |
+
"SIDE_EFFECT": "๋ถ์์ฉ",
|
| 80 |
+
"MARKET_TRENDS": "์์ฅ ๋ํฅ",
|
| 81 |
+
"RESULT_EFFECTIVENESS": "ํจ๊ณผ/๊ฒฐ๊ณผ",
|
| 82 |
+
"COMPARISON": "๋น๊ต",
|
| 83 |
+
"INGREDIENT": "์ฑ๋ถ",
|
| 84 |
+
"AWARENESS_COMPARISON": "์ธ์ง/๋น๊ต",
|
| 85 |
+
"PURCHASE": "๊ตฌ๋งค",
|
| 86 |
+
"POST_PURCHASE": "๊ตฌ๋งค ํ",
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# =============================================================================
|
| 91 |
+
# Gen3 Nudge Charts
|
| 92 |
+
# =============================================================================
|
| 93 |
+
|
| 94 |
+
def create_confidence_tier_pie_chart(tier_stats: dict) -> go.Figure:
|
| 95 |
+
"""Create pie chart for confidence tier distribution (HIGH/MEDIUM/LOW)."""
|
| 96 |
+
if not tier_stats:
|
| 97 |
+
return go.Figure()
|
| 98 |
+
|
| 99 |
+
labels = list(tier_stats.keys())
|
| 100 |
+
values = list(tier_stats.values())
|
| 101 |
+
colors = [CONFIDENCE_TIER_COLORS.get(k, "#6B7280") for k in labels]
|
| 102 |
+
|
| 103 |
+
label_map = {
|
| 104 |
+
"HIGH": "๐ด HIGH",
|
| 105 |
+
"MEDIUM": "๐ก MEDIUM",
|
| 106 |
+
"LOW": "๐ข LOW",
|
| 107 |
+
}
|
| 108 |
+
display_labels = [label_map.get(k, k) for k in labels]
|
| 109 |
+
|
| 110 |
+
fig = go.Figure(data=[go.Pie(
|
| 111 |
+
labels=display_labels,
|
| 112 |
+
values=values,
|
| 113 |
+
marker=dict(colors=colors),
|
| 114 |
+
hole=0.4,
|
| 115 |
+
textinfo="percent+value",
|
| 116 |
+
textposition="outside",
|
| 117 |
+
)])
|
| 118 |
+
|
| 119 |
+
fig.update_layout(
|
| 120 |
+
title="",
|
| 121 |
+
showlegend=True,
|
| 122 |
+
legend=dict(orientation="h", yanchor="bottom", y=-0.2, xanchor="center", x=0.5),
|
| 123 |
+
margin=dict(t=20, b=60, l=20, r=20),
|
| 124 |
+
height=280,
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
return fig
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def create_platform_bar_chart(platform_stats: dict) -> go.Figure:
|
| 131 |
+
"""Create horizontal bar chart for platform distribution."""
|
| 132 |
+
if not platform_stats:
|
| 133 |
+
return go.Figure()
|
| 134 |
+
|
| 135 |
+
sorted_items = sorted(platform_stats.items(), key=lambda x: x[1], reverse=True)
|
| 136 |
+
platforms = [item[0] for item in sorted_items]
|
| 137 |
+
counts = [item[1] for item in sorted_items]
|
| 138 |
+
colors = [PLATFORM_COLORS.get(p, "#6B7280") for p in platforms]
|
| 139 |
+
|
| 140 |
+
fig = go.Figure(data=[
|
| 141 |
+
go.Bar(
|
| 142 |
+
y=platforms,
|
| 143 |
+
x=counts,
|
| 144 |
+
orientation="h",
|
| 145 |
+
marker_color=colors,
|
| 146 |
+
text=[f"{c}๊ฑด" for c in counts],
|
| 147 |
+
textposition="auto",
|
| 148 |
+
)
|
| 149 |
+
])
|
| 150 |
+
|
| 151 |
+
fig.update_layout(
|
| 152 |
+
title="",
|
| 153 |
+
xaxis_title="๋์ง ํ๋ณด ์",
|
| 154 |
+
yaxis=dict(autorange="reversed"),
|
| 155 |
+
margin=dict(t=20, b=40, l=100, r=20),
|
| 156 |
+
height=max(200, len(platforms) * 35),
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
return fig
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def create_nudge_by_cej_bar_chart(cej_counts: dict) -> go.Figure:
|
| 163 |
+
"""Create horizontal bar chart for nudge distribution by CEJ stage."""
|
| 164 |
+
if not cej_counts:
|
| 165 |
+
return go.Figure()
|
| 166 |
+
|
| 167 |
+
sorted_items = sorted(cej_counts.items(), key=lambda x: x[1], reverse=True)
|
| 168 |
+
cej_stages = [item[0] for item in sorted_items]
|
| 169 |
+
counts = [item[1] for item in sorted_items]
|
| 170 |
+
|
| 171 |
+
display_labels = [CEJ_LABELS.get(cej, cej) for cej in cej_stages]
|
| 172 |
+
|
| 173 |
+
fig = go.Figure(data=[
|
| 174 |
+
go.Bar(
|
| 175 |
+
y=display_labels,
|
| 176 |
+
x=counts,
|
| 177 |
+
orientation="h",
|
| 178 |
+
marker_color="#6366F1",
|
| 179 |
+
text=[f"{c}๊ฑด" for c in counts],
|
| 180 |
+
textposition="auto",
|
| 181 |
+
)
|
| 182 |
+
])
|
| 183 |
+
|
| 184 |
+
fig.update_layout(
|
| 185 |
+
title="",
|
| 186 |
+
xaxis_title="๋์ง ํ๋ณด ์",
|
| 187 |
+
yaxis=dict(autorange="reversed"),
|
| 188 |
+
margin=dict(t=20, b=40, l=100, r=20),
|
| 189 |
+
height=max(200, len(cej_stages) * 35),
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
return fig
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def create_brand_sentiment_chart(brands: list[dict]) -> go.Figure:
|
| 196 |
+
"""Create horizontal stacked bar chart for brand sentiment comparison."""
|
| 197 |
+
if not brands:
|
| 198 |
+
return go.Figure()
|
| 199 |
+
|
| 200 |
+
# Sort by total mentions
|
| 201 |
+
sorted_brands = sorted(brands, key=lambda x: x.get("total_mentions", 0), reverse=True)[:10]
|
| 202 |
+
|
| 203 |
+
brand_names = []
|
| 204 |
+
for b in sorted_brands:
|
| 205 |
+
name = b.get("brand_name", "Unknown")
|
| 206 |
+
brand_type = b.get("brand_type", "")
|
| 207 |
+
prefix = "๐ " if brand_type == "IN_HOUSE" else "๐ข "
|
| 208 |
+
brand_names.append(f"{prefix}{name}")
|
| 209 |
+
|
| 210 |
+
positive_rates = [b.get("positive_rate", 0) for b in sorted_brands]
|
| 211 |
+
negative_rates = [b.get("negative_rate", 0) for b in sorted_brands]
|
| 212 |
+
neutral_rates = [100 - p - n for p, n in zip(positive_rates, negative_rates)]
|
| 213 |
+
|
| 214 |
+
fig = go.Figure()
|
| 215 |
+
|
| 216 |
+
fig.add_trace(go.Bar(
|
| 217 |
+
y=brand_names,
|
| 218 |
+
x=positive_rates,
|
| 219 |
+
name="๊ธ์ ",
|
| 220 |
+
orientation="h",
|
| 221 |
+
marker_color="#10B981",
|
| 222 |
+
text=[f"{v:.1f}%" for v in positive_rates],
|
| 223 |
+
textposition="inside",
|
| 224 |
+
))
|
| 225 |
+
|
| 226 |
+
fig.add_trace(go.Bar(
|
| 227 |
+
y=brand_names,
|
| 228 |
+
x=neutral_rates,
|
| 229 |
+
name="์ค๋ฆฝ",
|
| 230 |
+
orientation="h",
|
| 231 |
+
marker_color="#E5E7EB",
|
| 232 |
+
text=[f"{v:.1f}%" for v in neutral_rates],
|
| 233 |
+
textposition="inside",
|
| 234 |
+
))
|
| 235 |
+
|
| 236 |
+
fig.add_trace(go.Bar(
|
| 237 |
+
y=brand_names,
|
| 238 |
+
x=negative_rates,
|
| 239 |
+
name="๋ถ์ ",
|
| 240 |
+
orientation="h",
|
| 241 |
+
marker_color="#EF4444",
|
| 242 |
+
text=[f"{v:.1f}%" for v in negative_rates],
|
| 243 |
+
textposition="inside",
|
| 244 |
+
))
|
| 245 |
+
|
| 246 |
+
fig.update_layout(
|
| 247 |
+
title="",
|
| 248 |
+
barmode="stack",
|
| 249 |
+
xaxis_title="๋น์จ (%)",
|
| 250 |
+
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
|
| 251 |
+
margin=dict(t=50, b=50, l=150, r=20),
|
| 252 |
+
height=max(300, len(sorted_brands) * 40),
|
| 253 |
+
yaxis=dict(autorange="reversed"),
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
return fig
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def create_domain_bar_chart(domain_counts: dict) -> go.Figure:
|
| 260 |
+
"""Create horizontal bar chart for citation domain distribution."""
|
| 261 |
+
if not domain_counts:
|
| 262 |
+
return go.Figure()
|
| 263 |
+
|
| 264 |
+
# Take top 15 domains
|
| 265 |
+
sorted_items = list(domain_counts.items())[:15]
|
| 266 |
+
domains = [item[0] for item in sorted_items]
|
| 267 |
+
counts = [item[1] for item in sorted_items]
|
| 268 |
+
|
| 269 |
+
# Truncate long domain names
|
| 270 |
+
display_domains = [d[:40] + "..." if len(d) > 40 else d for d in domains]
|
| 271 |
+
|
| 272 |
+
fig = go.Figure(data=[
|
| 273 |
+
go.Bar(
|
| 274 |
+
y=display_domains,
|
| 275 |
+
x=counts,
|
| 276 |
+
orientation="h",
|
| 277 |
+
marker_color="#3B82F6",
|
| 278 |
+
text=[f"{c}ํ" for c in counts],
|
| 279 |
+
textposition="auto",
|
| 280 |
+
)
|
| 281 |
+
])
|
| 282 |
+
|
| 283 |
+
fig.update_layout(
|
| 284 |
+
title="",
|
| 285 |
+
xaxis_title="์ธ์ฉ ํ์",
|
| 286 |
+
yaxis=dict(autorange="reversed"),
|
| 287 |
+
margin=dict(t=20, b=40, l=200, r=20),
|
| 288 |
+
height=max(300, len(sorted_items) * 30),
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
return fig
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def create_bit_quadrant_chart(bit_stats: dict) -> go.Figure:
|
| 295 |
+
"""Create bar chart for BIT quadrant distribution."""
|
| 296 |
+
if not bit_stats:
|
| 297 |
+
return go.Figure()
|
| 298 |
+
|
| 299 |
+
BIT_LABELS = {
|
| 300 |
+
"neutral": "์ค๋ฆฝ",
|
| 301 |
+
"product_satisfaction": "์ ํ ๋ง์กฑ",
|
| 302 |
+
"unmet_expectations": "๊ธฐ๋ ๋ฏธ์ถฉ์กฑ",
|
| 303 |
+
"brand_trust": "๋ธ๋๋ ์ ๋ขฐ",
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
sorted_items = sorted(bit_stats.items(), key=lambda x: x[1], reverse=True)
|
| 307 |
+
quadrants = [BIT_LABELS.get(item[0], item[0]) for item in sorted_items]
|
| 308 |
+
counts = [item[1] for item in sorted_items]
|
| 309 |
+
|
| 310 |
+
colors = ["#6366F1", "#8B5CF6", "#A855F7", "#D946EF"]
|
| 311 |
+
|
| 312 |
+
fig = go.Figure(data=[
|
| 313 |
+
go.Bar(
|
| 314 |
+
x=quadrants,
|
| 315 |
+
y=counts,
|
| 316 |
+
marker_color=colors[:len(quadrants)],
|
| 317 |
+
text=[f"{c}๊ฑด" for c in counts],
|
| 318 |
+
textposition="outside",
|
| 319 |
+
)
|
| 320 |
+
])
|
| 321 |
+
|
| 322 |
+
fig.update_layout(
|
| 323 |
+
title="",
|
| 324 |
+
yaxis_title="๋์ง ํ๋ณด ์",
|
| 325 |
+
margin=dict(t=20, b=50, l=50, r=20),
|
| 326 |
+
height=280,
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
return fig
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def create_emotion_distribution_chart(emotion_stats: dict) -> go.Figure:
|
| 333 |
+
"""Create bar chart for emotion distribution in nudge candidates."""
|
| 334 |
+
if not emotion_stats:
|
| 335 |
+
return go.Figure()
|
| 336 |
+
|
| 337 |
+
sorted_items = sorted(emotion_stats.items(), key=lambda x: x[1], reverse=True)[:8]
|
| 338 |
+
emotions = [EMOTION_KO.get(item[0], item[0]) for item in sorted_items]
|
| 339 |
+
counts = [item[1] for item in sorted_items]
|
| 340 |
+
|
| 341 |
+
colors = ["#6366F1", "#8B5CF6", "#A855F7", "#D946EF", "#EC4899", "#F43F5E", "#F97316", "#FBBF24"]
|
| 342 |
+
|
| 343 |
+
fig = go.Figure(data=[
|
| 344 |
+
go.Bar(
|
| 345 |
+
x=emotions,
|
| 346 |
+
y=counts,
|
| 347 |
+
marker_color=colors[:len(emotions)],
|
| 348 |
+
text=[f"{c}๊ฑด" for c in counts],
|
| 349 |
+
textposition="outside",
|
| 350 |
+
)
|
| 351 |
+
])
|
| 352 |
+
|
| 353 |
+
fig.update_layout(
|
| 354 |
+
title="",
|
| 355 |
+
yaxis_title="๋์ง ํ๋ณด ์",
|
| 356 |
+
xaxis_tickangle=-30,
|
| 357 |
+
margin=dict(t=20, b=80, l=50, r=20),
|
| 358 |
+
height=280,
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
return fig
|
core/data_fetchers.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""์บ์๋ API ๋ฐ์ดํฐ ํ์ฒ.
|
| 2 |
+
|
| 3 |
+
Streamlit cache๋ฅผ ํ์ฉํ API ํธ์ถ ํจ์๋ค.
|
| 4 |
+
"""
|
| 5 |
+
import streamlit as st
|
| 6 |
+
|
| 7 |
+
from .api_client import ChainShiftClient
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@st.cache_data(ttl=300)
|
| 11 |
+
def get_campaigns(api_key: str = "", access_token: str = ""):
|
| 12 |
+
"""Fetch all campaigns with pagination and caching."""
|
| 13 |
+
client = ChainShiftClient(api_key=api_key or None, access_token=access_token or None)
|
| 14 |
+
all_items: list[dict] = []
|
| 15 |
+
page = 1
|
| 16 |
+
while True:
|
| 17 |
+
resp = client.get_campaigns(page=page, page_size=100)
|
| 18 |
+
items = resp.get("data", {}).get("items", [])
|
| 19 |
+
all_items.extend(items)
|
| 20 |
+
total = resp.get("data", {}).get("total", 0)
|
| 21 |
+
if len(all_items) >= total or not items:
|
| 22 |
+
break
|
| 23 |
+
page += 1
|
| 24 |
+
return {"data": {"items": all_items, "total": len(all_items)}}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@st.cache_data(ttl=60)
|
| 28 |
+
def get_nudge_candidates(
|
| 29 |
+
api_key: str = "",
|
| 30 |
+
campaign_id: int = 0,
|
| 31 |
+
page: int = 1,
|
| 32 |
+
page_size: int = 50,
|
| 33 |
+
platform: str | None = None,
|
| 34 |
+
confidence_tier: str | None = None,
|
| 35 |
+
access_token: str = "",
|
| 36 |
+
):
|
| 37 |
+
"""Fetch nudge candidates (in-house brand negative mentions)."""
|
| 38 |
+
client = ChainShiftClient(api_key=api_key or None, access_token=access_token or None)
|
| 39 |
+
return client.get_nudge_candidates(
|
| 40 |
+
campaign_id,
|
| 41 |
+
page=page,
|
| 42 |
+
page_size=page_size,
|
| 43 |
+
platform=platform if platform and platform != "์ ์ฒด" else None,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@st.cache_data(ttl=60)
|
| 48 |
+
def get_brand_mentions(
|
| 49 |
+
api_key: str = "",
|
| 50 |
+
campaign_id: int = 0,
|
| 51 |
+
brand_type: str | None = None,
|
| 52 |
+
polarity: str | None = None,
|
| 53 |
+
access_token: str = "",
|
| 54 |
+
):
|
| 55 |
+
"""Fetch brand mention analysis."""
|
| 56 |
+
client = ChainShiftClient(api_key=api_key or None, access_token=access_token or None)
|
| 57 |
+
return client.get_brand_mentions(
|
| 58 |
+
campaign_id,
|
| 59 |
+
brand_type=brand_type if brand_type and brand_type != "์ ์ฒด" else None,
|
| 60 |
+
polarity=polarity if polarity and polarity != "์ ์ฒด" else None,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@st.cache_data(ttl=60)
|
| 65 |
+
def get_feedback_stats(api_key: str = "", campaign_id: int = 0, access_token: str = ""):
|
| 66 |
+
"""Fetch feedback statistics for a campaign."""
|
| 67 |
+
try:
|
| 68 |
+
client = ChainShiftClient(api_key=api_key or None, access_token=access_token or None)
|
| 69 |
+
return client.get_feedback_stats(campaign_id)
|
| 70 |
+
except Exception:
|
| 71 |
+
return None
|
core/export_utils.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Export Utilities - ํตํฉ ๋ด๋ณด๋ด๊ธฐ ์ปดํฌ๋ํธ
|
| 3 |
+
|
| 4 |
+
๊ณตํต ๋ด๋ณด๋ด๊ธฐ ๊ธฐ๋ฅ์ ์ ๊ณตํฉ๋๋ค:
|
| 5 |
+
- CSV/Excel ๋ณํ
|
| 6 |
+
- ํํฐ๋ง ์ต์
|
| 7 |
+
- ์ ์ฒด ๋ต๋ณ ํฌํจ ์ต์
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import streamlit as st
|
| 12 |
+
from io import BytesIO
|
| 13 |
+
from typing import Callable
|
| 14 |
+
|
| 15 |
+
from .supabase_client import get_sentiment_data_for_export
|
| 16 |
+
from .athena_client import fetch_full_answers_batch
|
| 17 |
+
|
| 18 |
+
# openpyxl ์ค์น ์ฌ๋ถ ํ์ธ (Excel export์ฉ)
|
| 19 |
+
try:
|
| 20 |
+
import openpyxl
|
| 21 |
+
EXCEL_AVAILABLE = True
|
| 22 |
+
except ImportError:
|
| 23 |
+
EXCEL_AVAILABLE = False
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# LLM ๊ฒ์ฆ ์ํ ๋ผ๋ฒจ (์ฉ์ด ํต์ผ)
|
| 27 |
+
LLM_STATUS_LABELS = {
|
| 28 |
+
"all": "์ ์ฒด",
|
| 29 |
+
"verified": "๊ฒ์ฆ์๋ฃ",
|
| 30 |
+
"false_positive": "์คํ (๋ถ์ โ๋น๋ถ์ )", # ๋ถ์ ์๋
|
| 31 |
+
"true_negative": "์ ํ (๋ถ์ ํ์ )", # ๋ถ์ ํ์
|
| 32 |
+
"unverified": "๋ฏธ๊ฒ์ฆ",
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
POLARITY_LABELS = {
|
| 36 |
+
"all": "์ ์ฒด",
|
| 37 |
+
"negative": "๋ถ์ ",
|
| 38 |
+
"positive": "๊ธ์ ",
|
| 39 |
+
"neutral": "์ค๋ฆฝ",
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def prepare_dataframe_for_export(
|
| 44 |
+
data: list[dict],
|
| 45 |
+
include_full_answers: bool = False,
|
| 46 |
+
) -> pd.DataFrame:
|
| 47 |
+
"""๋ฐ์ดํฐ๋ฅผ DataFrame์ผ๋ก ๋ณํํ๊ณ ๋ด๋ณด๋ด๊ธฐ์ฉ์ผ๋ก ์ ๋ฆฌํฉ๋๋ค.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
data: ๋ด๋ณด๋ผ ๋ฐ์ดํฐ ๋ฆฌ์คํธ
|
| 51 |
+
include_full_answers: ์ ์ฒด ๋ต๋ณ ํฌํจ ์ฌ๋ถ
|
| 52 |
+
|
| 53 |
+
Returns:
|
| 54 |
+
์ ๋ฆฌ๋ DataFrame
|
| 55 |
+
"""
|
| 56 |
+
if not data:
|
| 57 |
+
return pd.DataFrame()
|
| 58 |
+
|
| 59 |
+
df = pd.DataFrame(data)
|
| 60 |
+
|
| 61 |
+
# ๋ฆฌ์คํธ ์ปฌ๋ผ์ ๋ฌธ์์ด๋ก ๋ณํ
|
| 62 |
+
list_columns = ['in_house_brands', 'mentioned_brands', 'llm_evidence_spans']
|
| 63 |
+
for col in list_columns:
|
| 64 |
+
if col in df.columns:
|
| 65 |
+
df[col] = df[col].apply(
|
| 66 |
+
lambda x: ', '.join(x) if isinstance(x, list) else str(x) if x else ''
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# ์ปฌ๋ผ ์์ ์ ๋ฆฌ - answer_full์ answer_preview ๋ค์์ ๋ฐฐ์น
|
| 70 |
+
if 'answer_full' in df.columns and 'answer_preview' in df.columns:
|
| 71 |
+
cols = list(df.columns)
|
| 72 |
+
cols.remove('answer_full')
|
| 73 |
+
idx = cols.index('answer_preview') + 1
|
| 74 |
+
cols.insert(idx, 'answer_full')
|
| 75 |
+
df = df[cols]
|
| 76 |
+
|
| 77 |
+
return df
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def export_to_csv(df: pd.DataFrame) -> bytes:
|
| 81 |
+
"""DataFrame์ CSV ๋ฐ์ดํธ๋ก ๋ณํํฉ๋๋ค."""
|
| 82 |
+
return df.to_csv(index=False).encode('utf-8-sig')
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def export_to_excel(df: pd.DataFrame) -> bytes | None:
|
| 86 |
+
"""DataFrame์ Excel ๋ฐ์ดํธ๋ก ๋ณํํฉ๋๋ค.
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
Excel ๋ฐ์ดํธ ๋ฐ์ดํฐ, ๋๋ openpyxl์ด ์์ผ๋ฉด None
|
| 90 |
+
"""
|
| 91 |
+
if not EXCEL_AVAILABLE:
|
| 92 |
+
return None
|
| 93 |
+
output = BytesIO()
|
| 94 |
+
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
| 95 |
+
df.to_excel(writer, index=False, sheet_name='Data')
|
| 96 |
+
return output.getvalue()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def render_export_component(
|
| 100 |
+
campaign_id: int,
|
| 101 |
+
key_prefix: str,
|
| 102 |
+
title: str = "๐ฅ ๋ฐ์ดํฐ ๋ด๋ณด๋ด๊ธฐ",
|
| 103 |
+
show_polarity_filter: bool = True,
|
| 104 |
+
show_llm_filter: bool = True,
|
| 105 |
+
default_polarity: str = "negative",
|
| 106 |
+
default_llm_status: str = "all",
|
| 107 |
+
in_house_only: bool = True,
|
| 108 |
+
):
|
| 109 |
+
"""ํตํฉ ๋ด๋ณด๋ด๊ธฐ ์ปดํฌ๋ํธ๋ฅผ ๋ ๋๋งํฉ๋๋ค.
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
campaign_id: ์บ ํ์ธ ID
|
| 113 |
+
key_prefix: Streamlit ์์ ฏ ํค ์ ๋์ฌ (์ค๋ณต ๋ฐฉ์ง)
|
| 114 |
+
title: ์น์
์ ๋ชฉ
|
| 115 |
+
show_polarity_filter: ๊ฐ์ ํํฐ ํ์ ์ฌ๋ถ
|
| 116 |
+
show_llm_filter: LLM ์ํ ํํฐ ํ์ ์ฌ๋ถ
|
| 117 |
+
default_polarity: ๊ธฐ๋ณธ ๊ฐ์ ํํฐ ๊ฐ
|
| 118 |
+
default_llm_status: ๊ธฐ๋ณธ LLM ์ํ ํํฐ ๊ฐ
|
| 119 |
+
in_house_only: ์์ฌ ๋ธ๋๋๋ง ํํฐ๋ง
|
| 120 |
+
"""
|
| 121 |
+
with st.expander(title, expanded=False):
|
| 122 |
+
# ํํฐ ์ต์
|
| 123 |
+
filter_col1, filter_col2 = st.columns(2)
|
| 124 |
+
|
| 125 |
+
with filter_col1:
|
| 126 |
+
if show_polarity_filter:
|
| 127 |
+
polarity_options = list(POLARITY_LABELS.keys())
|
| 128 |
+
polarity_labels = list(POLARITY_LABELS.values())
|
| 129 |
+
default_idx = polarity_options.index(default_polarity) if default_polarity in polarity_options else 0
|
| 130 |
+
selected_polarity = st.selectbox(
|
| 131 |
+
"๊ฐ์ ํํฐ",
|
| 132 |
+
options=polarity_options,
|
| 133 |
+
format_func=lambda x: POLARITY_LABELS[x],
|
| 134 |
+
index=default_idx,
|
| 135 |
+
key=f"{key_prefix}_polarity"
|
| 136 |
+
)
|
| 137 |
+
else:
|
| 138 |
+
selected_polarity = default_polarity
|
| 139 |
+
|
| 140 |
+
with filter_col2:
|
| 141 |
+
if show_llm_filter:
|
| 142 |
+
llm_options = list(LLM_STATUS_LABELS.keys())
|
| 143 |
+
default_idx = llm_options.index(default_llm_status) if default_llm_status in llm_options else 0
|
| 144 |
+
selected_llm_status = st.selectbox(
|
| 145 |
+
"LLM ๊ฒ์ฆ ์ํ",
|
| 146 |
+
options=llm_options,
|
| 147 |
+
format_func=lambda x: LLM_STATUS_LABELS[x],
|
| 148 |
+
index=default_idx,
|
| 149 |
+
key=f"{key_prefix}_llm_status"
|
| 150 |
+
)
|
| 151 |
+
else:
|
| 152 |
+
selected_llm_status = default_llm_status
|
| 153 |
+
|
| 154 |
+
# ๋ด๋ณด๋ด๊ธฐ ์ต์
|
| 155 |
+
opt_col1, opt_col2 = st.columns(2)
|
| 156 |
+
with opt_col1:
|
| 157 |
+
include_full_answers = st.checkbox(
|
| 158 |
+
"์ ์ฒด ๋ต๋ณ ํฌํจ",
|
| 159 |
+
value=False,
|
| 160 |
+
help="Athena์์ ์ ์ฒด ๋ต๋ณ์ ๊ฐ์ ธ์ต๋๋ค (ํ์ผ ํฌ๊ธฐ ์ฆ๊ฐ)",
|
| 161 |
+
key=f"{key_prefix}_full_answers"
|
| 162 |
+
)
|
| 163 |
+
with opt_col2:
|
| 164 |
+
include_evidence = st.checkbox(
|
| 165 |
+
"LLM ๊ทผ๊ฑฐ ํฌํจ",
|
| 166 |
+
value=False,
|
| 167 |
+
help="LLM ํ๋จ ๊ทผ๊ฑฐ(reasoning, evidence_spans)๋ฅผ ํฌํจํฉ๋๋ค",
|
| 168 |
+
key=f"{key_prefix}_evidence"
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
st.markdown("---")
|
| 172 |
+
|
| 173 |
+
# ๋ค์ด๋ก๋ ๋ฒํผ
|
| 174 |
+
btn_col1, btn_col2, btn_col3 = st.columns([1, 1, 2])
|
| 175 |
+
|
| 176 |
+
# ๋ฐ์ดํฐ ๊ฐ์ ธ์ค๊ธฐ
|
| 177 |
+
data = get_sentiment_data_for_export(
|
| 178 |
+
campaign_id=campaign_id,
|
| 179 |
+
polarity=selected_polarity if selected_polarity != "all" else None,
|
| 180 |
+
llm_status=selected_llm_status if selected_llm_status != "all" else None,
|
| 181 |
+
in_house_only=in_house_only,
|
| 182 |
+
include_full_answers=include_full_answers,
|
| 183 |
+
include_evidence=include_evidence,
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
if data:
|
| 187 |
+
df = prepare_dataframe_for_export(data, include_full_answers)
|
| 188 |
+
count = len(df)
|
| 189 |
+
|
| 190 |
+
with btn_col1:
|
| 191 |
+
csv_data = export_to_csv(df)
|
| 192 |
+
st.download_button(
|
| 193 |
+
label=f"๐ฅ CSV ({count}๊ฑด)",
|
| 194 |
+
data=csv_data,
|
| 195 |
+
file_name=f"campaign_{campaign_id}_export_{count}๊ฑด.csv",
|
| 196 |
+
mime="text/csv",
|
| 197 |
+
key=f"{key_prefix}_csv_download"
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
with btn_col2:
|
| 201 |
+
if EXCEL_AVAILABLE:
|
| 202 |
+
excel_data = export_to_excel(df)
|
| 203 |
+
st.download_button(
|
| 204 |
+
label=f"๐ฅ Excel ({count}๊ฑด)",
|
| 205 |
+
data=excel_data,
|
| 206 |
+
file_name=f"campaign_{campaign_id}_export_{count}๊ฑด.xlsx",
|
| 207 |
+
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 208 |
+
key=f"{key_prefix}_excel_download"
|
| 209 |
+
)
|
| 210 |
+
else:
|
| 211 |
+
st.caption("Excel: openpyxl ํ์")
|
| 212 |
+
|
| 213 |
+
with btn_col3:
|
| 214 |
+
st.caption(f"์ด {count}๊ฑด | ํํฐ: {POLARITY_LABELS.get(selected_polarity, '์ ์ฒด')} / {LLM_STATUS_LABELS.get(selected_llm_status, '์ ์ฒด')}")
|
| 215 |
+
else:
|
| 216 |
+
st.info("๋ด๋ณด๋ผ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.")
|
core/job_realtime.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sentiment Analysis Job ์ค์๊ฐ ๋ชจ๋ํฐ๋ง (Phase 4.1)
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
from job_realtime import get_active_jobs, get_job_by_id
|
| 5 |
+
|
| 6 |
+
# ์คํ ์ค์ธ Job ๋ชฉ๋ก
|
| 7 |
+
jobs = get_active_jobs(campaign_id=27)
|
| 8 |
+
|
| 9 |
+
# ํน์ Job ์์ธ
|
| 10 |
+
job = get_job_by_id("abc-123")
|
| 11 |
+
|
| 12 |
+
Note:
|
| 13 |
+
Streamlit์ WebSocket์ ์ง์ ์ง์ํ์ง ์์ผ๋ฏ๋ก,
|
| 14 |
+
st.rerun() ๋๋ st.cache_data(ttl=5)๋ก ํด๋ง ๋ฐฉ์ ์ฌ์ฉ
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
from datetime import datetime, timezone
|
| 19 |
+
from functools import lru_cache
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Literal
|
| 22 |
+
|
| 23 |
+
import streamlit as st
|
| 24 |
+
from supabase import create_client, Client
|
| 25 |
+
from dotenv import load_dotenv
|
| 26 |
+
|
| 27 |
+
# Load environment variables (project root)
|
| 28 |
+
_project_root = Path(__file__).parent.parent.parent.parent
|
| 29 |
+
for _env_name in (".env.dev", ".env.prod"):
|
| 30 |
+
_env_path = _project_root / _env_name
|
| 31 |
+
if _env_path.exists():
|
| 32 |
+
load_dotenv(_env_path, override=True)
|
| 33 |
+
break
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@lru_cache()
|
| 37 |
+
def _get_client() -> Client:
|
| 38 |
+
"""Supabase ํด๋ผ์ด์ธํธ (์บ์๋จ)"""
|
| 39 |
+
url = os.environ.get("SUPABASE_URL", "")
|
| 40 |
+
key = os.environ.get("SUPABASE_SERVICE_KEY", "")
|
| 41 |
+
if not url or not key:
|
| 42 |
+
raise ValueError("Missing SUPABASE_URL or SUPABASE_SERVICE_KEY")
|
| 43 |
+
return create_client(url, key)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
JobStatus = Literal["queued", "running", "completed", "failed", "cancelled"]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@st.cache_data(ttl=5)
|
| 50 |
+
def get_active_jobs(campaign_id: int | None = None, limit: int = 10) -> list[dict]:
|
| 51 |
+
"""์คํ ์ค์ด๊ฑฐ๋ ๋๊ธฐ ์ค์ธ Job ๋ชฉ๋ก ์กฐํ
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
campaign_id: ํน์ ์บ ํ์ธ๋ง ํํฐ๋ง (None์ด๋ฉด ์ ์ฒด)
|
| 55 |
+
limit: ์ต๋ ๊ฒฐ๊ณผ ์
|
| 56 |
+
|
| 57 |
+
Returns:
|
| 58 |
+
Job ๋ชฉ๋ก (์ต์ ์ ์ ๋ ฌ)
|
| 59 |
+
"""
|
| 60 |
+
client = _get_client()
|
| 61 |
+
query = client.table("sentiment_analysis_jobs").select(
|
| 62 |
+
"id, campaign_id, status, progress, message, "
|
| 63 |
+
"total_answers, processed_answers, nudge_candidates, "
|
| 64 |
+
"created_at, started_at, completed_at, error_message"
|
| 65 |
+
).in_("status", ["queued", "running"])
|
| 66 |
+
|
| 67 |
+
if campaign_id:
|
| 68 |
+
query = query.eq("campaign_id", campaign_id)
|
| 69 |
+
|
| 70 |
+
query = query.order("created_at", desc=True).limit(limit)
|
| 71 |
+
result = query.execute()
|
| 72 |
+
return result.data or []
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@st.cache_data(ttl=5)
|
| 76 |
+
def get_recent_jobs(
|
| 77 |
+
campaign_id: int | None = None,
|
| 78 |
+
status: JobStatus | None = None,
|
| 79 |
+
limit: int = 20,
|
| 80 |
+
) -> list[dict]:
|
| 81 |
+
"""์ต๊ทผ Job ๋ชฉ๋ก ์กฐํ (ํ์คํ ๋ฆฌ์ฉ)
|
| 82 |
+
|
| 83 |
+
Args:
|
| 84 |
+
campaign_id: ํน์ ์บ ํ์ธ๋ง ํํฐ๋ง
|
| 85 |
+
status: ํน์ ์ํ๋ง ํํฐ๋ง
|
| 86 |
+
limit: ์ต๋ ๊ฒฐ๊ณผ ์
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
Job ๋ชฉ๋ก (์ต์ ์ ์ ๋ ฌ)
|
| 90 |
+
"""
|
| 91 |
+
client = _get_client()
|
| 92 |
+
query = client.table("sentiment_analysis_jobs").select(
|
| 93 |
+
"id, campaign_id, status, progress, message, "
|
| 94 |
+
"total_answers, processed_answers, nudge_candidates, "
|
| 95 |
+
"created_at, started_at, completed_at, error_message"
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
if campaign_id:
|
| 99 |
+
query = query.eq("campaign_id", campaign_id)
|
| 100 |
+
if status:
|
| 101 |
+
query = query.eq("status", status)
|
| 102 |
+
|
| 103 |
+
query = query.order("created_at", desc=True).limit(limit)
|
| 104 |
+
result = query.execute()
|
| 105 |
+
return result.data or []
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def get_job_by_id(job_id: str) -> dict | None:
|
| 109 |
+
"""ํน์ Job ์์ธ ์กฐํ
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
job_id: Job ID
|
| 113 |
+
|
| 114 |
+
Returns:
|
| 115 |
+
Job ์ ๋ณด ๋๋ None
|
| 116 |
+
"""
|
| 117 |
+
client = _get_client()
|
| 118 |
+
result = client.table("sentiment_analysis_jobs").select(
|
| 119 |
+
"id, campaign_id, status, progress, message, "
|
| 120 |
+
"total_answers, processed_answers, nudge_candidates, "
|
| 121 |
+
"created_at, started_at, completed_at, error_message"
|
| 122 |
+
).eq("id", job_id).execute()
|
| 123 |
+
return result.data[0] if result.data else None
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def format_job_duration(job: dict) -> str:
|
| 127 |
+
"""Job ์์ ์๊ฐ ํฌ๋งทํ
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
job: Job ์ ๋ณด dict
|
| 131 |
+
|
| 132 |
+
Returns:
|
| 133 |
+
"2์๊ฐ 15๋ถ" ํํ์ ๋ฌธ์์ด
|
| 134 |
+
"""
|
| 135 |
+
started_at = job.get("started_at")
|
| 136 |
+
completed_at = job.get("completed_at")
|
| 137 |
+
|
| 138 |
+
if not started_at:
|
| 139 |
+
return "-"
|
| 140 |
+
|
| 141 |
+
try:
|
| 142 |
+
# ISO ๋ฌธ์์ด ํ์ฑ
|
| 143 |
+
if isinstance(started_at, str):
|
| 144 |
+
start = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
| 145 |
+
else:
|
| 146 |
+
start = started_at
|
| 147 |
+
|
| 148 |
+
if completed_at:
|
| 149 |
+
if isinstance(completed_at, str):
|
| 150 |
+
end = datetime.fromisoformat(completed_at.replace("Z", "+00:00"))
|
| 151 |
+
else:
|
| 152 |
+
end = completed_at
|
| 153 |
+
else:
|
| 154 |
+
end = datetime.now(timezone.utc)
|
| 155 |
+
|
| 156 |
+
seconds = int((end - start).total_seconds())
|
| 157 |
+
|
| 158 |
+
if seconds < 60:
|
| 159 |
+
return f"{seconds}์ด"
|
| 160 |
+
elif seconds < 3600:
|
| 161 |
+
return f"{seconds // 60}๋ถ"
|
| 162 |
+
else:
|
| 163 |
+
hours = seconds // 3600
|
| 164 |
+
minutes = (seconds % 3600) // 60
|
| 165 |
+
if minutes > 0:
|
| 166 |
+
return f"{hours}์๊ฐ {minutes}๋ถ"
|
| 167 |
+
return f"{hours}์๊ฐ"
|
| 168 |
+
except Exception:
|
| 169 |
+
return "-"
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def get_status_emoji(status: str) -> str:
|
| 173 |
+
"""Job ์ํ ์ด๋ชจ์ง ๋ฐํ"""
|
| 174 |
+
return {
|
| 175 |
+
"queued": "๐ก",
|
| 176 |
+
"running": "๐ต",
|
| 177 |
+
"completed": "๐ข",
|
| 178 |
+
"failed": "๐ด",
|
| 179 |
+
"cancelled": "โช",
|
| 180 |
+
}.get(status, "โช")
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def get_status_label(status: str) -> str:
|
| 184 |
+
"""Job ์ํ ํ๊ธ ๋ผ๋ฒจ ๋ฐํ"""
|
| 185 |
+
return {
|
| 186 |
+
"queued": "๋๊ธฐ ์ค",
|
| 187 |
+
"running": "์คํ ์ค",
|
| 188 |
+
"processing": "์ฒ๋ฆฌ ์ค",
|
| 189 |
+
"completed": "์๋ฃ",
|
| 190 |
+
"failed": "์คํจ",
|
| 191 |
+
"cancelled": "์ทจ์๋จ",
|
| 192 |
+
}.get(status, status)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# ============================================================================
|
| 196 |
+
# Hierarchy Analysis Jobs (hierarchy_analysis_jobs table)
|
| 197 |
+
# ============================================================================
|
| 198 |
+
|
| 199 |
+
_HIERARCHY_FIELDS = (
|
| 200 |
+
"id, user_id, prompt, title, status, progress, current_step, "
|
| 201 |
+
"created_at, started_at, completed_at, error_message"
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
_HIERARCHY_STEP_LABELS: dict[str, str] = {
|
| 205 |
+
"prompt_enhancement": "ํ๋กฌํํธ ๋ถ์",
|
| 206 |
+
"query_generation": "์ฟผ๋ฆฌ ์์ฑ",
|
| 207 |
+
"keyword_data": "ํค์๋ ์์ง",
|
| 208 |
+
"hierarchy_generation": "๊ณ์ธต ๊ตฌ์กฐ ์์ฑ",
|
| 209 |
+
"search_volume": "๊ฒ์๋ ์กฐํ",
|
| 210 |
+
"question_generation": "์ง๋ฌธ ์์ฑ",
|
| 211 |
+
"ratio_supplement": "๋น์จ ๋ณด์ ",
|
| 212 |
+
"finalization": "์ต์ข
์ ์ฅ",
|
| 213 |
+
"done": "์๋ฃ",
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def get_hierarchy_step_label(step: str | None) -> str:
|
| 218 |
+
"""Hierarchy ํ์ดํ๋ผ์ธ ์คํ
ํ๊ธ ๋ผ๋ฒจ ๋ฐํ"""
|
| 219 |
+
if not step:
|
| 220 |
+
return "๋๊ธฐ ์ค"
|
| 221 |
+
return _HIERARCHY_STEP_LABELS.get(step, step)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def get_active_hierarchy_jobs(user_id: str | None = None, limit: int = 5) -> list[dict]:
|
| 225 |
+
"""์คํ ์ค์ด๊ฑฐ๋ ๋๊ธฐ ์ค์ธ Hierarchy Job ๋ชฉ๋ก (Supabase ์ง์ ์ฟผ๋ฆฌ)
|
| 226 |
+
|
| 227 |
+
Args:
|
| 228 |
+
user_id: ์ฌ์ฉ์ ID๋ก ํํฐ๋ง (None์ด๋ฉด ์ ์ฒด)
|
| 229 |
+
limit: ์ต๋ ๊ฒฐ๊ณผ ์
|
| 230 |
+
"""
|
| 231 |
+
client = _get_client()
|
| 232 |
+
query = (
|
| 233 |
+
client.table("hierarchy_analysis_jobs")
|
| 234 |
+
.select(_HIERARCHY_FIELDS)
|
| 235 |
+
.in_("status", ["queued", "processing"])
|
| 236 |
+
)
|
| 237 |
+
if user_id:
|
| 238 |
+
query = query.eq("user_id", user_id)
|
| 239 |
+
query = query.order("created_at", desc=True).limit(limit)
|
| 240 |
+
result = query.execute()
|
| 241 |
+
return result.data or []
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def get_recent_hierarchy_jobs(
|
| 245 |
+
user_id: str | None = None,
|
| 246 |
+
status: str | None = None,
|
| 247 |
+
limit: int = 20,
|
| 248 |
+
) -> list[dict]:
|
| 249 |
+
"""์ต๊ทผ Hierarchy Job ์ด๋ ฅ ์กฐํ
|
| 250 |
+
|
| 251 |
+
Args:
|
| 252 |
+
user_id: ์ฌ์ฉ์ ID๋ก ํํฐ๋ง
|
| 253 |
+
status: ํน์ ์ํ๋ง ํํฐ๋ง
|
| 254 |
+
limit: ์ต๋ ๊ฒฐ๊ณผ ์
|
| 255 |
+
"""
|
| 256 |
+
client = _get_client()
|
| 257 |
+
query = client.table("hierarchy_analysis_jobs").select(_HIERARCHY_FIELDS)
|
| 258 |
+
if user_id:
|
| 259 |
+
query = query.eq("user_id", user_id)
|
| 260 |
+
if status:
|
| 261 |
+
query = query.eq("status", status)
|
| 262 |
+
query = query.order("created_at", desc=True).limit(limit)
|
| 263 |
+
result = query.execute()
|
| 264 |
+
return result.data or []
|
core/styles.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CSS Styles for Streamlit Dashboard.
|
| 2 |
+
|
| 3 |
+
Uses ChainShift Brand Colors (2026 Design System).
|
| 4 |
+
Primary: Electric Indigo (#5041FF)
|
| 5 |
+
Accent: Neo Aqua (#00D5B5)
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
# ============================================================================
|
| 9 |
+
# Brand Colors (synced with app/core/brand.py)
|
| 10 |
+
# ============================================================================
|
| 11 |
+
INDIGO_700 = "#5041FF" # Primary
|
| 12 |
+
INDIGO_800 = "#3E32CC" # Primary Dark
|
| 13 |
+
INDIGO_100 = "#E5E2FF" # Primary Light BG
|
| 14 |
+
AQUA_600 = "#00D5B5" # Accent
|
| 15 |
+
AQUA_100 = "#B8FFF6" # Accent Light BG
|
| 16 |
+
POSITIVE = "#10B981" # Green
|
| 17 |
+
NEGATIVE = "#EF4444" # Red
|
| 18 |
+
NEUTRAL = "#6B7280" # Gray
|
| 19 |
+
WARNING = "#F59E0B" # Amber
|
| 20 |
+
|
| 21 |
+
DASHBOARD_CSS = f"""
|
| 22 |
+
<style>
|
| 23 |
+
.insight-card {{
|
| 24 |
+
background: linear-gradient(135deg, {INDIGO_800} 0%, {INDIGO_700} 100%);
|
| 25 |
+
padding: 20px;
|
| 26 |
+
border-radius: 12px;
|
| 27 |
+
color: white;
|
| 28 |
+
margin-bottom: 20px;
|
| 29 |
+
}}
|
| 30 |
+
.metric-positive {{ color: {POSITIVE}; font-weight: bold; }}
|
| 31 |
+
.metric-negative {{ color: {NEGATIVE}; font-weight: bold; }}
|
| 32 |
+
.metric-neutral {{ color: {NEUTRAL}; }}
|
| 33 |
+
.highlight-box {{
|
| 34 |
+
background: {AQUA_100};
|
| 35 |
+
border-left: 4px solid {AQUA_600};
|
| 36 |
+
padding: 15px;
|
| 37 |
+
margin: 10px 0;
|
| 38 |
+
border-radius: 0 8px 8px 0;
|
| 39 |
+
}}
|
| 40 |
+
.warning-box {{
|
| 41 |
+
background: #FEF2F2;
|
| 42 |
+
border-left: 4px solid {NEGATIVE};
|
| 43 |
+
padding: 15px;
|
| 44 |
+
margin: 10px 0;
|
| 45 |
+
border-radius: 0 8px 8px 0;
|
| 46 |
+
}}
|
| 47 |
+
.info-box {{
|
| 48 |
+
background: {INDIGO_100};
|
| 49 |
+
border-left: 4px solid {INDIGO_700};
|
| 50 |
+
padding: 15px;
|
| 51 |
+
margin: 10px 0;
|
| 52 |
+
border-radius: 0 8px 8px 0;
|
| 53 |
+
}}
|
| 54 |
+
.big-number {{
|
| 55 |
+
font-size: 48px;
|
| 56 |
+
font-weight: bold;
|
| 57 |
+
line-height: 1;
|
| 58 |
+
color: {INDIGO_700};
|
| 59 |
+
}}
|
| 60 |
+
.filter-section {{
|
| 61 |
+
background: #F8FAFC;
|
| 62 |
+
padding: 15px;
|
| 63 |
+
border-radius: 8px;
|
| 64 |
+
margin-bottom: 15px;
|
| 65 |
+
}}
|
| 66 |
+
/* Primary button style */
|
| 67 |
+
.stButton > button {{
|
| 68 |
+
background-color: {INDIGO_700};
|
| 69 |
+
color: white;
|
| 70 |
+
border: none;
|
| 71 |
+
}}
|
| 72 |
+
.stButton > button:hover {{
|
| 73 |
+
background-color: {INDIGO_800};
|
| 74 |
+
}}
|
| 75 |
+
</style>
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
# Tier color mapping for nudge candidates
|
| 79 |
+
TIER_BORDER_COLORS = {
|
| 80 |
+
"HIGH": NEGATIVE,
|
| 81 |
+
"MEDIUM": WARNING,
|
| 82 |
+
"LOW": POSITIVE,
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
# Chart colors for Streamlit charts (plotly, altair)
|
| 86 |
+
CHART_COLORS = [
|
| 87 |
+
INDIGO_700, # Primary
|
| 88 |
+
AQUA_600, # Accent
|
| 89 |
+
"#9585FF", # Indigo 500
|
| 90 |
+
"#3DF1E7", # Aqua 400
|
| 91 |
+
"#3E32CC", # Indigo 800
|
| 92 |
+
"#06A68D", # Aqua 700
|
| 93 |
+
]
|
core/supabase_action_items.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Supabase action items & reports queries.
|
| 2 |
+
|
| 3 |
+
Module-level functions extracted from supabase_client.py.
|
| 4 |
+
All functions use get_supabase_client() from the parent module.
|
| 5 |
+
"""
|
| 6 |
+
import streamlit as st
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def get_action_items(
|
| 10 |
+
campaign_id: int,
|
| 11 |
+
status: str | None = None,
|
| 12 |
+
category: str | None = None,
|
| 13 |
+
page: int = 1,
|
| 14 |
+
page_size: int = 50,
|
| 15 |
+
order_by: str = "created_at",
|
| 16 |
+
desc: bool = True,
|
| 17 |
+
) -> tuple[list[dict], int]:
|
| 18 |
+
"""Get action items for a campaign with filters and sorting.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
order_by: Column to sort by (created_at, priority, category, status)
|
| 22 |
+
desc: True for descending, False for ascending
|
| 23 |
+
|
| 24 |
+
Returns (items, total_count).
|
| 25 |
+
"""
|
| 26 |
+
from core.supabase_client import get_supabase_client
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
client = get_supabase_client()
|
| 30 |
+
query = (
|
| 31 |
+
client.table("action_items")
|
| 32 |
+
.select(
|
| 33 |
+
"id, campaign_id, trigger_rule_id, category, priority, label, "
|
| 34 |
+
"evidence, llm_recommendation, status, assignee_email, "
|
| 35 |
+
"created_at, completed_at",
|
| 36 |
+
count="planned",
|
| 37 |
+
)
|
| 38 |
+
.eq("campaign_id", campaign_id)
|
| 39 |
+
)
|
| 40 |
+
if status:
|
| 41 |
+
query = query.eq("status", status)
|
| 42 |
+
if category:
|
| 43 |
+
query = query.eq("category", category)
|
| 44 |
+
|
| 45 |
+
offset = (page - 1) * page_size
|
| 46 |
+
query = (
|
| 47 |
+
query.order(order_by, desc=desc)
|
| 48 |
+
.range(offset, offset + page_size - 1)
|
| 49 |
+
)
|
| 50 |
+
result = query.execute()
|
| 51 |
+
return result.data or [], result.count or 0
|
| 52 |
+
except Exception:
|
| 53 |
+
return [], 0
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@st.cache_data(ttl=60)
|
| 57 |
+
def get_action_item_stats(campaign_id: int) -> dict:
|
| 58 |
+
"""Get action item stats via RPC (COUNT FILTER pattern)."""
|
| 59 |
+
_empty = {"pending": 0, "in_progress": 0, "completed": 0, "archived": 0, "total": 0}
|
| 60 |
+
try:
|
| 61 |
+
from core.supabase_client import get_supabase_client
|
| 62 |
+
|
| 63 |
+
client = get_supabase_client()
|
| 64 |
+
result = client.rpc(
|
| 65 |
+
"get_action_item_stats", {"p_campaign_id": campaign_id}
|
| 66 |
+
).execute()
|
| 67 |
+
data = result.data
|
| 68 |
+
if isinstance(data, list) and data:
|
| 69 |
+
data = data[0]
|
| 70 |
+
if isinstance(data, str):
|
| 71 |
+
import json
|
| 72 |
+
data = json.loads(data)
|
| 73 |
+
return data if isinstance(data, dict) else _empty
|
| 74 |
+
except Exception:
|
| 75 |
+
return _empty
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def update_action_item_status(item_id: str, new_status: str) -> bool:
|
| 79 |
+
"""Update action item status. Returns True on success."""
|
| 80 |
+
try:
|
| 81 |
+
from core.supabase_client import get_supabase_client
|
| 82 |
+
|
| 83 |
+
client = get_supabase_client()
|
| 84 |
+
result = (
|
| 85 |
+
client.table("action_items")
|
| 86 |
+
.update({"status": new_status})
|
| 87 |
+
.eq("id", item_id)
|
| 88 |
+
.execute()
|
| 89 |
+
)
|
| 90 |
+
return bool(result.data)
|
| 91 |
+
except Exception:
|
| 92 |
+
return False
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def delete_action_item(item_id: str) -> bool:
|
| 96 |
+
"""Delete action item. Returns True on success."""
|
| 97 |
+
try:
|
| 98 |
+
from core.supabase_client import get_supabase_client
|
| 99 |
+
|
| 100 |
+
client = get_supabase_client()
|
| 101 |
+
result = (
|
| 102 |
+
client.table("action_items")
|
| 103 |
+
.delete()
|
| 104 |
+
.eq("id", item_id)
|
| 105 |
+
.execute()
|
| 106 |
+
)
|
| 107 |
+
return bool(result.data)
|
| 108 |
+
except Exception:
|
| 109 |
+
return False
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def save_action_items_batch(
|
| 113 |
+
campaign_id: int,
|
| 114 |
+
user_id: str,
|
| 115 |
+
items: list[dict],
|
| 116 |
+
report_id: str | None = None,
|
| 117 |
+
) -> dict:
|
| 118 |
+
"""Save action items with dedup (check existing active items).
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
campaign_id: Campaign ID
|
| 122 |
+
user_id: User UUID
|
| 123 |
+
items: List of dicts with trigger_rule_id, category, priority, label, evidence
|
| 124 |
+
report_id: Optional report UUID
|
| 125 |
+
|
| 126 |
+
Returns:
|
| 127 |
+
{"created": N, "skipped": N}
|
| 128 |
+
"""
|
| 129 |
+
try:
|
| 130 |
+
from core.supabase_client import get_supabase_client
|
| 131 |
+
|
| 132 |
+
client = get_supabase_client()
|
| 133 |
+
|
| 134 |
+
# Batch dedup check: single query instead of N queries
|
| 135 |
+
trigger_ids = [item["trigger_rule_id"] for item in items]
|
| 136 |
+
existing_result = (
|
| 137 |
+
client.table("action_items")
|
| 138 |
+
.select("trigger_rule_id")
|
| 139 |
+
.eq("campaign_id", campaign_id)
|
| 140 |
+
.in_("trigger_rule_id", trigger_ids)
|
| 141 |
+
.in_("status", ["pending", "in_progress"])
|
| 142 |
+
.execute()
|
| 143 |
+
)
|
| 144 |
+
existing_set = {r["trigger_rule_id"] for r in (existing_result.data or [])}
|
| 145 |
+
|
| 146 |
+
rows_to_insert = []
|
| 147 |
+
skipped = 0
|
| 148 |
+
for item in items:
|
| 149 |
+
if item["trigger_rule_id"] in existing_set:
|
| 150 |
+
skipped += 1
|
| 151 |
+
continue
|
| 152 |
+
rows_to_insert.append({
|
| 153 |
+
"campaign_id": campaign_id,
|
| 154 |
+
"user_id": user_id,
|
| 155 |
+
"report_id": report_id,
|
| 156 |
+
"trigger_rule_id": item["trigger_rule_id"],
|
| 157 |
+
"category": item["category"],
|
| 158 |
+
"priority": item["priority"],
|
| 159 |
+
"label": item["label"],
|
| 160 |
+
"evidence": item.get("evidence"),
|
| 161 |
+
"llm_recommendation": item.get("llm_recommendation"),
|
| 162 |
+
"status": "pending",
|
| 163 |
+
})
|
| 164 |
+
|
| 165 |
+
created = 0
|
| 166 |
+
if rows_to_insert:
|
| 167 |
+
client.table("action_items").insert(rows_to_insert).execute()
|
| 168 |
+
created = len(rows_to_insert)
|
| 169 |
+
|
| 170 |
+
return {"created": created, "skipped": skipped}
|
| 171 |
+
except Exception as e:
|
| 172 |
+
return {"created": 0, "skipped": 0, "error": str(e)}
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def get_campaign_date_range(campaign_id: int) -> tuple[str, str] | None:
|
| 176 |
+
"""Get first and last data dates for a campaign via RPC (single query).
|
| 177 |
+
|
| 178 |
+
Returns:
|
| 179 |
+
Tuple of (first_date, last_date) as strings, or None if no data
|
| 180 |
+
"""
|
| 181 |
+
from core.supabase_client import get_supabase_client
|
| 182 |
+
|
| 183 |
+
client = get_supabase_client()
|
| 184 |
+
result = client.rpc("get_campaign_date_range_agg", {"p_campaign_id": campaign_id}).execute()
|
| 185 |
+
|
| 186 |
+
data = result.data
|
| 187 |
+
# PostgREST may wrap json return as [dict] or dict
|
| 188 |
+
if isinstance(data, list) and data:
|
| 189 |
+
data = data[0]
|
| 190 |
+
if isinstance(data, dict) and data.get("first_date") and data.get("last_date"):
|
| 191 |
+
return (data["first_date"], data["last_date"])
|
| 192 |
+
return None
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def get_report_history_count(campaign_id: int) -> int:
|
| 196 |
+
"""Get total count of generated HTML reports for a campaign."""
|
| 197 |
+
from core.supabase_client import get_supabase_client
|
| 198 |
+
|
| 199 |
+
client = get_supabase_client()
|
| 200 |
+
result = (
|
| 201 |
+
client.table("html_reports")
|
| 202 |
+
.select("id", count="planned")
|
| 203 |
+
.eq("campaign_id", campaign_id)
|
| 204 |
+
.execute()
|
| 205 |
+
)
|
| 206 |
+
return result.count or 0
|
core/supabase_client.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Supabase client for dashboard - direct queries for LLM verification data.
|
| 2 |
+
|
| 3 |
+
Domain queries are in separate modules:
|
| 4 |
+
- supabase_sentiment.py: Overview, LLM verification, polarity
|
| 5 |
+
- supabase_research.py: Topic clusters, cross-model analysis
|
| 6 |
+
- supabase_action_items.py: Action items CRUD, reports, date range
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
from functools import lru_cache
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from supabase import create_client, Client
|
| 14 |
+
from dotenv import load_dotenv
|
| 15 |
+
|
| 16 |
+
# Load environment variables (project root)
|
| 17 |
+
_project_root = Path(__file__).parent.parent.parent.parent
|
| 18 |
+
for _env_name in (".env.dev", ".env.prod"):
|
| 19 |
+
_env_path = _project_root / _env_name
|
| 20 |
+
if _env_path.exists():
|
| 21 |
+
load_dotenv(_env_path, override=True)
|
| 22 |
+
break
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@lru_cache()
|
| 26 |
+
def get_supabase_client() -> Client:
|
| 27 |
+
"""Create Supabase client for unified database.
|
| 28 |
+
|
| 29 |
+
Uses service_role key (bypasses RLS) because the dashboard is an
|
| 30 |
+
internal admin tool that needs cross-campaign analytics.
|
| 31 |
+
DO NOT use this client in user-facing API routes.
|
| 32 |
+
"""
|
| 33 |
+
url = os.environ.get("SUPABASE_URL", "")
|
| 34 |
+
key = os.environ.get("SUPABASE_SERVICE_KEY", "")
|
| 35 |
+
if not url or not key:
|
| 36 |
+
raise ValueError("Missing SUPABASE_URL or SUPABASE_SERVICE_KEY")
|
| 37 |
+
return create_client(url, key)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ============================================================================
|
| 41 |
+
# Re-exports for backward compatibility
|
| 42 |
+
# All consumers import from core.supabase_client โ these re-exports
|
| 43 |
+
# ensure existing imports continue to work unchanged.
|
| 44 |
+
# ============================================================================
|
| 45 |
+
|
| 46 |
+
# Sentiment / Overview / LLM Verification
|
| 47 |
+
from core.supabase_sentiment import ( # noqa: E402, F401
|
| 48 |
+
get_campaign_overview,
|
| 49 |
+
get_llm_verification_stats,
|
| 50 |
+
get_false_positives,
|
| 51 |
+
get_true_negatives,
|
| 52 |
+
get_llm_verified_for_export,
|
| 53 |
+
get_sentiment_data_for_export,
|
| 54 |
+
get_answers_by_polarity,
|
| 55 |
+
get_polarity_stats,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# Research / Topics
|
| 59 |
+
from core.supabase_research import ( # noqa: E402, F401
|
| 60 |
+
get_topic_clusters,
|
| 61 |
+
get_topic_map_snapshot,
|
| 62 |
+
get_cross_model_analysis,
|
| 63 |
+
get_gap_scores,
|
| 64 |
+
find_cross_model_pair,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# Action Items / Reports
|
| 68 |
+
from core.supabase_action_items import ( # noqa: E402, F401
|
| 69 |
+
get_action_items,
|
| 70 |
+
get_action_item_stats,
|
| 71 |
+
update_action_item_status,
|
| 72 |
+
delete_action_item,
|
| 73 |
+
save_action_items_batch,
|
| 74 |
+
get_campaign_date_range,
|
| 75 |
+
get_report_history_count,
|
| 76 |
+
)
|
core/supabase_research.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Supabase research queries โ topic clusters, cross-model analysis.
|
| 2 |
+
|
| 3 |
+
Module-level functions extracted from supabase_client.py.
|
| 4 |
+
All functions use get_supabase_client() from the parent module.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def get_topic_clusters(campaign_id: int, source: str | None = None) -> list[dict]:
|
| 9 |
+
"""Fetch topic clusters with scores for a campaign, optionally filtered by source.
|
| 10 |
+
|
| 11 |
+
Returns:
|
| 12 |
+
List of cluster dicts sorted by opportunity_score DESC.
|
| 13 |
+
"""
|
| 14 |
+
from core.supabase_client import get_supabase_client
|
| 15 |
+
|
| 16 |
+
client = get_supabase_client()
|
| 17 |
+
query = (
|
| 18 |
+
client.table("topic_clusters")
|
| 19 |
+
.select(
|
| 20 |
+
"id, cluster_label, fanout_count, unique_questions, "
|
| 21 |
+
"attention_score, citation_density, opportunity_score, "
|
| 22 |
+
"sample_fanouts, top_sources, source"
|
| 23 |
+
)
|
| 24 |
+
.eq("campaign_id", campaign_id)
|
| 25 |
+
)
|
| 26 |
+
if source:
|
| 27 |
+
query = query.eq("source", source)
|
| 28 |
+
result = query.order("opportunity_score", desc=True).execute()
|
| 29 |
+
return result.data or []
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def get_topic_map_snapshot(campaign_id: int, source: str | None = None) -> dict | None:
|
| 33 |
+
"""Fetch latest UMAP 2D coordinates for visualization.
|
| 34 |
+
|
| 35 |
+
Returns:
|
| 36 |
+
Dict with coordinates and algorithm_params, or None.
|
| 37 |
+
"""
|
| 38 |
+
from core.supabase_client import get_supabase_client
|
| 39 |
+
|
| 40 |
+
client = get_supabase_client()
|
| 41 |
+
result = (
|
| 42 |
+
client.table("topic_map_snapshots")
|
| 43 |
+
.select("coordinates, algorithm_params")
|
| 44 |
+
.eq("campaign_id", campaign_id)
|
| 45 |
+
.order("created_at", desc=True)
|
| 46 |
+
.execute()
|
| 47 |
+
)
|
| 48 |
+
if not result.data:
|
| 49 |
+
return None
|
| 50 |
+
if source:
|
| 51 |
+
for snap in result.data:
|
| 52 |
+
params = snap.get("algorithm_params") or {}
|
| 53 |
+
if params.get("source") == source:
|
| 54 |
+
return snap
|
| 55 |
+
return None
|
| 56 |
+
return result.data[0]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def get_cross_model_analysis(
|
| 60 |
+
campaign_chatgpt: int,
|
| 61 |
+
campaign_gemini: int,
|
| 62 |
+
) -> dict | None:
|
| 63 |
+
"""Fetch cross-model analysis summary (NMI, match count).
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
Dict with nmi_score, total_matched_topics, algorithm_params, or None.
|
| 67 |
+
"""
|
| 68 |
+
from core.supabase_client import get_supabase_client
|
| 69 |
+
|
| 70 |
+
client = get_supabase_client()
|
| 71 |
+
result = (
|
| 72 |
+
client.table("cross_model_analysis")
|
| 73 |
+
.select("nmi_score, total_matched_topics, algorithm_params, created_at")
|
| 74 |
+
.eq("campaign_chatgpt", campaign_chatgpt)
|
| 75 |
+
.eq("campaign_gemini", campaign_gemini)
|
| 76 |
+
.limit(1)
|
| 77 |
+
.execute()
|
| 78 |
+
)
|
| 79 |
+
return result.data[0] if result.data else None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def get_gap_scores(
|
| 83 |
+
campaign_chatgpt: int,
|
| 84 |
+
campaign_gemini: int,
|
| 85 |
+
) -> list[dict]:
|
| 86 |
+
"""Fetch cross-model topic matches with GapScore and quadrant.
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
List of match dicts with cluster labels, sorted by gap_score DESC.
|
| 90 |
+
"""
|
| 91 |
+
from core.supabase_client import get_supabase_client
|
| 92 |
+
|
| 93 |
+
client = get_supabase_client()
|
| 94 |
+
result = (
|
| 95 |
+
client.table("cross_model_topic_matches")
|
| 96 |
+
.select(
|
| 97 |
+
"id, chatgpt_cluster_id, gemini_cluster_id, "
|
| 98 |
+
"match_score, label_similarity, centroid_similarity, "
|
| 99 |
+
"demand_percentile, supply_percentile, gap_score, quadrant"
|
| 100 |
+
)
|
| 101 |
+
.eq("campaign_chatgpt", campaign_chatgpt)
|
| 102 |
+
.eq("campaign_gemini", campaign_gemini)
|
| 103 |
+
.not_.is_("gap_score", "null")
|
| 104 |
+
.order("gap_score", desc=True)
|
| 105 |
+
.execute()
|
| 106 |
+
)
|
| 107 |
+
matches = result.data or []
|
| 108 |
+
|
| 109 |
+
# Enrich with cluster labels (batch query instead of N+1)
|
| 110 |
+
if matches:
|
| 111 |
+
chatgpt_ids = [m["chatgpt_cluster_id"] for m in matches]
|
| 112 |
+
gemini_ids = [m["gemini_cluster_id"] for m in matches]
|
| 113 |
+
all_ids = list(set(chatgpt_ids + gemini_ids))
|
| 114 |
+
|
| 115 |
+
label_map = {}
|
| 116 |
+
label_result = (
|
| 117 |
+
client.table("topic_clusters")
|
| 118 |
+
.select("id, cluster_label")
|
| 119 |
+
.in_("id", all_ids)
|
| 120 |
+
.execute()
|
| 121 |
+
)
|
| 122 |
+
for row in (label_result.data or []):
|
| 123 |
+
label_map[row["id"]] = row.get("cluster_label", "")
|
| 124 |
+
|
| 125 |
+
for m in matches:
|
| 126 |
+
m["chatgpt_label"] = label_map.get(m["chatgpt_cluster_id"], "")
|
| 127 |
+
m["gemini_label"] = label_map.get(m["gemini_cluster_id"], "")
|
| 128 |
+
|
| 129 |
+
return matches
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def find_cross_model_pair(campaign_id: int) -> dict | None:
|
| 133 |
+
"""Find cross-model analysis pair containing this campaign_id.
|
| 134 |
+
|
| 135 |
+
Checks both chatgpt and gemini sides so the sidebar only needs one ID.
|
| 136 |
+
|
| 137 |
+
Returns:
|
| 138 |
+
Dict with campaign_chatgpt, campaign_gemini, nmi_score,
|
| 139 |
+
total_matched_topics, or None if no pair exists.
|
| 140 |
+
"""
|
| 141 |
+
from core.supabase_client import get_supabase_client
|
| 142 |
+
|
| 143 |
+
client = get_supabase_client()
|
| 144 |
+
resp = (
|
| 145 |
+
client.table("cross_model_analysis")
|
| 146 |
+
.select("campaign_chatgpt, campaign_gemini, nmi_score, total_matched_topics")
|
| 147 |
+
.or_(f"campaign_chatgpt.eq.{campaign_id},campaign_gemini.eq.{campaign_id}")
|
| 148 |
+
.limit(1)
|
| 149 |
+
.execute()
|
| 150 |
+
)
|
| 151 |
+
if resp.data:
|
| 152 |
+
return resp.data[0]
|
| 153 |
+
return None
|
core/supabase_sentiment.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Supabase sentiment queries โ overview, LLM verification, polarity.
|
| 2 |
+
|
| 3 |
+
Module-level functions extracted from supabase_client.py.
|
| 4 |
+
All functions use get_supabase_client() from the parent module.
|
| 5 |
+
"""
|
| 6 |
+
import logging
|
| 7 |
+
import streamlit as st
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def get_campaign_overview(campaign_id: int) -> dict:
|
| 13 |
+
"""Get comprehensive overview stats for a campaign via single RPC call.
|
| 14 |
+
|
| 15 |
+
Uses get_campaign_overview_agg() RPC which performs COUNT(*) FILTER
|
| 16 |
+
in a single table scan instead of 6 separate count queries.
|
| 17 |
+
|
| 18 |
+
Returns:
|
| 19 |
+
Dict with total_answers, in_house_negative_count, llm_verified_total,
|
| 20 |
+
llm_verified_in_house, llm_pending, llm_confirmed_negative.
|
| 21 |
+
Returns zeroed dict on DB error for graceful degradation.
|
| 22 |
+
"""
|
| 23 |
+
from core.supabase_client import get_supabase_client
|
| 24 |
+
|
| 25 |
+
_empty = {
|
| 26 |
+
"total_answers": 0,
|
| 27 |
+
"in_house_negative_count": 0,
|
| 28 |
+
"llm_verified_total": 0,
|
| 29 |
+
"llm_verified_in_house": 0,
|
| 30 |
+
"llm_pending": 0,
|
| 31 |
+
"llm_confirmed_negative": 0,
|
| 32 |
+
}
|
| 33 |
+
try:
|
| 34 |
+
client = get_supabase_client()
|
| 35 |
+
result = client.rpc(
|
| 36 |
+
"get_campaign_overview_agg", {"p_campaign_id": campaign_id}
|
| 37 |
+
).execute()
|
| 38 |
+
data = result.data
|
| 39 |
+
# RPC may return list-wrapped or JSON string depending on PostgREST
|
| 40 |
+
if isinstance(data, list) and data:
|
| 41 |
+
data = data[0]
|
| 42 |
+
if isinstance(data, str):
|
| 43 |
+
import json
|
| 44 |
+
data = json.loads(data)
|
| 45 |
+
return data if (isinstance(data, dict) and data) else _empty
|
| 46 |
+
except Exception:
|
| 47 |
+
return _empty
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def get_llm_verification_stats(
|
| 51 |
+
campaign_id: int,
|
| 52 |
+
in_house_only: bool = True,
|
| 53 |
+
) -> dict:
|
| 54 |
+
"""Get LLM verification statistics via single RPC (avoids VIEW timeout).
|
| 55 |
+
|
| 56 |
+
Uses get_campaign_sentiment_stats RPC โ one table scan with COUNT FILTER.
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
campaign_id: Campaign ID
|
| 60 |
+
in_house_only: If True, only count in-house brand negatives (default)
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
Dict with total_verified, false_positives, true_negatives counts
|
| 64 |
+
"""
|
| 65 |
+
from core.supabase_client import get_supabase_client
|
| 66 |
+
|
| 67 |
+
_empty = {"total_verified": 0, "false_positives": 0, "true_negatives": 0}
|
| 68 |
+
try:
|
| 69 |
+
client = get_supabase_client()
|
| 70 |
+
result = client.rpc(
|
| 71 |
+
"get_campaign_sentiment_stats", {"p_campaign_id": campaign_id}
|
| 72 |
+
).execute()
|
| 73 |
+
stats = result.data or {}
|
| 74 |
+
if isinstance(stats, list) and stats:
|
| 75 |
+
stats = stats[0]
|
| 76 |
+
if isinstance(stats, str):
|
| 77 |
+
import json
|
| 78 |
+
stats = json.loads(stats)
|
| 79 |
+
if not isinstance(stats, dict):
|
| 80 |
+
return _empty
|
| 81 |
+
|
| 82 |
+
if in_house_only:
|
| 83 |
+
return {
|
| 84 |
+
"total_verified": stats.get("in_house_llm_verified", 0),
|
| 85 |
+
"false_positives": stats.get("in_house_llm_false_pos", 0),
|
| 86 |
+
"true_negatives": stats.get("in_house_llm_true_neg", 0),
|
| 87 |
+
}
|
| 88 |
+
return {
|
| 89 |
+
"total_verified": stats.get("llm_verified", 0),
|
| 90 |
+
"false_positives": stats.get("llm_false_positive", 0),
|
| 91 |
+
"true_negatives": stats.get("llm_true_negative", 0),
|
| 92 |
+
}
|
| 93 |
+
except Exception:
|
| 94 |
+
return _empty
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@st.cache_data(ttl=60)
|
| 98 |
+
def get_false_positives(
|
| 99 |
+
campaign_id: int,
|
| 100 |
+
page: int = 1,
|
| 101 |
+
page_size: int = 50,
|
| 102 |
+
in_house_only: bool = True,
|
| 103 |
+
) -> tuple[list[dict], int]:
|
| 104 |
+
"""Get false positive items via RPC (avoids VIEW timeout).
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
Tuple of (items list, total count)
|
| 108 |
+
"""
|
| 109 |
+
from core.supabase_client import get_supabase_client
|
| 110 |
+
|
| 111 |
+
try:
|
| 112 |
+
client = get_supabase_client()
|
| 113 |
+
result = client.rpc("get_nudge_export_data", {
|
| 114 |
+
"p_campaign_id": campaign_id,
|
| 115 |
+
"p_in_house_only": in_house_only,
|
| 116 |
+
"p_llm_verified_only": True,
|
| 117 |
+
"p_llm_is_negative": False,
|
| 118 |
+
"p_limit": 50000,
|
| 119 |
+
}).execute()
|
| 120 |
+
data = result.data or {}
|
| 121 |
+
rows = data.get("rows", []) if isinstance(data, dict) else []
|
| 122 |
+
total = data.get("total", len(rows)) if isinstance(data, dict) else len(rows)
|
| 123 |
+
|
| 124 |
+
# Sort by llm_verified_at DESC (RPC sorts by analyzed_at)
|
| 125 |
+
rows.sort(key=lambda r: r.get("llm_verified_at") or "", reverse=True)
|
| 126 |
+
|
| 127 |
+
# Client-side pagination
|
| 128 |
+
offset = (page - 1) * page_size
|
| 129 |
+
return rows[offset:offset + page_size], total
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logger.warning("get_false_positives failed for campaign %s: %s", campaign_id, e)
|
| 132 |
+
return [], 0
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@st.cache_data(ttl=60)
|
| 136 |
+
def get_true_negatives(
|
| 137 |
+
campaign_id: int,
|
| 138 |
+
page: int = 1,
|
| 139 |
+
page_size: int = 50,
|
| 140 |
+
in_house_only: bool = True,
|
| 141 |
+
) -> tuple[list[dict], int]:
|
| 142 |
+
"""Get true negative items via RPC (avoids VIEW timeout).
|
| 143 |
+
|
| 144 |
+
Returns:
|
| 145 |
+
Tuple of (items list, total count)
|
| 146 |
+
"""
|
| 147 |
+
from core.supabase_client import get_supabase_client
|
| 148 |
+
|
| 149 |
+
try:
|
| 150 |
+
client = get_supabase_client()
|
| 151 |
+
result = client.rpc("get_nudge_export_data", {
|
| 152 |
+
"p_campaign_id": campaign_id,
|
| 153 |
+
"p_in_house_only": in_house_only,
|
| 154 |
+
"p_llm_verified_only": True,
|
| 155 |
+
"p_llm_is_negative": True,
|
| 156 |
+
"p_limit": 50000,
|
| 157 |
+
}).execute()
|
| 158 |
+
data = result.data or {}
|
| 159 |
+
rows = data.get("rows", []) if isinstance(data, dict) else []
|
| 160 |
+
total = data.get("total", len(rows)) if isinstance(data, dict) else len(rows)
|
| 161 |
+
|
| 162 |
+
# Sort by llm_verified_at DESC (RPC sorts by analyzed_at)
|
| 163 |
+
rows.sort(key=lambda r: r.get("llm_verified_at") or "", reverse=True)
|
| 164 |
+
|
| 165 |
+
# Client-side pagination
|
| 166 |
+
offset = (page - 1) * page_size
|
| 167 |
+
return rows[offset:offset + page_size], total
|
| 168 |
+
except Exception as e:
|
| 169 |
+
logger.warning("get_true_negatives failed for campaign %s: %s", campaign_id, e)
|
| 170 |
+
return [], 0
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def get_llm_verified_for_export(
|
| 174 |
+
campaign_id: int,
|
| 175 |
+
is_negative: bool,
|
| 176 |
+
in_house_only: bool = True,
|
| 177 |
+
include_full_answers: bool = False,
|
| 178 |
+
) -> list[dict]:
|
| 179 |
+
"""Get all LLM verified items for export via single RPC (avoids VIEW timeout).
|
| 180 |
+
|
| 181 |
+
Returns:
|
| 182 |
+
List of items with optional full answer text
|
| 183 |
+
"""
|
| 184 |
+
from core.supabase_client import get_supabase_client
|
| 185 |
+
|
| 186 |
+
try:
|
| 187 |
+
client = get_supabase_client()
|
| 188 |
+
result = client.rpc("get_nudge_export_data", {
|
| 189 |
+
"p_campaign_id": campaign_id,
|
| 190 |
+
"p_in_house_only": in_house_only,
|
| 191 |
+
"p_llm_verified_only": True,
|
| 192 |
+
"p_llm_is_negative": is_negative,
|
| 193 |
+
"p_limit": 50000,
|
| 194 |
+
}).execute()
|
| 195 |
+
data = result.data or {}
|
| 196 |
+
items = data.get("rows", []) if isinstance(data, dict) else []
|
| 197 |
+
|
| 198 |
+
# Fetch full answers from Athena if requested
|
| 199 |
+
if include_full_answers and items:
|
| 200 |
+
from .athena_client import fetch_full_answers_batch
|
| 201 |
+
answer_ids = [item["answer_id"] for item in items if item.get("answer_id")]
|
| 202 |
+
if answer_ids:
|
| 203 |
+
full_answers = fetch_full_answers_batch(answer_ids)
|
| 204 |
+
for item in items:
|
| 205 |
+
aid = item.get("answer_id")
|
| 206 |
+
if aid and aid in full_answers:
|
| 207 |
+
item["answer_full"] = full_answers[aid]
|
| 208 |
+
else:
|
| 209 |
+
item["answer_full"] = item.get("answer_preview", "")
|
| 210 |
+
|
| 211 |
+
return items
|
| 212 |
+
except Exception as e:
|
| 213 |
+
logger.warning("get_llm_verified_for_export failed for campaign %s: %s", campaign_id, e)
|
| 214 |
+
return []
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def get_sentiment_data_for_export(
|
| 218 |
+
campaign_id: int,
|
| 219 |
+
polarity: str | None = None,
|
| 220 |
+
llm_status: str | None = None,
|
| 221 |
+
in_house_only: bool = True,
|
| 222 |
+
include_full_answers: bool = False,
|
| 223 |
+
include_evidence: bool = False,
|
| 224 |
+
) -> list[dict]:
|
| 225 |
+
"""Export sentiment data via RPC (avoids VIEW timeout on large campaigns).
|
| 226 |
+
|
| 227 |
+
Uses get_nudge_export_data RPC with inline CTE โ WHERE campaign_id
|
| 228 |
+
is applied before DISTINCT ON, enabling index scan instead of full
|
| 229 |
+
VIEW materialization.
|
| 230 |
+
|
| 231 |
+
Args:
|
| 232 |
+
campaign_id: Campaign ID
|
| 233 |
+
polarity: Polarity filter ('negative', 'positive', 'neutral', None=all)
|
| 234 |
+
llm_status: LLM status filter ('verified', 'false_positive', 'true_negative', 'unverified', None=all)
|
| 235 |
+
in_house_only: In-house brand filter
|
| 236 |
+
include_full_answers: Include full answer text from Athena
|
| 237 |
+
include_evidence: Include LLM evidence fields
|
| 238 |
+
|
| 239 |
+
Returns:
|
| 240 |
+
Filtered data list
|
| 241 |
+
"""
|
| 242 |
+
from core.supabase_client import get_supabase_client
|
| 243 |
+
|
| 244 |
+
try:
|
| 245 |
+
client = get_supabase_client()
|
| 246 |
+
|
| 247 |
+
# Map llm_status to RPC parameters
|
| 248 |
+
llm_verified_only = llm_status in ("verified", "false_positive", "true_negative")
|
| 249 |
+
llm_is_negative = None
|
| 250 |
+
if llm_status == "true_negative":
|
| 251 |
+
llm_is_negative = True
|
| 252 |
+
elif llm_status == "false_positive":
|
| 253 |
+
llm_is_negative = False
|
| 254 |
+
|
| 255 |
+
params = {
|
| 256 |
+
"p_campaign_id": campaign_id,
|
| 257 |
+
"p_in_house_only": in_house_only,
|
| 258 |
+
"p_llm_verified_only": llm_verified_only,
|
| 259 |
+
"p_llm_is_negative": llm_is_negative,
|
| 260 |
+
"p_limit": 50000,
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
result = client.rpc("get_nudge_export_data", params).execute()
|
| 264 |
+
data = result.data or {}
|
| 265 |
+
if isinstance(data, dict):
|
| 266 |
+
items = data.get("rows", []) or []
|
| 267 |
+
else:
|
| 268 |
+
items = data if isinstance(data, list) else []
|
| 269 |
+
|
| 270 |
+
# Apply polarity filter (kept client-side for simplicity)
|
| 271 |
+
if polarity:
|
| 272 |
+
items = [item for item in items if item.get("overall_polarity") == polarity]
|
| 273 |
+
|
| 274 |
+
# Apply unverified filter (RPC only supports verified=True)
|
| 275 |
+
if llm_status == "unverified":
|
| 276 |
+
items = [item for item in items if not item.get("llm_verified")]
|
| 277 |
+
|
| 278 |
+
if include_full_answers and items:
|
| 279 |
+
from .athena_client import fetch_full_answers_batch
|
| 280 |
+
answer_ids = [item["answer_id"] for item in items if item.get("answer_id")]
|
| 281 |
+
if answer_ids:
|
| 282 |
+
full_answers = fetch_full_answers_batch(answer_ids)
|
| 283 |
+
for item in items:
|
| 284 |
+
aid = item.get("answer_id")
|
| 285 |
+
if aid and aid in full_answers:
|
| 286 |
+
item["answer_full"] = full_answers[aid]
|
| 287 |
+
else:
|
| 288 |
+
item["answer_full"] = item.get("answer_preview", "")
|
| 289 |
+
|
| 290 |
+
return items
|
| 291 |
+
except Exception as e:
|
| 292 |
+
logger.warning("get_sentiment_data_for_export failed for campaign %s: %s", campaign_id, e)
|
| 293 |
+
return []
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
@st.cache_data(ttl=60)
|
| 297 |
+
def get_answers_by_polarity(
|
| 298 |
+
campaign_id: int,
|
| 299 |
+
polarity: str,
|
| 300 |
+
page: int = 1,
|
| 301 |
+
page_size: int = 50,
|
| 302 |
+
in_house_only: bool = False,
|
| 303 |
+
) -> tuple[list[dict], int]:
|
| 304 |
+
"""Get answers filtered by polarity via RPC (avoids VIEW timeout).
|
| 305 |
+
|
| 306 |
+
Returns:
|
| 307 |
+
Tuple of (items list, total count)
|
| 308 |
+
"""
|
| 309 |
+
from core.supabase_client import get_supabase_client
|
| 310 |
+
|
| 311 |
+
try:
|
| 312 |
+
client = get_supabase_client()
|
| 313 |
+
offset = (page - 1) * page_size
|
| 314 |
+
|
| 315 |
+
params: dict = {
|
| 316 |
+
"p_campaign_id": campaign_id,
|
| 317 |
+
"p_in_house_only": False,
|
| 318 |
+
"p_polarity": polarity,
|
| 319 |
+
"p_offset": offset,
|
| 320 |
+
"p_limit": page_size,
|
| 321 |
+
}
|
| 322 |
+
if in_house_only:
|
| 323 |
+
params["p_has_in_house_brands"] = True
|
| 324 |
+
|
| 325 |
+
result = client.rpc("get_nudge_export_data", params).execute()
|
| 326 |
+
data = result.data or {}
|
| 327 |
+
rows = data.get("rows", []) if isinstance(data, dict) else []
|
| 328 |
+
total = data.get("total", len(rows)) if isinstance(data, dict) else len(rows)
|
| 329 |
+
|
| 330 |
+
return rows, total
|
| 331 |
+
except Exception as e:
|
| 332 |
+
logger.warning("get_answers_by_polarity failed for campaign %s: %s", campaign_id, e)
|
| 333 |
+
return [], 0
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def get_polarity_stats(campaign_id: int, in_house_only: bool = False) -> dict:
|
| 337 |
+
"""Get polarity distribution via single RPC (avoids VIEW timeout).
|
| 338 |
+
|
| 339 |
+
Uses get_campaign_sentiment_stats RPC โ one table scan with COUNT FILTER.
|
| 340 |
+
|
| 341 |
+
Returns:
|
| 342 |
+
Dict with positive, neutral, negative counts
|
| 343 |
+
"""
|
| 344 |
+
from core.supabase_client import get_supabase_client
|
| 345 |
+
|
| 346 |
+
_empty = {"positive": 0, "neutral": 0, "negative": 0}
|
| 347 |
+
try:
|
| 348 |
+
client = get_supabase_client()
|
| 349 |
+
result = client.rpc(
|
| 350 |
+
"get_campaign_sentiment_stats", {"p_campaign_id": campaign_id}
|
| 351 |
+
).execute()
|
| 352 |
+
stats = result.data or {}
|
| 353 |
+
if isinstance(stats, list) and stats:
|
| 354 |
+
stats = stats[0]
|
| 355 |
+
if isinstance(stats, str):
|
| 356 |
+
import json
|
| 357 |
+
stats = json.loads(stats)
|
| 358 |
+
if not isinstance(stats, dict):
|
| 359 |
+
return _empty
|
| 360 |
+
|
| 361 |
+
if in_house_only:
|
| 362 |
+
return {
|
| 363 |
+
"positive": stats.get("in_house_positive", 0),
|
| 364 |
+
"neutral": stats.get("in_house_neutral", 0),
|
| 365 |
+
"negative": stats.get("in_house_negative_polarity", 0),
|
| 366 |
+
}
|
| 367 |
+
return {
|
| 368 |
+
"positive": stats.get("positive", 0),
|
| 369 |
+
"neutral": stats.get("neutral", 0),
|
| 370 |
+
"negative": stats.get("negative", 0),
|
| 371 |
+
}
|
| 372 |
+
except Exception:
|
| 373 |
+
return _empty
|
core/utils.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Utility functions for Gen3 Nudge Detection Dashboard."""
|
| 2 |
+
|
| 3 |
+
# Confidence tier thresholds
|
| 4 |
+
CONFIDENCE_HIGH_THRESHOLD = 0.85
|
| 5 |
+
CONFIDENCE_MEDIUM_THRESHOLD = 0.70
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def get_confidence_tier(confidence: float | None) -> tuple[str, str, str]:
|
| 9 |
+
"""Get confidence tier info for nudge candidates.
|
| 10 |
+
|
| 11 |
+
Returns:
|
| 12 |
+
Tuple of (tier, emoji, description)
|
| 13 |
+
"""
|
| 14 |
+
if confidence is None:
|
| 15 |
+
return "LOW", "๐ข", "์คํ ๊ฐ๋ฅ์ฑ"
|
| 16 |
+
if confidence >= CONFIDENCE_HIGH_THRESHOLD:
|
| 17 |
+
return "HIGH", "๐ด", "ํ์คํ ๋์ง ๋์"
|
| 18 |
+
elif confidence >= CONFIDENCE_MEDIUM_THRESHOLD:
|
| 19 |
+
return "MEDIUM", "๐ก", "๊ฒํ ํ์"
|
| 20 |
+
return "LOW", "๐ข", "์คํ ๊ฐ๋ฅ์ฑ"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def truncate_text(text: str | None, max_length: int, suffix: str = "...") -> str:
|
| 24 |
+
"""Truncate text to max_length with suffix."""
|
| 25 |
+
if not text:
|
| 26 |
+
return "N/A"
|
| 27 |
+
if len(text) <= max_length:
|
| 28 |
+
return text
|
| 29 |
+
return text[:max_length] + suffix
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def format_brands_list(brands: list[str] | None) -> str:
|
| 33 |
+
"""Format list of brands for display."""
|
| 34 |
+
if not brands:
|
| 35 |
+
return "N/A"
|
| 36 |
+
return ", ".join(brands)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def highlight_evidence_spans(text: str, evidence_spans: list[dict] | None) -> str:
|
| 40 |
+
"""Highlight evidence spans in text using HTML.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
text: Full answer text
|
| 44 |
+
evidence_spans: List of evidence span dicts with text, start, end, type
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
HTML string with highlighted spans
|
| 48 |
+
"""
|
| 49 |
+
if not evidence_spans or not text:
|
| 50 |
+
return text or ""
|
| 51 |
+
|
| 52 |
+
# Sort spans by start position (descending) to avoid index shifting
|
| 53 |
+
sorted_spans = sorted(
|
| 54 |
+
[s for s in evidence_spans if s.get("text")],
|
| 55 |
+
key=lambda s: s.get("start", 0) if s.get("start") is not None else -1,
|
| 56 |
+
reverse=True,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
result = text
|
| 60 |
+
for span in sorted_spans:
|
| 61 |
+
span_text = span.get("text", "")
|
| 62 |
+
span_type = span.get("type", "negative")
|
| 63 |
+
start = span.get("start")
|
| 64 |
+
end = span.get("end")
|
| 65 |
+
|
| 66 |
+
# Color by type
|
| 67 |
+
color_map = {
|
| 68 |
+
"negative": "#FF6B6B",
|
| 69 |
+
"positive": "#51CF66",
|
| 70 |
+
"neutral": "#748FFC",
|
| 71 |
+
"comparison": "#FAB005",
|
| 72 |
+
"hallucination": "#ADB5BD",
|
| 73 |
+
"category_general": "#9775FA",
|
| 74 |
+
}
|
| 75 |
+
color = color_map.get(span_type, "#ADB5BD")
|
| 76 |
+
|
| 77 |
+
mark_style = f'background-color: {color}; padding: 2px 4px; border-radius: 3px;'
|
| 78 |
+
|
| 79 |
+
mark_style = f'background-color: {color}; padding: 2px 4px; border-radius: 3px;'
|
| 80 |
+
|
| 81 |
+
if start is not None and end is not None and 0 <= start < end <= len(result):
|
| 82 |
+
# Use exact positions
|
| 83 |
+
before = result[:start]
|
| 84 |
+
highlighted = f'<mark style="{mark_style}">{result[start:end]}</mark>'
|
| 85 |
+
after = result[end:]
|
| 86 |
+
result = before + highlighted + after
|
| 87 |
+
elif span_text and span_text in result:
|
| 88 |
+
# Fallback: find text in result
|
| 89 |
+
highlighted = f'<mark style="{mark_style}">{span_text}</mark>'
|
| 90 |
+
result = result.replace(span_text, highlighted, 1)
|
| 91 |
+
elif span_text and "..." in span_text:
|
| 92 |
+
# Ellipsis fallback: LLM truncated the evidence with "..."
|
| 93 |
+
# Split into fragments and highlight each one found in the text
|
| 94 |
+
fragments = [f.strip() for f in span_text.split("...") if f.strip()]
|
| 95 |
+
for frag in fragments:
|
| 96 |
+
if frag in result:
|
| 97 |
+
highlighted = f'<mark style="{mark_style}">{frag}</mark>'
|
| 98 |
+
result = result.replace(frag, highlighted, 1)
|
| 99 |
+
|
| 100 |
+
return result
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def get_llm_tier_badge(adjusted_tier: str | None, is_negative: bool | None) -> tuple[str, str]:
|
| 104 |
+
"""Get badge info for LLM verification result.
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
Tuple of (badge_text, badge_color)
|
| 108 |
+
"""
|
| 109 |
+
if adjusted_tier is None:
|
| 110 |
+
return "๋ฏธ๊ฒ์ฆ", "gray"
|
| 111 |
+
|
| 112 |
+
if adjusted_tier == "NONE" or is_negative is False:
|
| 113 |
+
return "โ
์คํ (False Positive)", "green"
|
| 114 |
+
|
| 115 |
+
tier_map = {
|
| 116 |
+
"HIGH": ("๐ด ๋ถ์ ํ์ธ (HIGH)", "red"),
|
| 117 |
+
"MEDIUM": ("๐ก ๋ถ์ ํ์ธ (MEDIUM)", "orange"),
|
| 118 |
+
"LOW": ("๐ข ๋ถ์ ํ์ธ (LOW)", "blue"),
|
| 119 |
+
}
|
| 120 |
+
return tier_map.get(adjusted_tier, ("ํ์ธ๋จ", "gray"))
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def get_feedback_reason_label(reason: str | None) -> str:
|
| 124 |
+
"""Get human-readable label for feedback wrong_reason."""
|
| 125 |
+
reason_labels = {
|
| 126 |
+
"actually_positive": "์ค์ ๋ก๋ ๊ธ์ ์ ์ธ ๋ด์ฉ์
๋๋ค",
|
| 127 |
+
"actually_neutral": "์ค์ ๋ก๋ ์ค๋ฆฝ์ ์ธ ๋ด์ฉ์
๋๋ค",
|
| 128 |
+
"wrong_evidence": "๊ทผ๊ฑฐ ๋ฌธ์ฅ์ด ์๋ชป ์ถ์ถ๋์์ต๋๋ค",
|
| 129 |
+
"context_missing": "๋งฅ๋ฝ์ด ๋น ์ ธ์ ์คํด๊ฐ ์์ต๋๋ค",
|
| 130 |
+
"wrong_brand": "๋ธ๋๋๊ฐ ์๋ชป ์ธ์๋์์ต๋๋ค",
|
| 131 |
+
"other": "๊ธฐํ",
|
| 132 |
+
}
|
| 133 |
+
return reason_labels.get(reason, reason or "")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def get_feedback_type_emoji(feedback_type: str | None) -> str:
|
| 137 |
+
"""Get emoji for feedback type."""
|
| 138 |
+
emoji_map = {
|
| 139 |
+
"correct": "๐",
|
| 140 |
+
"wrong": "๐",
|
| 141 |
+
"ambiguous": "๐ค",
|
| 142 |
+
}
|
| 143 |
+
return emoji_map.get(feedback_type, "")
|
features/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Feature plugins package.
|
| 2 |
+
|
| 3 |
+
๊ฐ ํ์ ๋๋ ํ ๋ฆฌ์ FEATURE_CONFIG + render(base_ctx)๊ฐ ์์ผ๋ฉด ์๋ ๋ฑ๋ก๋จ.
|
| 4 |
+
"""
|
features/action_items/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""์ก์
์์ดํ
Feature Plugin.
|
| 2 |
+
|
| 3 |
+
API: /api/v1/action-items
|
| 4 |
+
ADR-018 Phase 2: DB ์ํ๊ด๋ฆฌ
|
| 5 |
+
๋
๋ฆฝ ํธ๋ฆฌ๊ฑฐ ๋ถ์: ๋ฆฌํฌํธ ์์ด ์ง์ ํธ๋ฆฌ๊ฑฐ ํ๊ฐ โ ์ ์ฅ
|
| 6 |
+
"""
|
| 7 |
+
import streamlit as st
|
| 8 |
+
|
| 9 |
+
from . import overview
|
| 10 |
+
|
| 11 |
+
FEATURE_CONFIG = {
|
| 12 |
+
"key": "action_items",
|
| 13 |
+
"name": "์ก์
์์ดํ
",
|
| 14 |
+
"icon": "โ
",
|
| 15 |
+
"description": "ํธ๋ฆฌ๊ฑฐ ๋ถ์ + ์ก์
์์ดํ
๊ด๋ฆฌ (์ํ ์ถ์ , ๋ด๋น์ ๋ฐฐ์ )",
|
| 16 |
+
"api_base": "/api/v1/action-items",
|
| 17 |
+
"order": 4,
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def render(base_ctx):
|
| 22 |
+
"""์ก์
์์ดํ
feature ๋ ๋๋ง."""
|
| 23 |
+
st.caption("14๊ฐ ํธ๋ฆฌ๊ฑฐ ๊ท์น์ผ๋ก ์ ํธ๋ฅผ ํ์งํ๊ณ ์ก์
์์ดํ
์ผ๋ก ๊ด๋ฆฌํฉ๋๋ค")
|
| 24 |
+
overview.render(base_ctx)
|
features/action_items/analysis.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Trigger analysis section โ date picker, run, preview, save.
|
| 2 |
+
|
| 3 |
+
Separated from overview.py so that CRUD operations work even when
|
| 4 |
+
trigger_rules module is unavailable (e.g., standalone HuggingFace Space).
|
| 5 |
+
"""
|
| 6 |
+
from datetime import date, timedelta
|
| 7 |
+
|
| 8 |
+
import streamlit as st
|
| 9 |
+
|
| 10 |
+
from core.supabase_client import (
|
| 11 |
+
get_campaign_date_range,
|
| 12 |
+
save_action_items_batch,
|
| 13 |
+
)
|
| 14 |
+
from .triggers import TRIGGERS_AVAILABLE
|
| 15 |
+
from .utils import priority_level, CATEGORY_CONFIG
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def render_analysis_section(campaign_id: int, base_ctx: dict):
|
| 19 |
+
"""Render trigger analysis: date picker, run button, preview, save."""
|
| 20 |
+
with st.expander("๐ ํธ๋ฆฌ๊ฑฐ ๋ถ์ ์คํ", expanded=True):
|
| 21 |
+
if not TRIGGERS_AVAILABLE:
|
| 22 |
+
st.info(
|
| 23 |
+
"ํธ๋ฆฌ๊ฑฐ ๋ถ์ ๊ธฐ๋ฅ์ ์ฌ์ฉํ ์ ์์ต๋๋ค. "
|
| 24 |
+
"์ ์ฒด ํ๋ก์ ํธ ํ๊ฒฝ์์๋ง ์ง์๋ฉ๋๋ค."
|
| 25 |
+
)
|
| 26 |
+
return
|
| 27 |
+
|
| 28 |
+
# Date range
|
| 29 |
+
date_range = get_campaign_date_range(campaign_id)
|
| 30 |
+
if not date_range:
|
| 31 |
+
st.info("์บ ํ์ธ ๋ฐ์ดํฐ๊ฐ ์์ง ๋๊ธฐํ๋์ง ์์์ต๋๋ค.")
|
| 32 |
+
return
|
| 33 |
+
|
| 34 |
+
_, last_date_str = date_range
|
| 35 |
+
last_date = date.fromisoformat(last_date_str)
|
| 36 |
+
default_start = last_date - timedelta(days=6)
|
| 37 |
+
|
| 38 |
+
date_cols = st.columns(2)
|
| 39 |
+
with date_cols[0]:
|
| 40 |
+
start_date = st.date_input(
|
| 41 |
+
"์์์ผ",
|
| 42 |
+
value=default_start,
|
| 43 |
+
key="ai_trigger_start",
|
| 44 |
+
)
|
| 45 |
+
with date_cols[1]:
|
| 46 |
+
end_date = st.date_input(
|
| 47 |
+
"์ข
๋ฃ์ผ",
|
| 48 |
+
value=last_date,
|
| 49 |
+
key="ai_trigger_end",
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
if st.button("๐ ๋ถ์ ์คํ", key="ai_run_triggers", type="primary", use_container_width=True):
|
| 53 |
+
with st.spinner("14๊ฐ ํธ๋ฆฌ๊ฑฐ ๊ท์น์ ํ๊ฐ ์ค..."):
|
| 54 |
+
try:
|
| 55 |
+
from .triggers import evaluate_triggers
|
| 56 |
+
|
| 57 |
+
results = evaluate_triggers(
|
| 58 |
+
campaign_id,
|
| 59 |
+
start_date.isoformat(),
|
| 60 |
+
end_date.isoformat(),
|
| 61 |
+
)
|
| 62 |
+
st.session_state["ai_trigger_results"] = results
|
| 63 |
+
except Exception as e:
|
| 64 |
+
st.error(f"๋ถ์ ์ค๋ฅ: {e}")
|
| 65 |
+
return
|
| 66 |
+
|
| 67 |
+
# Show results if available
|
| 68 |
+
results = st.session_state.get("ai_trigger_results")
|
| 69 |
+
if results is None:
|
| 70 |
+
return
|
| 71 |
+
|
| 72 |
+
if not results:
|
| 73 |
+
st.success("๋ชจ๋ ์งํ๊ฐ ์ ์ ๋ฒ์์
๋๋ค. ํ์ง๋ ์ ํธ๊ฐ ์์ต๋๋ค.")
|
| 74 |
+
return
|
| 75 |
+
|
| 76 |
+
st.markdown(f"**{len(results)}๊ฐ ์ ํธ ํ์ง๋จ**")
|
| 77 |
+
|
| 78 |
+
# Preview cards
|
| 79 |
+
for item in results:
|
| 80 |
+
p_label, p_color, p_bg = priority_level(item["priority"])
|
| 81 |
+
cat_cfg = CATEGORY_CONFIG.get(item["category"], {"color": "#666", "icon": "๐"})
|
| 82 |
+
st.markdown(f"""
|
| 83 |
+
<div style="background:#FAFAFA;border:1px solid #E5E7EB;border-radius:8px;
|
| 84 |
+
padding:12px;margin-bottom:8px;border-left:3px solid {p_color}">
|
| 85 |
+
<div style="display:flex;gap:8px;align-items:center;margin-bottom:6px">
|
| 86 |
+
<span style="background:{p_bg};color:{p_color};padding:1px 8px;
|
| 87 |
+
border-radius:10px;font-size:11px;font-weight:600">{p_label}</span>
|
| 88 |
+
<span style="font-size:11px;color:{cat_cfg['color']}">{cat_cfg['icon']} {item['category']}</span>
|
| 89 |
+
</div>
|
| 90 |
+
<div style="font-size:14px;font-weight:600;color:#1F2937;margin-bottom:4px">{item['label']}</div>
|
| 91 |
+
<div style="font-size:12px;color:#6B7280">{item.get('evidence', '')}</div>
|
| 92 |
+
</div>
|
| 93 |
+
""", unsafe_allow_html=True)
|
| 94 |
+
|
| 95 |
+
# Save button
|
| 96 |
+
if st.button("๐พ ์ก์
์์ดํ
์ผ๋ก ์ ์ฅ", key="ai_save_triggers", use_container_width=True):
|
| 97 |
+
_save_trigger_results(campaign_id, results, base_ctx)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _save_trigger_results(campaign_id: int, results: list[dict], base_ctx: dict):
|
| 101 |
+
"""Save trigger analysis results as action items."""
|
| 102 |
+
try:
|
| 103 |
+
from core.supabase_client import get_supabase_client
|
| 104 |
+
|
| 105 |
+
user_client = get_supabase_client()
|
| 106 |
+
key_result = (
|
| 107 |
+
user_client.table("api_keys")
|
| 108 |
+
.select("user_id")
|
| 109 |
+
.limit(1)
|
| 110 |
+
.execute()
|
| 111 |
+
)
|
| 112 |
+
user_id = str(key_result.data[0]["user_id"]) if key_result.data else "unknown"
|
| 113 |
+
|
| 114 |
+
result = save_action_items_batch(
|
| 115 |
+
campaign_id=campaign_id,
|
| 116 |
+
user_id=user_id,
|
| 117 |
+
items=results,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
created = result.get("created", 0)
|
| 121 |
+
skipped = result.get("skipped", 0)
|
| 122 |
+
if result.get("error"):
|
| 123 |
+
st.error(f"์ ์ฅ ์ค๋ฅ: {result['error']}")
|
| 124 |
+
elif created > 0:
|
| 125 |
+
st.success(f"โ
{created}๊ฐ ์ ์ฅ ์๋ฃ (์ค๋ณต {skipped}๊ฐ ์คํต)")
|
| 126 |
+
st.session_state.pop("ai_trigger_results", None)
|
| 127 |
+
st.rerun()
|
| 128 |
+
else:
|
| 129 |
+
st.info(f"๋ชจ๋ ํญ๋ชฉ์ด ์ด๋ฏธ ์กด์ฌํฉ๋๋ค ({skipped}๊ฐ ์คํต)")
|
| 130 |
+
except Exception as e:
|
| 131 |
+
st.error(f"์ ์ฅ ์คํจ: {e}")
|
features/action_items/overview.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Action Items overview โ card-based UI with stats, filters, and inline editing."""
|
| 2 |
+
import re
|
| 3 |
+
from datetime import date, timedelta
|
| 4 |
+
|
| 5 |
+
import streamlit as st
|
| 6 |
+
|
| 7 |
+
from core.supabase_client import (
|
| 8 |
+
get_action_items,
|
| 9 |
+
get_action_item_stats,
|
| 10 |
+
update_action_item_status,
|
| 11 |
+
delete_action_item,
|
| 12 |
+
get_campaign_date_range,
|
| 13 |
+
)
|
| 14 |
+
from .analysis import render_analysis_section
|
| 15 |
+
from .trend_charts import (
|
| 16 |
+
render_visibility_trend,
|
| 17 |
+
render_citation_type_trend,
|
| 18 |
+
render_negative_rate_trend,
|
| 19 |
+
render_action_items_history,
|
| 20 |
+
)
|
| 21 |
+
from .utils import (
|
| 22 |
+
STATUS_CONFIG,
|
| 23 |
+
STATUS_OPTIONS,
|
| 24 |
+
STATUS_LABELS,
|
| 25 |
+
CATEGORY_CONFIG,
|
| 26 |
+
priority_level,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _render_stats(stats: dict):
|
| 31 |
+
"""Render stats summary bar."""
|
| 32 |
+
total = stats.get("total", 0)
|
| 33 |
+
pending = stats.get("pending", 0)
|
| 34 |
+
in_progress = stats.get("in_progress", 0)
|
| 35 |
+
completed = stats.get("completed", 0)
|
| 36 |
+
|
| 37 |
+
if total == 0:
|
| 38 |
+
return
|
| 39 |
+
|
| 40 |
+
cols = st.columns(4)
|
| 41 |
+
items = [
|
| 42 |
+
("pending", "๋๊ธฐ", pending),
|
| 43 |
+
("in_progress", "์งํ ์ค", in_progress),
|
| 44 |
+
("completed", "์๋ฃ", completed),
|
| 45 |
+
("archived", "๋ณด๊ด", stats.get("archived", 0)),
|
| 46 |
+
]
|
| 47 |
+
for col, (key, label, count) in zip(cols, items):
|
| 48 |
+
cfg = STATUS_CONFIG[key]
|
| 49 |
+
col.markdown(f"""
|
| 50 |
+
<div style="background:{cfg['bg']};border-radius:10px;padding:16px;text-align:center;
|
| 51 |
+
border-left:4px solid {cfg['color']}">
|
| 52 |
+
<div style="font-size:28px;font-weight:700;color:{cfg['color']}">{count}</div>
|
| 53 |
+
<div style="font-size:13px;color:#6B7280;margin-top:2px">{cfg['emoji']} {label}</div>
|
| 54 |
+
</div>
|
| 55 |
+
""", unsafe_allow_html=True)
|
| 56 |
+
|
| 57 |
+
# Progress bar
|
| 58 |
+
if total > 0:
|
| 59 |
+
done_pct = completed / total * 100
|
| 60 |
+
active_pct = in_progress / total * 100
|
| 61 |
+
st.markdown(f"""
|
| 62 |
+
<div style="margin:12px 0 4px 0">
|
| 63 |
+
<div style="display:flex;height:8px;border-radius:4px;overflow:hidden;background:#F3F4F6">
|
| 64 |
+
<div style="width:{done_pct}%;background:#10b981"></div>
|
| 65 |
+
<div style="width:{active_pct}%;background:#f59e0b"></div>
|
| 66 |
+
</div>
|
| 67 |
+
<div style="display:flex;justify-content:space-between;font-size:11px;color:#9ca3af;margin-top:4px">
|
| 68 |
+
<span>์๋ฃ {done_pct:.0f}%</span>
|
| 69 |
+
<span>์ ์ฒด {total}๊ฑด</span>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
""", unsafe_allow_html=True)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _render_empty_state():
|
| 76 |
+
"""Render friendly empty state."""
|
| 77 |
+
st.markdown("""
|
| 78 |
+
<div style="text-align:center;padding:60px 20px;color:#9ca3af">
|
| 79 |
+
<div style="font-size:48px;margin-bottom:12px">๐</div>
|
| 80 |
+
<div style="font-size:18px;font-weight:600;color:#6B7280;margin-bottom:8px">
|
| 81 |
+
์ก์
์์ดํ
์ด ์์ต๋๋ค
|
| 82 |
+
</div>
|
| 83 |
+
<div style="font-size:14px">
|
| 84 |
+
์์ ๋ถ์ ์คํ ๋ฒํผ์ผ๋ก ํธ๋ฆฌ๊ฑฐ๋ฅผ ํ๊ฐํ๊ณ ์ ์ฅํด๋ณด์ธ์
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
""", unsafe_allow_html=True)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _render_card(item: dict, idx: int, base_ctx: dict | None = None):
|
| 91 |
+
"""Render a single action item card."""
|
| 92 |
+
item_id = item["id"]
|
| 93 |
+
current_status = item.get("status", "pending")
|
| 94 |
+
priority = item.get("priority", 50)
|
| 95 |
+
category = item.get("category", "")
|
| 96 |
+
label = item.get("label", "")
|
| 97 |
+
evidence = item.get("evidence") or ""
|
| 98 |
+
llm_rec = item.get("llm_recommendation") or ""
|
| 99 |
+
created = (item.get("created_at") or "")[:10]
|
| 100 |
+
|
| 101 |
+
p_label, p_color, p_bg = priority_level(priority)
|
| 102 |
+
cat_cfg = CATEGORY_CONFIG.get(category, {"color": "#666", "icon": "๐"})
|
| 103 |
+
|
| 104 |
+
# Card header HTML
|
| 105 |
+
st.markdown(f"""
|
| 106 |
+
<div style="background:white;border:1px solid #E5E7EB;border-radius:12px;
|
| 107 |
+
padding:20px;margin-bottom:4px;border-left:4px solid {p_color}">
|
| 108 |
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
| 109 |
+
<div style="display:flex;gap:8px;align-items:center">
|
| 110 |
+
<span style="background:{p_bg};color:{p_color};padding:2px 10px;
|
| 111 |
+
border-radius:12px;font-size:12px;font-weight:600">{p_label} {priority}</span>
|
| 112 |
+
<span style="background:{cat_cfg['color']}15;color:{cat_cfg['color']};padding:2px 10px;
|
| 113 |
+
border-radius:12px;font-size:12px">{cat_cfg['icon']} {category}</span>
|
| 114 |
+
</div>
|
| 115 |
+
<span style="font-size:11px;color:#9ca3af">{created}</span>
|
| 116 |
+
</div>
|
| 117 |
+
<div style="font-size:15px;font-weight:600;color:#1F2937;margin-bottom:6px;line-height:1.4">
|
| 118 |
+
{label}
|
| 119 |
+
</div>
|
| 120 |
+
{"<div style='font-size:13px;color:#6B7280;margin-bottom:6px;line-height:1.5'>" + evidence[:200] + ("..." if len(evidence) > 200 else "") + "</div>" if evidence else ""}
|
| 121 |
+
{"<div style='background:#EFF6FF;border-radius:8px;padding:10px;font-size:13px;color:#1D4ED8;margin-bottom:4px'>๐ก " + llm_rec + "</div>" if llm_rec else ""}
|
| 122 |
+
</div>
|
| 123 |
+
""", unsafe_allow_html=True)
|
| 124 |
+
|
| 125 |
+
# Interactive controls (Streamlit widgets, can't be inside HTML)
|
| 126 |
+
ctrl_cols = st.columns([2, 1, 1, 1])
|
| 127 |
+
with ctrl_cols[0]:
|
| 128 |
+
new_status = st.selectbox(
|
| 129 |
+
"์ํ",
|
| 130 |
+
options=STATUS_OPTIONS,
|
| 131 |
+
index=STATUS_OPTIONS.index(current_status),
|
| 132 |
+
format_func=lambda s: STATUS_LABELS.get(s, s),
|
| 133 |
+
key=f"ai_st_{item_id}",
|
| 134 |
+
label_visibility="collapsed",
|
| 135 |
+
)
|
| 136 |
+
if new_status != current_status:
|
| 137 |
+
if update_action_item_status(item_id, new_status):
|
| 138 |
+
st.rerun()
|
| 139 |
+
|
| 140 |
+
with ctrl_cols[1]:
|
| 141 |
+
assignee = item.get("assignee_email") or ""
|
| 142 |
+
if assignee:
|
| 143 |
+
st.caption(f"๐ค {assignee.split('@')[0]}")
|
| 144 |
+
|
| 145 |
+
with ctrl_cols[2]:
|
| 146 |
+
export_key = f"ai_export_html_{item_id}"
|
| 147 |
+
cached = st.session_state.get(export_key)
|
| 148 |
+
if cached:
|
| 149 |
+
st.download_button(
|
| 150 |
+
"๐ฅ ๋ค์ด๋ก๋",
|
| 151 |
+
data=b'\xef\xbb\xbf' + cached["content"].encode("utf-8"),
|
| 152 |
+
file_name=cached["file_name"],
|
| 153 |
+
mime="text/html; charset=utf-8",
|
| 154 |
+
key=f"ai_dl_{item_id}",
|
| 155 |
+
use_container_width=True,
|
| 156 |
+
)
|
| 157 |
+
else:
|
| 158 |
+
if st.button("๐ HTML", key=f"ai_export_{item_id}", type="secondary"):
|
| 159 |
+
with st.spinner("HTML ์์ฑ ์ค..."):
|
| 160 |
+
try:
|
| 161 |
+
from core.api_client import ChainShiftClient
|
| 162 |
+
ctx = base_ctx or {}
|
| 163 |
+
client = ChainShiftClient(
|
| 164 |
+
api_key=ctx.get("api_key"),
|
| 165 |
+
access_token=ctx.get("access_token"),
|
| 166 |
+
)
|
| 167 |
+
resp = client.export_action_item_html(item_id)
|
| 168 |
+
data = resp.get("data", {})
|
| 169 |
+
html_content = data.get("html_content", "") if isinstance(data, dict) else ""
|
| 170 |
+
if html_content:
|
| 171 |
+
safe_label = re.sub(r'[^\w\-]', '_', (label or "action_item")[:30])
|
| 172 |
+
st.session_state[export_key] = {
|
| 173 |
+
"content": html_content,
|
| 174 |
+
"file_name": f"{safe_label}_{item_id[:8]}.html",
|
| 175 |
+
}
|
| 176 |
+
st.rerun()
|
| 177 |
+
else:
|
| 178 |
+
st.error("HTML ์์ฑ ๊ฒฐ๊ณผ๊ฐ ๋น์ด์์ต๋๋ค.")
|
| 179 |
+
except Exception as e:
|
| 180 |
+
st.error(f"HTML ์์ฑ ์คํจ: {e}")
|
| 181 |
+
|
| 182 |
+
with ctrl_cols[3]:
|
| 183 |
+
if st.button("๐๏ธ ์ญ์ ", key=f"ai_del_{item_id}", type="secondary"):
|
| 184 |
+
if delete_action_item(item_id):
|
| 185 |
+
st.rerun()
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _render_trend_section(campaign_id: int):
|
| 189 |
+
"""Render trigger metric trend charts in an expander."""
|
| 190 |
+
with st.expander("๐ ์งํ ์ถ์ด", expanded=False):
|
| 191 |
+
# Reuse existing date range from session state
|
| 192 |
+
start_date = st.session_state.get("ai_trigger_start")
|
| 193 |
+
end_date = st.session_state.get("ai_trigger_end")
|
| 194 |
+
|
| 195 |
+
if not start_date or not end_date:
|
| 196 |
+
date_range = get_campaign_date_range(campaign_id)
|
| 197 |
+
if not date_range:
|
| 198 |
+
st.info("์บ ํ์ธ ๋ฐ์ดํฐ๊ฐ ์์ง ๋๊ธฐํ๋์ง ์์์ต๋๋ค.")
|
| 199 |
+
return
|
| 200 |
+
_, last_date_str = date_range
|
| 201 |
+
last_date = date.fromisoformat(last_date_str)
|
| 202 |
+
end_date = last_date
|
| 203 |
+
start_date = last_date - timedelta(days=13)
|
| 204 |
+
|
| 205 |
+
s = str(start_date)
|
| 206 |
+
e = str(end_date)
|
| 207 |
+
|
| 208 |
+
col1, col2 = st.columns(2)
|
| 209 |
+
with col1:
|
| 210 |
+
render_visibility_trend(campaign_id, s, e)
|
| 211 |
+
with col2:
|
| 212 |
+
render_citation_type_trend(campaign_id, s, e)
|
| 213 |
+
|
| 214 |
+
col3, col4 = st.columns(2)
|
| 215 |
+
with col3:
|
| 216 |
+
render_negative_rate_trend(campaign_id, s, e)
|
| 217 |
+
with col4:
|
| 218 |
+
render_action_items_history(campaign_id)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
PAGE_SIZE = 10
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def render(base_ctx):
|
| 225 |
+
"""Render action items overview with card-based UI."""
|
| 226 |
+
campaign_id = base_ctx.get("campaign_id")
|
| 227 |
+
if not campaign_id:
|
| 228 |
+
st.warning("์บ ํ์ธ์ ์ ํํด์ฃผ์ธ์.")
|
| 229 |
+
return
|
| 230 |
+
|
| 231 |
+
# โโ Stats โโ
|
| 232 |
+
stats = get_action_item_stats(campaign_id)
|
| 233 |
+
_render_stats(stats)
|
| 234 |
+
|
| 235 |
+
st.markdown("") # spacer
|
| 236 |
+
|
| 237 |
+
# โโ Trigger Analysis โโ
|
| 238 |
+
render_analysis_section(campaign_id, base_ctx)
|
| 239 |
+
|
| 240 |
+
# โโ Trend Charts โโ
|
| 241 |
+
_render_trend_section(campaign_id)
|
| 242 |
+
|
| 243 |
+
st.markdown("---")
|
| 244 |
+
|
| 245 |
+
# โโ Filters โโ
|
| 246 |
+
filter_cols = st.columns([1, 1, 1])
|
| 247 |
+
with filter_cols[0]:
|
| 248 |
+
status_filter = st.selectbox(
|
| 249 |
+
"์ํ ํํฐ",
|
| 250 |
+
options=["์ ์ฒด"] + [STATUS_LABELS[s] for s in STATUS_OPTIONS],
|
| 251 |
+
index=0,
|
| 252 |
+
key="ai_status_filter",
|
| 253 |
+
)
|
| 254 |
+
with filter_cols[1]:
|
| 255 |
+
cat_options = ["์ ์ฒด"] + list(CATEGORY_CONFIG.keys())
|
| 256 |
+
category_filter = st.selectbox(
|
| 257 |
+
"์นดํ
๊ณ ๋ฆฌ ํํฐ",
|
| 258 |
+
options=cat_options,
|
| 259 |
+
index=0,
|
| 260 |
+
key="ai_category_filter",
|
| 261 |
+
)
|
| 262 |
+
with filter_cols[2]:
|
| 263 |
+
SORT_OPTIONS = {
|
| 264 |
+
"์ต์ ์": ("created_at", True),
|
| 265 |
+
"์ค๋๋์": ("created_at", False),
|
| 266 |
+
"์ฐ์ ์์ ๋์์": ("priority", True),
|
| 267 |
+
"์ฐ์ ์์ ๋ฎ์์": ("priority", False),
|
| 268 |
+
}
|
| 269 |
+
sort_choice = st.selectbox(
|
| 270 |
+
"์ ๋ ฌ",
|
| 271 |
+
options=list(SORT_OPTIONS.keys()),
|
| 272 |
+
index=0,
|
| 273 |
+
key="ai_sort",
|
| 274 |
+
)
|
| 275 |
+
sort_col, sort_desc = SORT_OPTIONS[sort_choice]
|
| 276 |
+
|
| 277 |
+
# Resolve filter values
|
| 278 |
+
selected_status = None
|
| 279 |
+
if status_filter != "์ ์ฒด":
|
| 280 |
+
for k, v in STATUS_LABELS.items():
|
| 281 |
+
if v == status_filter:
|
| 282 |
+
selected_status = k
|
| 283 |
+
break
|
| 284 |
+
|
| 285 |
+
selected_category = None if category_filter == "์ ์ฒด" else category_filter
|
| 286 |
+
|
| 287 |
+
# โโ Pagination state โโ
|
| 288 |
+
page_key = "ai_page"
|
| 289 |
+
if page_key not in st.session_state:
|
| 290 |
+
st.session_state[page_key] = 1
|
| 291 |
+
current_page = st.session_state[page_key]
|
| 292 |
+
|
| 293 |
+
# โโ Fetch items โโ
|
| 294 |
+
items, total = get_action_items(
|
| 295 |
+
campaign_id,
|
| 296 |
+
status=selected_status,
|
| 297 |
+
category=selected_category,
|
| 298 |
+
page=current_page,
|
| 299 |
+
page_size=PAGE_SIZE,
|
| 300 |
+
order_by=sort_col,
|
| 301 |
+
desc=sort_desc,
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
if not items and current_page == 1:
|
| 305 |
+
_render_empty_state()
|
| 306 |
+
return
|
| 307 |
+
|
| 308 |
+
total_pages = max(1, -(-total // PAGE_SIZE)) # ceil division
|
| 309 |
+
st.caption(f"์ด **{total}**๊ฑด ยท ํ์ด์ง {current_page}/{total_pages}")
|
| 310 |
+
|
| 311 |
+
# โโ Card grid (2 columns) โโ
|
| 312 |
+
for i in range(0, len(items), 2):
|
| 313 |
+
cols = st.columns(2)
|
| 314 |
+
for col_idx, col in enumerate(cols):
|
| 315 |
+
item_idx = i + col_idx
|
| 316 |
+
if item_idx < len(items):
|
| 317 |
+
with col:
|
| 318 |
+
_render_card(items[item_idx], item_idx, base_ctx)
|
| 319 |
+
|
| 320 |
+
# โโ Pagination controls โโ
|
| 321 |
+
if total_pages > 1:
|
| 322 |
+
st.markdown("")
|
| 323 |
+
nav_cols = st.columns([1, 2, 1])
|
| 324 |
+
with nav_cols[0]:
|
| 325 |
+
if current_page > 1:
|
| 326 |
+
if st.button("โ ์ด์ ", key="ai_prev", use_container_width=True):
|
| 327 |
+
st.session_state[page_key] = current_page - 1
|
| 328 |
+
st.rerun()
|
| 329 |
+
with nav_cols[1]:
|
| 330 |
+
st.markdown(
|
| 331 |
+
f"<div style='text-align:center;color:#9ca3af;padding:8px'>"
|
| 332 |
+
f"{current_page} / {total_pages}</div>",
|
| 333 |
+
unsafe_allow_html=True,
|
| 334 |
+
)
|
| 335 |
+
with nav_cols[2]:
|
| 336 |
+
if current_page < total_pages:
|
| 337 |
+
if st.button("๋ค์ โ", key="ai_next", use_container_width=True):
|
| 338 |
+
st.session_state[page_key] = current_page + 1
|
| 339 |
+
st.rerun()
|
features/action_items/trend_charts.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Trigger metric trend charts โ Plotly mini charts for action items dashboard."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from collections import defaultdict
|
| 5 |
+
from datetime import date, timedelta
|
| 6 |
+
|
| 7 |
+
import plotly.graph_objects as go
|
| 8 |
+
import streamlit as st
|
| 9 |
+
|
| 10 |
+
CHART_HEIGHT = 260
|
| 11 |
+
MINI_LAYOUT = dict(
|
| 12 |
+
margin=dict(t=30, b=40, l=50, r=20),
|
| 13 |
+
height=CHART_HEIGHT,
|
| 14 |
+
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
|
| 15 |
+
xaxis=dict(tickformat="%m/%d"),
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _get_client():
|
| 20 |
+
from core.supabase_client import get_supabase_client
|
| 21 |
+
return get_supabase_client()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@st.cache_data(ttl=120)
|
| 25 |
+
def _fetch_visibility_data(campaign_id: int, start_date: str, end_date: str) -> list[dict]:
|
| 26 |
+
"""Cached fetch for visibility trend data."""
|
| 27 |
+
client = _get_client()
|
| 28 |
+
try:
|
| 29 |
+
result = (
|
| 30 |
+
client.table("report_visibility_daily")
|
| 31 |
+
.select("task_date, brand_name, brand_type, visibility_pct")
|
| 32 |
+
.eq("campaign_id", campaign_id)
|
| 33 |
+
.gte("task_date", start_date)
|
| 34 |
+
.lte("task_date", end_date)
|
| 35 |
+
.order("task_date")
|
| 36 |
+
.execute()
|
| 37 |
+
)
|
| 38 |
+
return result.data or []
|
| 39 |
+
except Exception:
|
| 40 |
+
return []
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@st.cache_data(ttl=120)
|
| 44 |
+
def _fetch_citation_type_data(campaign_id: int, start_date: str, end_date: str) -> list[dict]:
|
| 45 |
+
"""Cached fetch for citation type trend data."""
|
| 46 |
+
client = _get_client()
|
| 47 |
+
try:
|
| 48 |
+
result = (
|
| 49 |
+
client.table("report_source_daily")
|
| 50 |
+
.select("task_date, source_host_type, citation_count")
|
| 51 |
+
.eq("campaign_id", campaign_id)
|
| 52 |
+
.eq("agg_level", "host")
|
| 53 |
+
.gte("task_date", start_date)
|
| 54 |
+
.lte("task_date", end_date)
|
| 55 |
+
.order("task_date")
|
| 56 |
+
.execute()
|
| 57 |
+
)
|
| 58 |
+
return result.data or []
|
| 59 |
+
except Exception:
|
| 60 |
+
return []
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@st.cache_data(ttl=120)
|
| 64 |
+
def _fetch_negative_rate_data(campaign_id: int, start_date: str, end_date: str) -> list[dict]:
|
| 65 |
+
"""Cached fetch for negative sentiment rate data."""
|
| 66 |
+
client = _get_client()
|
| 67 |
+
try:
|
| 68 |
+
result = client.rpc("get_nudge_export_data", {
|
| 69 |
+
"p_campaign_id": campaign_id,
|
| 70 |
+
"p_in_house_only": False,
|
| 71 |
+
"p_date_from": f"{start_date}T00:00:00+00:00",
|
| 72 |
+
"p_date_to": f"{_next_day(end_date)}T00:00:00+00:00",
|
| 73 |
+
"p_limit": 10000,
|
| 74 |
+
}).execute()
|
| 75 |
+
data = result.data or {}
|
| 76 |
+
return data.get("rows", []) if isinstance(data, dict) else []
|
| 77 |
+
except Exception:
|
| 78 |
+
return []
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@st.cache_data(ttl=120)
|
| 82 |
+
def _fetch_action_items_history(campaign_id: int) -> list[dict]:
|
| 83 |
+
"""Cached fetch for action items history data."""
|
| 84 |
+
client = _get_client()
|
| 85 |
+
try:
|
| 86 |
+
result = (
|
| 87 |
+
client.table("action_items")
|
| 88 |
+
.select("created_at, completed_at, status")
|
| 89 |
+
.eq("campaign_id", campaign_id)
|
| 90 |
+
.order("created_at")
|
| 91 |
+
.limit(5000)
|
| 92 |
+
.execute()
|
| 93 |
+
)
|
| 94 |
+
return result.data or []
|
| 95 |
+
except Exception:
|
| 96 |
+
return []
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ============================================================================
|
| 100 |
+
# Chart 1: Visibility trend (own brand vs competitor average)
|
| 101 |
+
# ============================================================================
|
| 102 |
+
|
| 103 |
+
def render_visibility_trend(campaign_id: int, start_date: str, end_date: str):
|
| 104 |
+
"""Show daily own-brand vs competitor average visibility."""
|
| 105 |
+
rows = _fetch_visibility_data(campaign_id, start_date, end_date)
|
| 106 |
+
|
| 107 |
+
if not rows:
|
| 108 |
+
st.info("๊ฐ์์ฑ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.")
|
| 109 |
+
return
|
| 110 |
+
|
| 111 |
+
# Group by date: own avg, competitor avg
|
| 112 |
+
own_by_date: dict[str, list[float]] = defaultdict(list)
|
| 113 |
+
comp_by_date: dict[str, list[float]] = defaultdict(list)
|
| 114 |
+
|
| 115 |
+
for r in rows:
|
| 116 |
+
d = r["task_date"]
|
| 117 |
+
pct = r.get("visibility_pct", 0)
|
| 118 |
+
if r.get("brand_type") == "PRIMARY":
|
| 119 |
+
own_by_date[d].append(pct)
|
| 120 |
+
else:
|
| 121 |
+
comp_by_date[d].append(pct)
|
| 122 |
+
|
| 123 |
+
dates = sorted(set(list(own_by_date.keys()) + list(comp_by_date.keys())))
|
| 124 |
+
own_avgs = [
|
| 125 |
+
sum(own_by_date[d]) / len(own_by_date[d]) if own_by_date.get(d) else None
|
| 126 |
+
for d in dates
|
| 127 |
+
]
|
| 128 |
+
comp_avgs = [
|
| 129 |
+
sum(comp_by_date[d]) / len(comp_by_date[d]) if comp_by_date.get(d) else None
|
| 130 |
+
for d in dates
|
| 131 |
+
]
|
| 132 |
+
|
| 133 |
+
fig = go.Figure()
|
| 134 |
+
fig.add_trace(go.Scatter(
|
| 135 |
+
x=dates, y=own_avgs,
|
| 136 |
+
mode="lines+markers", name="์์ฌ",
|
| 137 |
+
line=dict(color="#3B82F6", width=2),
|
| 138 |
+
marker=dict(size=5),
|
| 139 |
+
))
|
| 140 |
+
fig.add_trace(go.Scatter(
|
| 141 |
+
x=dates, y=comp_avgs,
|
| 142 |
+
mode="lines+markers", name="๊ฒฝ์์ฌ ํ๊ท ",
|
| 143 |
+
line=dict(color="#EF4444", width=2, dash="dash"),
|
| 144 |
+
marker=dict(size=5),
|
| 145 |
+
))
|
| 146 |
+
fig.update_layout(
|
| 147 |
+
title=dict(text="์์ฌ vs ๊ฒฝ์์ฌ ๊ฐ์์ฑ", font=dict(size=13)),
|
| 148 |
+
yaxis_title="Visibility %",
|
| 149 |
+
**MINI_LAYOUT,
|
| 150 |
+
)
|
| 151 |
+
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# ============================================================================
|
| 155 |
+
# Chart 2: Citation type trend (OFFICIAL % + top channel concentration)
|
| 156 |
+
# ============================================================================
|
| 157 |
+
|
| 158 |
+
def render_citation_type_trend(campaign_id: int, start_date: str, end_date: str):
|
| 159 |
+
"""Show daily OFFICIAL citation % and top channel concentration."""
|
| 160 |
+
rows = _fetch_citation_type_data(campaign_id, start_date, end_date)
|
| 161 |
+
|
| 162 |
+
if not rows:
|
| 163 |
+
st.info("์ธ์ฉ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.")
|
| 164 |
+
return
|
| 165 |
+
|
| 166 |
+
# Group by date
|
| 167 |
+
by_date: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
| 168 |
+
for r in rows:
|
| 169 |
+
d = r["task_date"]
|
| 170 |
+
ht = r.get("source_host_type") or "UNKNOWN"
|
| 171 |
+
by_date[d][ht] += r.get("citation_count", 0)
|
| 172 |
+
|
| 173 |
+
dates = sorted(by_date.keys())
|
| 174 |
+
official_pcts = []
|
| 175 |
+
top_channel_pcts = []
|
| 176 |
+
|
| 177 |
+
for d in dates:
|
| 178 |
+
type_counts = by_date[d]
|
| 179 |
+
total = sum(type_counts.values())
|
| 180 |
+
if total > 0:
|
| 181 |
+
official_pcts.append(round(type_counts.get("OFFICIAL", 0) / total * 100, 1))
|
| 182 |
+
max_count = max(type_counts.values())
|
| 183 |
+
top_channel_pcts.append(round(max_count / total * 100, 1))
|
| 184 |
+
else:
|
| 185 |
+
official_pcts.append(0)
|
| 186 |
+
top_channel_pcts.append(0)
|
| 187 |
+
|
| 188 |
+
fig = go.Figure()
|
| 189 |
+
fig.add_trace(go.Scatter(
|
| 190 |
+
x=dates, y=official_pcts,
|
| 191 |
+
mode="lines+markers", name="OFFICIAL %",
|
| 192 |
+
line=dict(color="#7C3AED", width=2),
|
| 193 |
+
marker=dict(size=5),
|
| 194 |
+
))
|
| 195 |
+
fig.add_trace(go.Scatter(
|
| 196 |
+
x=dates, y=top_channel_pcts,
|
| 197 |
+
mode="lines+markers", name="Top ์ฑ๋ %",
|
| 198 |
+
line=dict(color="#F59E0B", width=2, dash="dot"),
|
| 199 |
+
marker=dict(size=5),
|
| 200 |
+
))
|
| 201 |
+
# Threshold lines
|
| 202 |
+
fig.add_hline(y=10, line_dash="dash", line_color="#EF4444", opacity=0.5,
|
| 203 |
+
annotation_text="OFFICIAL 10%", annotation_position="bottom right")
|
| 204 |
+
fig.add_hline(y=50, line_dash="dash", line_color="#F97316", opacity=0.5,
|
| 205 |
+
annotation_text="์ง์ค 50%", annotation_position="top right")
|
| 206 |
+
fig.update_layout(
|
| 207 |
+
title=dict(text="์ธ์ฉ ์ ํ ์ถ์ด", font=dict(size=13)),
|
| 208 |
+
yaxis_title="%",
|
| 209 |
+
**MINI_LAYOUT,
|
| 210 |
+
)
|
| 211 |
+
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# ============================================================================
|
| 215 |
+
# Chart 3: Negative sentiment rate trend
|
| 216 |
+
# ============================================================================
|
| 217 |
+
|
| 218 |
+
def render_negative_rate_trend(campaign_id: int, start_date: str, end_date: str):
|
| 219 |
+
"""Show daily in-house brand negative sentiment rate."""
|
| 220 |
+
rows = _fetch_negative_rate_data(campaign_id, start_date, end_date)
|
| 221 |
+
|
| 222 |
+
if not rows:
|
| 223 |
+
st.info("๊ฐ์ฑ ๋ถ์ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.")
|
| 224 |
+
return
|
| 225 |
+
|
| 226 |
+
if len(rows) >= 10000:
|
| 227 |
+
st.warning("๊ฐ์ฑ ๋ฐ์ดํฐ๊ฐ 10,000๊ฑด์ ์ด๊ณผํ์ฌ ์ผ๋ถ๋ง ํ์๋ฉ๋๋ค. ๊ธฐ๊ฐ์ ์ขํ๋ณด์ธ์.")
|
| 228 |
+
|
| 229 |
+
# Group by date
|
| 230 |
+
total_by_date: dict[str, int] = defaultdict(int)
|
| 231 |
+
neg_by_date: dict[str, int] = defaultdict(int)
|
| 232 |
+
|
| 233 |
+
for r in rows:
|
| 234 |
+
d = (r.get("analyzed_at") or "")[:10]
|
| 235 |
+
if not d:
|
| 236 |
+
continue
|
| 237 |
+
total_by_date[d] += 1
|
| 238 |
+
if r.get("overall_polarity") == "negative":
|
| 239 |
+
neg_by_date[d] += 1
|
| 240 |
+
|
| 241 |
+
dates = sorted(total_by_date.keys())
|
| 242 |
+
neg_rates = [
|
| 243 |
+
round(neg_by_date.get(d, 0) / total_by_date[d] * 100, 1) if total_by_date[d] > 0 else 0
|
| 244 |
+
for d in dates
|
| 245 |
+
]
|
| 246 |
+
|
| 247 |
+
fig = go.Figure()
|
| 248 |
+
fig.add_trace(go.Scatter(
|
| 249 |
+
x=dates, y=neg_rates,
|
| 250 |
+
mode="lines+markers", name="๋ถ์ ๋น์จ",
|
| 251 |
+
line=dict(color="#EF4444", width=2),
|
| 252 |
+
marker=dict(size=5),
|
| 253 |
+
fill="tozeroy",
|
| 254 |
+
fillcolor="rgba(239,68,68,0.1)",
|
| 255 |
+
))
|
| 256 |
+
fig.add_hline(y=30, line_dash="dash", line_color="#F97316", opacity=0.5,
|
| 257 |
+
annotation_text="๊ฒฝ๊ณ 30%", annotation_position="top right")
|
| 258 |
+
fig.update_layout(
|
| 259 |
+
title=dict(text="๋ถ์ ๊ฐ์ฑ ๋น์จ ์ถ์ด", font=dict(size=13)),
|
| 260 |
+
yaxis_title="๋ถ์ %",
|
| 261 |
+
**MINI_LAYOUT,
|
| 262 |
+
)
|
| 263 |
+
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
# ============================================================================
|
| 267 |
+
# Chart 4: Action items history (created vs completed per week)
|
| 268 |
+
# ============================================================================
|
| 269 |
+
|
| 270 |
+
def render_action_items_history(campaign_id: int):
|
| 271 |
+
"""Show weekly created vs completed action items."""
|
| 272 |
+
rows = _fetch_action_items_history(campaign_id)
|
| 273 |
+
|
| 274 |
+
if not rows:
|
| 275 |
+
st.info("์ก์
์์ดํ
์ด๋ ฅ์ด ์์ต๋๋ค.")
|
| 276 |
+
return
|
| 277 |
+
|
| 278 |
+
# Group by ISO week
|
| 279 |
+
created_by_week: dict[str, int] = defaultdict(int)
|
| 280 |
+
completed_by_week: dict[str, int] = defaultdict(int)
|
| 281 |
+
|
| 282 |
+
for r in rows:
|
| 283 |
+
c_date = (r.get("created_at") or "")[:10]
|
| 284 |
+
if c_date:
|
| 285 |
+
week = _iso_week_label(c_date)
|
| 286 |
+
created_by_week[week] += 1
|
| 287 |
+
|
| 288 |
+
if r.get("status") == "completed" and r.get("completed_at"):
|
| 289 |
+
d_date = r["completed_at"][:10]
|
| 290 |
+
week = _iso_week_label(d_date)
|
| 291 |
+
completed_by_week[week] += 1
|
| 292 |
+
|
| 293 |
+
weeks = sorted(set(list(created_by_week.keys()) + list(completed_by_week.keys())))
|
| 294 |
+
created_vals = [created_by_week.get(w, 0) for w in weeks]
|
| 295 |
+
completed_vals = [completed_by_week.get(w, 0) for w in weeks]
|
| 296 |
+
|
| 297 |
+
fig = go.Figure()
|
| 298 |
+
fig.add_trace(go.Bar(
|
| 299 |
+
x=weeks, y=created_vals, name="์์ฑ",
|
| 300 |
+
marker_color="#6366F1",
|
| 301 |
+
))
|
| 302 |
+
fig.add_trace(go.Bar(
|
| 303 |
+
x=weeks, y=completed_vals, name="์๋ฃ",
|
| 304 |
+
marker_color="#10B981",
|
| 305 |
+
))
|
| 306 |
+
fig.update_layout(
|
| 307 |
+
title=dict(text="์ฃผ๋ณ ์ก์
์์ดํ
์์ฑ/์๋ฃ", font=dict(size=13)),
|
| 308 |
+
barmode="group",
|
| 309 |
+
yaxis_title="๊ฑด์",
|
| 310 |
+
**MINI_LAYOUT,
|
| 311 |
+
)
|
| 312 |
+
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
# ============================================================================
|
| 316 |
+
# Helpers
|
| 317 |
+
# ============================================================================
|
| 318 |
+
|
| 319 |
+
def _next_day(date_str: str) -> str:
|
| 320 |
+
"""Return next day as ISO string (for half-open TIMESTAMPTZ filter)."""
|
| 321 |
+
d = date.fromisoformat(date_str)
|
| 322 |
+
return (d + timedelta(days=1)).isoformat()
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def _iso_week_label(date_str: str) -> str:
|
| 326 |
+
"""Convert date string to 'MM/DD' label of the week's Monday."""
|
| 327 |
+
d = date.fromisoformat(date_str)
|
| 328 |
+
monday = d - timedelta(days=d.weekday())
|
| 329 |
+
return monday.strftime("%m/%d")
|
features/action_items/triggers.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Independent trigger evaluation module โ queries Supabase directly.
|
| 2 |
+
|
| 3 |
+
Evaluates 14 trigger rules against cached report data:
|
| 4 |
+
- Rules 1-2: report_visibility_daily (visibility gap, decline)
|
| 5 |
+
- Rules 3, 5: report_source_daily (official citation, channel concentration)
|
| 6 |
+
- Rule 4: answer_sentiment_latest VIEW (negative spike)
|
| 7 |
+
- Rules 6-9: report_source_daily channel groups (editorial/ugc/reference/owned)
|
| 8 |
+
- Rules 10-12: report_source_brand_daily (coverage gap, format gap, share gap)
|
| 9 |
+
- Rules 13-14: answer_sentiment_latest VIEW question types (trust/risk, decision)
|
| 10 |
+
|
| 11 |
+
No API dependency โ all data comes from Supabase tables that
|
| 12 |
+
sync-worker syncs daily at 08:00 KST.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import logging
|
| 17 |
+
import sys
|
| 18 |
+
from datetime import date, timedelta
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _find_project_root() -> str | None:
|
| 25 |
+
"""Find project root by locating scripts/shared/trigger_rules.py.
|
| 26 |
+
|
| 27 |
+
Traverses parent directories instead of hardcoding parents[N]
|
| 28 |
+
to handle varying deployment paths (local dev, HuggingFace Space, Docker).
|
| 29 |
+
"""
|
| 30 |
+
current = Path(__file__).resolve().parent
|
| 31 |
+
for parent in current.parents:
|
| 32 |
+
if (parent / "scripts" / "shared" / "trigger_rules.py").exists():
|
| 33 |
+
return str(parent)
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
_project_root = _find_project_root()
|
| 38 |
+
if _project_root and _project_root not in sys.path:
|
| 39 |
+
sys.path.insert(0, _project_root)
|
| 40 |
+
|
| 41 |
+
# Graceful import: trigger rules may not be available in standalone
|
| 42 |
+
# dashboard deployments (e.g., HuggingFace Space without full repo).
|
| 43 |
+
_TRIGGERS_AVAILABLE = False
|
| 44 |
+
try:
|
| 45 |
+
from scripts.shared.trigger_rules import ( # noqa: E402
|
| 46 |
+
TRIGGER_RULES,
|
| 47 |
+
CHANNEL_GROUPS,
|
| 48 |
+
_avg_visibility_by_brand,
|
| 49 |
+
_is_primary,
|
| 50 |
+
_rule_visibility_gap,
|
| 51 |
+
_rule_visibility_decline,
|
| 52 |
+
_rule_official_low_citation,
|
| 53 |
+
_rule_negative_spike,
|
| 54 |
+
_rule_channel_concentration,
|
| 55 |
+
_rule_channel_type_low,
|
| 56 |
+
_rule_gap_coverage,
|
| 57 |
+
_rule_gap_format,
|
| 58 |
+
_rule_gap_share,
|
| 59 |
+
_rule_question_trust_risk,
|
| 60 |
+
_rule_question_decision,
|
| 61 |
+
_fetch_visibility,
|
| 62 |
+
_fetch_source_types,
|
| 63 |
+
_fetch_brand_sentiments,
|
| 64 |
+
_fetch_source_content_types,
|
| 65 |
+
_fetch_source_brand_mix,
|
| 66 |
+
_fetch_question_type_stats,
|
| 67 |
+
)
|
| 68 |
+
_TRIGGERS_AVAILABLE = True
|
| 69 |
+
except (ImportError, ModuleNotFoundError) as e:
|
| 70 |
+
logger.warning("trigger_rules not available (standalone dashboard?): %s", e)
|
| 71 |
+
TRIGGER_RULES = {}
|
| 72 |
+
CHANNEL_GROUPS = {}
|
| 73 |
+
|
| 74 |
+
# Re-export all shared symbols so existing imports keep working
|
| 75 |
+
__all__ = [
|
| 76 |
+
"TRIGGER_RULES",
|
| 77 |
+
"CHANNEL_GROUPS",
|
| 78 |
+
"TRIGGERS_AVAILABLE",
|
| 79 |
+
"evaluate_triggers",
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
TRIGGERS_AVAILABLE = _TRIGGERS_AVAILABLE
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def evaluate_triggers(
|
| 86 |
+
campaign_id: int,
|
| 87 |
+
start_date: str,
|
| 88 |
+
end_date: str,
|
| 89 |
+
) -> list[dict]:
|
| 90 |
+
"""Evaluate all trigger rules and return action items (max 14).
|
| 91 |
+
|
| 92 |
+
Args:
|
| 93 |
+
campaign_id: Campaign ID
|
| 94 |
+
start_date: Start date (YYYY-MM-DD)
|
| 95 |
+
end_date: End date (YYYY-MM-DD)
|
| 96 |
+
|
| 97 |
+
Returns:
|
| 98 |
+
List of action item dicts sorted by priority descending.
|
| 99 |
+
|
| 100 |
+
Raises:
|
| 101 |
+
RuntimeError: If trigger rules module is not available.
|
| 102 |
+
"""
|
| 103 |
+
if not _TRIGGERS_AVAILABLE:
|
| 104 |
+
raise RuntimeError(
|
| 105 |
+
"ํธ๋ฆฌ๊ฑฐ ๋ถ์์ ์ฌ์ฉํ ์ ์์ต๋๋ค. "
|
| 106 |
+
"scripts/shared/trigger_rules.py๊ฐ ํ์ํฉ๋๋ค."
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
from core.supabase_client import get_supabase_client
|
| 110 |
+
|
| 111 |
+
client = get_supabase_client()
|
| 112 |
+
items: list[dict] = []
|
| 113 |
+
|
| 114 |
+
# โโ Data collection โโ
|
| 115 |
+
visibility = _fetch_visibility(client, campaign_id, start_date, end_date)
|
| 116 |
+
source_types = _fetch_source_types(client, campaign_id, start_date, end_date)
|
| 117 |
+
|
| 118 |
+
# โโ Rule 1: visibility_gap โ own brand < competitor average โโ
|
| 119 |
+
items.extend(_rule_visibility_gap(visibility))
|
| 120 |
+
|
| 121 |
+
# โโ Rule 2: visibility_decline โ delta <= -3pp vs prior period โโ
|
| 122 |
+
period_days = (date.fromisoformat(end_date) - date.fromisoformat(start_date)).days + 1
|
| 123 |
+
prior_end = date.fromisoformat(start_date) - timedelta(days=1)
|
| 124 |
+
prior_start = prior_end - timedelta(days=period_days - 1)
|
| 125 |
+
prior_visibility = _fetch_visibility(
|
| 126 |
+
client, campaign_id, prior_start.isoformat(), prior_end.isoformat(),
|
| 127 |
+
)
|
| 128 |
+
items.extend(_rule_visibility_decline(visibility, prior_visibility))
|
| 129 |
+
|
| 130 |
+
# โโ Rule 3: official_low_citation โ OFFICIAL < 10% โโ
|
| 131 |
+
items.extend(_rule_official_low_citation(source_types))
|
| 132 |
+
|
| 133 |
+
# โโ Rule 4: negative_spike โ in-house negative > 30% โโ
|
| 134 |
+
brand_sentiments = _fetch_brand_sentiments(client, campaign_id, visibility)
|
| 135 |
+
items.extend(_rule_negative_spike(brand_sentiments))
|
| 136 |
+
|
| 137 |
+
# โโ Rule 5: channel_concentration โ single channel > 50% โโ
|
| 138 |
+
items.extend(_rule_channel_concentration(source_types))
|
| 139 |
+
|
| 140 |
+
# โโ Rules 6-9: channel type rules โโ
|
| 141 |
+
for channel_name in CHANNEL_GROUPS:
|
| 142 |
+
items.extend(_rule_channel_type_low(source_types, channel_name))
|
| 143 |
+
|
| 144 |
+
# โโ Rules 10-12: gap rules โโ
|
| 145 |
+
source_brand_mix = _fetch_source_brand_mix(client, campaign_id, start_date, end_date)
|
| 146 |
+
content_types = _fetch_source_content_types(client, campaign_id, start_date, end_date)
|
| 147 |
+
|
| 148 |
+
items.extend(_rule_gap_coverage(source_brand_mix))
|
| 149 |
+
items.extend(_rule_gap_format(content_types))
|
| 150 |
+
items.extend(_rule_gap_share(source_brand_mix))
|
| 151 |
+
|
| 152 |
+
# โโ Rules 13-14: question type rules โโ
|
| 153 |
+
question_stats = _fetch_question_type_stats(client, campaign_id)
|
| 154 |
+
items.extend(_rule_question_trust_risk(question_stats))
|
| 155 |
+
items.extend(_rule_question_decision(question_stats, source_types))
|
| 156 |
+
|
| 157 |
+
items.sort(key=lambda x: x["priority"], reverse=True)
|
| 158 |
+
return items[:14]
|
features/action_items/utils.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Action Items utility functions โ status/priority/category styling."""
|
| 2 |
+
|
| 3 |
+
STATUS_CONFIG = {
|
| 4 |
+
"pending": {"emoji": "๐ด", "label": "๋๊ธฐ", "color": "#ff6b6b", "bg": "#FEE2E2"},
|
| 5 |
+
"in_progress": {"emoji": "๐ก", "label": "์งํ", "color": "#f59e0b", "bg": "#FEF3C7"},
|
| 6 |
+
"completed": {"emoji": "๐ข", "label": "์๋ฃ", "color": "#10b981", "bg": "#D1FAE5"},
|
| 7 |
+
"archived": {"emoji": "โช", "label": "๋ณด๊ด", "color": "#9ca3af", "bg": "#F3F4F6"},
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
STATUS_OPTIONS = ["pending", "in_progress", "completed", "archived"]
|
| 11 |
+
STATUS_LABELS = {k: f"{v['emoji']} {v['label']}" for k, v in STATUS_CONFIG.items()}
|
| 12 |
+
|
| 13 |
+
CATEGORY_CONFIG = {
|
| 14 |
+
"ํ๋ซํผ์ต์ ํ": {"color": "#4361ee", "icon": "๐"},
|
| 15 |
+
"์ฑ๋์ ๋ต": {"color": "#3a0ca3", "icon": "๐ก"},
|
| 16 |
+
"SEO๊ฐํ": {"color": "#7209b7", "icon": "๐"},
|
| 17 |
+
"๊ฐ์ฑ๊ด๋ฆฌ": {"color": "#f72585", "icon": "๐ฌ"},
|
| 18 |
+
"์ฝํ
์ธ ๊ฐ์ ": {"color": "#4cc9f0", "icon": "๐"},
|
| 19 |
+
"๋ถ์ ๊ฐ์ฑ๋์": {"color": "#e63946", "icon": "๐ก๏ธ"},
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
# Legacy alias
|
| 23 |
+
CATEGORY_COLORS = {k: v["color"] for k, v in CATEGORY_CONFIG.items()}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def priority_level(priority: int) -> tuple[str, str, str]:
|
| 27 |
+
"""Return (label, color, bg) based on priority score."""
|
| 28 |
+
if priority >= 80:
|
| 29 |
+
return "๊ธด๊ธ", "#dc2626", "#FEE2E2"
|
| 30 |
+
if priority >= 60:
|
| 31 |
+
return "๋์", "#f59e0b", "#FEF3C7"
|
| 32 |
+
if priority >= 40:
|
| 33 |
+
return "๋ณดํต", "#3b82f6", "#DBEAFE"
|
| 34 |
+
return "๋ฎ์", "#9ca3af", "#F3F4F6"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def status_emoji(status: str) -> str:
|
| 38 |
+
"""Return emoji for status."""
|
| 39 |
+
return STATUS_CONFIG.get(status, {}).get("emoji", "โ")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def status_label(status: str) -> str:
|
| 43 |
+
"""Return localized label for status."""
|
| 44 |
+
return STATUS_CONFIG.get(status, {}).get("label", status)
|
features/hierarchy/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""๊ณ์ธต ๋ถ์ Feature Plugin.
|
| 2 |
+
|
| 3 |
+
API: /api/v1/hierarchy
|
| 4 |
+
ADR-017: Hierarchy Generator Python Migration
|
| 5 |
+
"""
|
| 6 |
+
import streamlit as st
|
| 7 |
+
|
| 8 |
+
from . import request, monitor
|
| 9 |
+
|
| 10 |
+
FEATURE_CONFIG = {
|
| 11 |
+
"key": "hierarchy",
|
| 12 |
+
"name": "๊ณ์ธต ๋ถ์",
|
| 13 |
+
"icon": "๐",
|
| 14 |
+
"description": "ํค์๋ ๊ธฐ๋ฐ ์๋น์ ์ฌ์ ๊ณ์ธต ๋ถ์ ๋ฐ ์ง๋ฌธ ์์ฑ",
|
| 15 |
+
"api_base": "/api/v1/hierarchy",
|
| 16 |
+
"order": 5,
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def render(base_ctx):
|
| 21 |
+
"""๊ณ์ธต ๋ถ์ feature ๋ ๋๋ง."""
|
| 22 |
+
st.caption("ํค์๋๋ฅผ ์
๋ ฅํ๋ฉด ์๋น์ ์ฌ์ (CEJ) ๊ธฐ๋ฐ์ผ๋ก ๊ณ์ธต ๊ตฌ์กฐ๋ฅผ ๋ถ์ํ๊ณ ์ง๋ฌธ์ ์์ฑํฉ๋๋ค")
|
| 23 |
+
|
| 24 |
+
tabs = st.tabs([
|
| 25 |
+
"๐ ๋ถ์ ์์ฒญ",
|
| 26 |
+
"โณ ์งํ ํํฉ",
|
| 27 |
+
"๐ ๋ถ์ ๊ฒฐ๊ณผ",
|
| 28 |
+
])
|
| 29 |
+
|
| 30 |
+
tab_renderers = [
|
| 31 |
+
("๋ถ์ ์์ฒญ", request.render),
|
| 32 |
+
("์งํ ํํฉ", monitor.render_active),
|
| 33 |
+
("๋ถ์ ๊ฒฐ๊ณผ", monitor.render_history),
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
for tab, (label, renderer) in zip(tabs, tab_renderers):
|
| 37 |
+
with tab:
|
| 38 |
+
try:
|
| 39 |
+
renderer(base_ctx)
|
| 40 |
+
except Exception as e:
|
| 41 |
+
st.error(f"{label} ๋ก๋ฉ ์คํจ: {e}")
|
features/hierarchy/monitor.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""๊ณ์ธต ๋ถ์ ์งํ ํํฉ + ๋ถ์ ๊ฒฐ๊ณผ ํญ.
|
| 2 |
+
|
| 3 |
+
์งํ ํํฉ: Supabase ์ง์ ์ฟผ๋ฆฌ (Vercel timeout ํํผ)
|
| 4 |
+
๋ถ์ ๊ฒฐ๊ณผ: API ํธ์ถ (์ง๋ฌธ ๋ฐ์ดํฐ)
|
| 5 |
+
"""
|
| 6 |
+
import streamlit as st
|
| 7 |
+
import pandas as pd
|
| 8 |
+
|
| 9 |
+
from core.api_client import ChainShiftClient
|
| 10 |
+
from core.job_realtime import (
|
| 11 |
+
get_active_hierarchy_jobs,
|
| 12 |
+
get_recent_hierarchy_jobs,
|
| 13 |
+
get_hierarchy_step_label,
|
| 14 |
+
format_job_duration,
|
| 15 |
+
get_status_emoji,
|
| 16 |
+
get_status_label,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ============================================================================
|
| 21 |
+
# ์งํ ํํฉ ํญ
|
| 22 |
+
# ============================================================================
|
| 23 |
+
|
| 24 |
+
def render_active(base_ctx: dict):
|
| 25 |
+
"""์งํ ํํฉ ํญ ๋ ๋๋ง."""
|
| 26 |
+
st.markdown("##### โณ ์งํ ํํฉ")
|
| 27 |
+
st.caption("๋๊ธฐ ์ค์ด๊ฑฐ๋ ์ฒ๋ฆฌ ์ค์ธ ๊ณ์ธต ๋ถ์ Job์ ํ์ธํฉ๋๋ค")
|
| 28 |
+
|
| 29 |
+
if st.button("๐ ์๋ก๊ณ ์นจ", key="hier:refresh_active"):
|
| 30 |
+
st.rerun()
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
active_jobs = get_active_hierarchy_jobs(limit=5)
|
| 34 |
+
except Exception as e:
|
| 35 |
+
st.warning(f"Job ๋ชฉ๋ก ๋ก๋ ์คํจ: {e}")
|
| 36 |
+
active_jobs = []
|
| 37 |
+
|
| 38 |
+
if not active_jobs:
|
| 39 |
+
st.info("ํ์ฌ ์งํ ์ค์ธ ๊ณ์ธต ๋ถ์ Job์ด ์์ต๋๋ค.")
|
| 40 |
+
return
|
| 41 |
+
|
| 42 |
+
for job in active_jobs:
|
| 43 |
+
_render_active_card(job)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _render_active_card(job: dict):
|
| 47 |
+
"""ํ์ฑ Job ์นด๋ ๋ ๋๋ง."""
|
| 48 |
+
progress = job.get("progress", 0)
|
| 49 |
+
status = job.get("status", "")
|
| 50 |
+
step = job.get("current_step")
|
| 51 |
+
step_label = get_hierarchy_step_label(step)
|
| 52 |
+
status_emoji = get_status_emoji(status)
|
| 53 |
+
status_label = get_status_label(status)
|
| 54 |
+
duration = format_job_duration(job)
|
| 55 |
+
prompt = job.get("prompt", "")
|
| 56 |
+
title = job.get("title") or prompt[:30]
|
| 57 |
+
|
| 58 |
+
st.markdown(f"""
|
| 59 |
+
<div style="background: linear-gradient(135deg, #EDE9FE 0%, #F5F3FF 100%);
|
| 60 |
+
border-radius: 12px; padding: 20px; margin-bottom: 16px;
|
| 61 |
+
border: 1px solid #C4B5FD;">
|
| 62 |
+
<div style="display: flex; justify-content: space-between; align-items: center;">
|
| 63 |
+
<div>
|
| 64 |
+
<span style="font-size: 28px;">{status_emoji}</span>
|
| 65 |
+
<span style="font-size: 18px; font-weight: bold; margin-left: 8px;">{title}</span>
|
| 66 |
+
</div>
|
| 67 |
+
<div style="text-align: right;">
|
| 68 |
+
<div style="font-size: 32px; font-weight: bold; color: #6D28D9;">{progress}%</div>
|
| 69 |
+
<div style="font-size: 12px; color: #6B7280;">์์์๊ฐ: {duration}</div>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
<div style="margin-top: 12px; font-size: 14px; color: #374151;">
|
| 73 |
+
{status_label} · {step_label}
|
| 74 |
+
</div>
|
| 75 |
+
<div style="margin-top: 4px; font-size: 12px; color: #6B7280;">
|
| 76 |
+
ํค์๋: {prompt}
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
""", unsafe_allow_html=True)
|
| 80 |
+
|
| 81 |
+
st.progress(progress / 100)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ============================================================================
|
| 85 |
+
# ๋ถ์ ๊ฒฐ๊ณผ ํญ
|
| 86 |
+
# ============================================================================
|
| 87 |
+
|
| 88 |
+
def render_history(base_ctx: dict):
|
| 89 |
+
"""๋ถ์ ๊ฒฐ๊ณผ ํญ ๋ ๋๋ง."""
|
| 90 |
+
st.markdown("##### ๐ ๋ถ์ ๊ฒฐ๊ณผ")
|
| 91 |
+
st.caption("์๋ฃ๋ ๊ณ์ธต ๋ถ์ Job ์ด๋ ฅ๊ณผ ๊ฒฐ๊ณผ๋ฅผ ํ์ธํฉ๋๋ค")
|
| 92 |
+
|
| 93 |
+
if st.button("๐ ์๋ก๊ณ ์นจ", key="hier:refresh_history"):
|
| 94 |
+
st.rerun()
|
| 95 |
+
|
| 96 |
+
try:
|
| 97 |
+
jobs = get_recent_hierarchy_jobs(limit=20)
|
| 98 |
+
except Exception as e:
|
| 99 |
+
st.warning(f"Job ์ด๋ ฅ ๋ก๋ ์คํจ: {e}")
|
| 100 |
+
jobs = []
|
| 101 |
+
|
| 102 |
+
if not jobs:
|
| 103 |
+
st.info("์์ง ๊ณ์ธต ๋ถ์ ์ด๋ ฅ์ด ์์ต๋๋ค.")
|
| 104 |
+
return
|
| 105 |
+
|
| 106 |
+
# Job ์ด๋ ฅ ํ
์ด๋ธ
|
| 107 |
+
rows = []
|
| 108 |
+
for j in jobs:
|
| 109 |
+
status = j.get("status", "")
|
| 110 |
+
emoji = get_status_emoji(status)
|
| 111 |
+
label = get_status_label(status)
|
| 112 |
+
duration = format_job_duration(j)
|
| 113 |
+
rows.append({
|
| 114 |
+
"์ํ": f"{emoji} {label}",
|
| 115 |
+
"์ ๋ชฉ": j.get("title") or "-",
|
| 116 |
+
"ํค์๋": j.get("prompt", "")[:30],
|
| 117 |
+
"์งํ๋ฅ ": f"{j.get('progress', 0)}%",
|
| 118 |
+
"์์์๊ฐ": duration,
|
| 119 |
+
"์์ฑ์ผ": (j.get("created_at") or "")[:19].replace("T", " "),
|
| 120 |
+
"ID": (j.get("id") or "")[:8],
|
| 121 |
+
})
|
| 122 |
+
|
| 123 |
+
st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True)
|
| 124 |
+
|
| 125 |
+
# ์๋ฃ๋ Job expander: ์ง๋ฌธ ํต๊ณ + ์ํ
|
| 126 |
+
completed_jobs = [j for j in jobs if j.get("status") == "completed"]
|
| 127 |
+
if not completed_jobs:
|
| 128 |
+
return
|
| 129 |
+
|
| 130 |
+
st.markdown("---")
|
| 131 |
+
st.markdown("###### ์๋ฃ๋ ๋ถ์ ์์ธ")
|
| 132 |
+
|
| 133 |
+
if not base_ctx.get("api_key") and not base_ctx.get("access_token"):
|
| 134 |
+
st.caption("์ง๋ฌธ ์์ธ ๋ณด๊ธฐ๋ ์ธ์ฆ์ด ํ์ํฉ๋๋ค.")
|
| 135 |
+
return
|
| 136 |
+
|
| 137 |
+
client = ChainShiftClient(
|
| 138 |
+
api_key=base_ctx.get("api_key"),
|
| 139 |
+
access_token=base_ctx.get("access_token"),
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
for job in completed_jobs[:5]:
|
| 143 |
+
job_id = job["id"]
|
| 144 |
+
title = job.get("title") or job.get("prompt", "")[:30]
|
| 145 |
+
created = (job.get("created_at") or "")[:10]
|
| 146 |
+
|
| 147 |
+
with st.expander(f"{title} ({created})", expanded=False):
|
| 148 |
+
_render_job_detail(client, job_id)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _render_job_detail(client: ChainShiftClient, job_id: str):
|
| 152 |
+
"""์๋ฃ๋ Job ์์ธ: ์ง๋ฌธ ํต๊ณ + ์ํ ์ง๋ฌธ."""
|
| 153 |
+
# ์ง๋ฌธ ํต๊ณ
|
| 154 |
+
try:
|
| 155 |
+
stats_resp = client.get_hierarchy_question_stats(job_id)
|
| 156 |
+
stats = (stats_resp or {}).get("data") or {}
|
| 157 |
+
except Exception as e:
|
| 158 |
+
st.warning(f"ํต๊ณ ๋ก๋ ์คํจ: {e}")
|
| 159 |
+
stats = {}
|
| 160 |
+
|
| 161 |
+
if stats:
|
| 162 |
+
total = stats.get("total_questions", 0)
|
| 163 |
+
brand_count = stats.get("brand_mention_count", 0)
|
| 164 |
+
persona_count = stats.get("persona_included_count", 0)
|
| 165 |
+
|
| 166 |
+
col1, col2, col3 = st.columns(3)
|
| 167 |
+
with col1:
|
| 168 |
+
st.metric("์ด ์ง๋ฌธ", f"{total:,}๊ฐ")
|
| 169 |
+
with col2:
|
| 170 |
+
st.metric("๋ธ๋๋ ํฌํจ", f"{brand_count:,}๊ฐ")
|
| 171 |
+
with col3:
|
| 172 |
+
st.metric("ํ๋ฅด์๋ ์ ์ฉ", f"{persona_count:,}๊ฐ")
|
| 173 |
+
|
| 174 |
+
# ์ฌ์ ๋ณ ๋ถํฌ
|
| 175 |
+
by_depth1 = stats.get("by_journey_depth1") or {}
|
| 176 |
+
if by_depth1:
|
| 177 |
+
st.markdown("**์ฌ์ ์ ํ๋ณ ๋ถํฌ**")
|
| 178 |
+
depth1_labels = {
|
| 179 |
+
"awareness_comparison": "์ธ์ง/๋น๊ต",
|
| 180 |
+
"purchase": "๊ตฌ๋งค",
|
| 181 |
+
"post_purchase": "๊ตฌ๋งค ํ",
|
| 182 |
+
}
|
| 183 |
+
depth1_rows = [
|
| 184 |
+
{"์ฌ์ ": depth1_labels.get(k, k), "์ง๋ฌธ ์": v}
|
| 185 |
+
for k, v in by_depth1.items()
|
| 186 |
+
]
|
| 187 |
+
st.dataframe(
|
| 188 |
+
pd.DataFrame(depth1_rows),
|
| 189 |
+
use_container_width=True,
|
| 190 |
+
hide_index=True,
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
# ์ํ ์ง๋ฌธ 10๊ฐ
|
| 194 |
+
try:
|
| 195 |
+
q_resp = client.get_hierarchy_questions(job_id, page=1, page_size=10)
|
| 196 |
+
questions = ((q_resp or {}).get("data") or {}).get("items") or []
|
| 197 |
+
except Exception:
|
| 198 |
+
questions = []
|
| 199 |
+
|
| 200 |
+
if questions:
|
| 201 |
+
st.markdown("**์ํ ์ง๋ฌธ (์ต๋ 10๊ฐ)**")
|
| 202 |
+
for i, q in enumerate(questions, 1):
|
| 203 |
+
journey = q.get("journey_depth2", "")
|
| 204 |
+
question_text = q.get("question", "")
|
| 205 |
+
brand = " ๐ท๏ธ" if q.get("brand_mention") else ""
|
| 206 |
+
st.markdown(f"{i}. [{journey}] {question_text}{brand}")
|
| 207 |
+
elif stats:
|
| 208 |
+
st.caption("์ง๋ฌธ ๋ฐ์ดํฐ๊ฐ ์์ง ์์ต๋๋ค.")
|
features/hierarchy/request.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""๊ณ์ธต ๋ถ์ ์์ฒญ ํญ.
|
| 2 |
+
|
| 3 |
+
5๋จ๊ณ ์ค์ ํผ โ ProcessorConfig ๋งคํ โ API ํธ์ถ.
|
| 4 |
+
"""
|
| 5 |
+
import streamlit as st
|
| 6 |
+
|
| 7 |
+
from core.api_client import ChainShiftClient
|
| 8 |
+
|
| 9 |
+
# 17 Journey Types across 3 depth1 groups
|
| 10 |
+
_JOURNEY_GROUPS: dict[str, list[tuple[str, str]]] = {
|
| 11 |
+
"์ธ์ง/๋น๊ต (Awareness & Comparison)": [
|
| 12 |
+
("verification", "์ฌ์คํ์ธ"),
|
| 13 |
+
("market_trends", "์ต์ ํธ๋ ๋"),
|
| 14 |
+
("preparation", "์ค๋น/ํ์"),
|
| 15 |
+
("timing", "์๊ธฐ/ํ์ด๋ฐ"),
|
| 16 |
+
("review_experience", "๋ฆฌ๋ทฐ/๊ฒฝํ"),
|
| 17 |
+
("information_discovery", "์ ๋ณดํ์/๊ฐ๋
"),
|
| 18 |
+
("result_effectiveness", "ํจ๊ณผ/๊ฒฐ๊ณผ"),
|
| 19 |
+
("recommendation", "๊ตฌ๋งค์ถ์ฒ"),
|
| 20 |
+
("comparison", "๊ตฌ๋งค์ถ์ฒ(๋น๊ต)"),
|
| 21 |
+
("problem_solving", "๋ฌธ์ ํด๊ฒฐ"),
|
| 22 |
+
("difference_pros_cons", "์ฐจ์ด์ /์ฅ๋จ์ "),
|
| 23 |
+
],
|
| 24 |
+
"๊ตฌ๋งค (Purchase)": [
|
| 25 |
+
("pricing", "๋น์ฉ/๊ฐ๊ฒฉ"),
|
| 26 |
+
("promotion_benefits", "ํ๋ก๋ชจ์
/ํ ์ธ/ํํ"),
|
| 27 |
+
("where_to_buy", "๊ตฌ๋งค์ฒ"),
|
| 28 |
+
],
|
| 29 |
+
"๊ตฌ๋งค ํ (Post-Purchase)": [
|
| 30 |
+
("howto", "์ ํ/์๋น์ค how-to"),
|
| 31 |
+
("refund_customer_service", "ํ๋ถ A/S"),
|
| 32 |
+
("side_effect", "๋ถ์์ฉ"),
|
| 33 |
+
],
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
_PRODUCT_TYPES = ["product", "service", "brand"]
|
| 37 |
+
_MODELS = ["gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-2.5-pro", "gemini-2.5-flash"]
|
| 38 |
+
_AGE_OPTIONS = ["10๋", "20๋", "30๋", "40๋", "50๋", "60๋ ์ด์"]
|
| 39 |
+
_GENDER_OPTIONS = ["์ฌ์ฑ", "๋จ์ฑ"]
|
| 40 |
+
_TRAIT_OPTIONS = ["๊ฐ์ฑ๋น์ค์", "ํ๋ฆฌ๋ฏธ์์ ํธ", "ํธ๋ ๋๋ฏผ๊ฐ", "์ค์ฉ์ฃผ์"]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def render(base_ctx: dict):
|
| 44 |
+
"""๋ถ์ ์์ฒญ ํผ ๋ ๋๋ง."""
|
| 45 |
+
st.markdown("##### ๐ ๊ณ์ธต ๋ถ์ ์์ฒญ")
|
| 46 |
+
st.caption("ํค์๋์ ๋ถ์ ์กฐ๊ฑด์ ์ค์ ํ๊ณ ๋ถ์์ ์์ํฉ๋๋ค")
|
| 47 |
+
|
| 48 |
+
if not base_ctx.get("api_key") and not base_ctx.get("access_token"):
|
| 49 |
+
st.warning("์ธ์ฆ ์ ๋ณด๊ฐ ์ค์ ๋์ง ์์์ต๋๋ค.")
|
| 50 |
+
return
|
| 51 |
+
|
| 52 |
+
client = ChainShiftClient(
|
| 53 |
+
api_key=base_ctx.get("api_key"),
|
| 54 |
+
access_token=base_ctx.get("access_token"),
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# โโ Step 1: ์
๋ ฅ ๋ถ์ โโ
|
| 58 |
+
st.markdown("###### 1. ์
๋ ฅ ๋ถ์")
|
| 59 |
+
col1, col2 = st.columns([3, 1])
|
| 60 |
+
with col1:
|
| 61 |
+
raw_text = st.text_input(
|
| 62 |
+
"๋ถ์ ํค์๋",
|
| 63 |
+
key="hier:raw_text",
|
| 64 |
+
placeholder="์: ๊ฐ์์ง ์ฌ๋ฃ, ์ฌํ ๊ฐ๋ฐฉ, ์ ๊ธฐ์ฐจ ๋ณดํ",
|
| 65 |
+
)
|
| 66 |
+
with col2:
|
| 67 |
+
product_type = st.selectbox(
|
| 68 |
+
"์ ํ ์ ํ",
|
| 69 |
+
_PRODUCT_TYPES,
|
| 70 |
+
key="hier:product_type",
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
title = st.text_input(
|
| 74 |
+
"๋ถ์ ์ ๋ชฉ (์ ํ)",
|
| 75 |
+
key="hier:title",
|
| 76 |
+
placeholder="๋ถ์ ๊ฒฐ๊ณผ ๊ตฌ๋ถ์ฉ ์ ๋ชฉ",
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# โโ Step 2: ์ฌ์ ์ ํ โโ
|
| 80 |
+
st.markdown("---")
|
| 81 |
+
st.markdown("###### 2. ์๋น์ ์ฌ์ ์ ํ")
|
| 82 |
+
st.caption("๋ถ์์ ํฌํจํ ์ฌ์ ์ ํ์ ์ ํํ์ธ์ (๊ธฐ๋ณธ: ๊ตฌ๋งค์ถ์ฒ)")
|
| 83 |
+
|
| 84 |
+
selected_journeys: list[str] = []
|
| 85 |
+
for group_label, types in _JOURNEY_GROUPS.items():
|
| 86 |
+
with st.expander(group_label, expanded=(group_label.startswith("์ธ์ง"))):
|
| 87 |
+
for code, label in types:
|
| 88 |
+
default = code == "recommendation"
|
| 89 |
+
if st.checkbox(
|
| 90 |
+
label,
|
| 91 |
+
value=default,
|
| 92 |
+
key=f"hier:cej:{code}",
|
| 93 |
+
):
|
| 94 |
+
selected_journeys.append(code)
|
| 95 |
+
|
| 96 |
+
# โโ Step 3: ํ๋ฅด์๋ (์ ํ) โโ
|
| 97 |
+
st.markdown("---")
|
| 98 |
+
st.markdown("###### 3. ํ๋ฅด์๋ ์ค์ (์ ํ)")
|
| 99 |
+
|
| 100 |
+
use_persona = st.checkbox("ํ๋ฅด์๋ ์ ์ฉ", key="hier:use_persona")
|
| 101 |
+
persona_ages: list[str] = []
|
| 102 |
+
persona_gender: str | None = None
|
| 103 |
+
persona_trait: str | None = None
|
| 104 |
+
|
| 105 |
+
if use_persona:
|
| 106 |
+
col1, col2, col3 = st.columns(3)
|
| 107 |
+
with col1:
|
| 108 |
+
persona_ages = st.multiselect("์ฐ๋ น๋", _AGE_OPTIONS, key="hier:ages")
|
| 109 |
+
with col2:
|
| 110 |
+
gender_sel = st.selectbox(
|
| 111 |
+
"์ฑ๋ณ", ["์ ํ ์ํจ"] + _GENDER_OPTIONS, key="hier:gender",
|
| 112 |
+
)
|
| 113 |
+
persona_gender = gender_sel if gender_sel != "์ ํ ์ํจ" else None
|
| 114 |
+
with col3:
|
| 115 |
+
trait_sel = st.selectbox(
|
| 116 |
+
"์๋น ์ฑํฅ", ["์ ํ ์ํจ"] + _TRAIT_OPTIONS, key="hier:trait",
|
| 117 |
+
)
|
| 118 |
+
persona_trait = trait_sel if trait_sel != "์ ํ ์ํจ" else None
|
| 119 |
+
|
| 120 |
+
# โโ Step 4: ๋ธ๋๋ ์ปจํ
์คํธ (์ ํ) โโ
|
| 121 |
+
st.markdown("---")
|
| 122 |
+
st.markdown("###### 4. ๋ธ๋๋ ์ปจํ
์คํธ (์ ํ)")
|
| 123 |
+
|
| 124 |
+
brand_mention = st.checkbox(
|
| 125 |
+
"์ง๋ฌธ์ ๋ธ๋๋ ํฌํจ",
|
| 126 |
+
key="hier:brand_mention",
|
| 127 |
+
help="ํ์ฑํํ๋ฉด ์์ฑ๋ ์ง๋ฌธ์ ๋ธ๋๋๋ช
์ด ํฌํจ๋ฉ๋๋ค",
|
| 128 |
+
)
|
| 129 |
+
own_brands_input = ""
|
| 130 |
+
if brand_mention:
|
| 131 |
+
own_brands_input = st.text_input(
|
| 132 |
+
"์์ฌ ๋ธ๋๋ (์ผํ ๊ตฌ๋ถ)",
|
| 133 |
+
key="hier:own_brands",
|
| 134 |
+
placeholder="๋ธ๋๋A, ๋ธ๋๋B",
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# โโ Step 5: ๋ถ์ ์ค์ โโ
|
| 138 |
+
st.markdown("---")
|
| 139 |
+
st.markdown("###### 5. ๋ถ์ ์ค์ ")
|
| 140 |
+
|
| 141 |
+
col1, col2, col3 = st.columns(3)
|
| 142 |
+
with col1:
|
| 143 |
+
model = st.selectbox("AI ๋ชจ๋ธ", _MODELS, key="hier:model")
|
| 144 |
+
with col2:
|
| 145 |
+
questions_per_kw = st.number_input(
|
| 146 |
+
"ํค์๋๋น ์ง๋ฌธ ์",
|
| 147 |
+
min_value=5,
|
| 148 |
+
max_value=100,
|
| 149 |
+
value=25,
|
| 150 |
+
step=5,
|
| 151 |
+
key="hier:qpk",
|
| 152 |
+
)
|
| 153 |
+
with col3:
|
| 154 |
+
max_nodes = st.number_input(
|
| 155 |
+
"์ต๋ ๋
ธ๋ ์",
|
| 156 |
+
min_value=10,
|
| 157 |
+
max_value=500,
|
| 158 |
+
value=100,
|
| 159 |
+
step=10,
|
| 160 |
+
key="hier:max_nodes",
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
# โโ Submit โโ
|
| 164 |
+
st.markdown("---")
|
| 165 |
+
|
| 166 |
+
can_submit = bool(raw_text and raw_text.strip() and selected_journeys)
|
| 167 |
+
if not raw_text or not raw_text.strip():
|
| 168 |
+
st.info("๋ถ์ ํค์๋๋ฅผ ์
๋ ฅํ์ธ์.")
|
| 169 |
+
elif not selected_journeys:
|
| 170 |
+
st.info("์ต์ 1๊ฐ์ ์ฌ์ ์ ํ์ ์ ํํ์ธ์.")
|
| 171 |
+
|
| 172 |
+
if st.button(
|
| 173 |
+
"โถ๏ธ ๋ถ์ ์์",
|
| 174 |
+
type="primary",
|
| 175 |
+
key="hier:submit",
|
| 176 |
+
disabled=not can_submit,
|
| 177 |
+
):
|
| 178 |
+
keyword = raw_text.strip()
|
| 179 |
+
own_brands = [b.strip() for b in own_brands_input.split(",") if b.strip()] if own_brands_input else []
|
| 180 |
+
|
| 181 |
+
processor_config = {
|
| 182 |
+
"version": "1.0",
|
| 183 |
+
"inputAnalysis": {
|
| 184 |
+
"rawText": keyword,
|
| 185 |
+
"primaryKeyword": keyword,
|
| 186 |
+
"productType": product_type,
|
| 187 |
+
"locationCode": 2410,
|
| 188 |
+
"languageCode": "ko",
|
| 189 |
+
},
|
| 190 |
+
"brandContext": {
|
| 191 |
+
"brandMention": brand_mention,
|
| 192 |
+
"ownBrands": own_brands,
|
| 193 |
+
},
|
| 194 |
+
"selectedJourneyTypes": selected_journeys,
|
| 195 |
+
"persona": {
|
| 196 |
+
"attributes": {
|
| 197 |
+
"ages": persona_ages,
|
| 198 |
+
"gender": persona_gender,
|
| 199 |
+
"trait": persona_trait,
|
| 200 |
+
},
|
| 201 |
+
},
|
| 202 |
+
"modifiers": [],
|
| 203 |
+
}
|
| 204 |
+
settings = {
|
| 205 |
+
"model": model,
|
| 206 |
+
"questionsPerKeyword": questions_per_kw,
|
| 207 |
+
"maxNodes": max_nodes,
|
| 208 |
+
"outputLanguage": "ko",
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
try:
|
| 212 |
+
client.create_hierarchy_job(
|
| 213 |
+
prompt=keyword,
|
| 214 |
+
title=title.strip() if title else None,
|
| 215 |
+
processor_config=processor_config,
|
| 216 |
+
settings=settings,
|
| 217 |
+
)
|
| 218 |
+
st.success("๋ถ์ Job์ด ์์ฑ๋์์ต๋๋ค! '์งํ ํํฉ' ํญ์์ ํ์ธํ์ธ์.")
|
| 219 |
+
st.rerun()
|
| 220 |
+
except Exception as e:
|
| 221 |
+
st.error(f"๋ถ์ ์์ ์คํจ: {e}")
|
features/reports/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""๋ฆฌํฌํธ Feature Plugin.
|
| 2 |
+
|
| 3 |
+
API: /api/v1/reports
|
| 4 |
+
"""
|
| 5 |
+
import streamlit as st
|
| 6 |
+
|
| 7 |
+
from . import overview, summary
|
| 8 |
+
|
| 9 |
+
FEATURE_CONFIG = {
|
| 10 |
+
"key": "reports",
|
| 11 |
+
"name": "๋ฆฌํฌํธ",
|
| 12 |
+
"icon": "๐",
|
| 13 |
+
"description": "Feature๋ณ ๋ฏธ๋ฆฌ๋ณด๊ธฐ, HTML/CSV ๋ค์ด๋ก๋, ์ ์ฒด ๋ฆฌํฌํธ ์์ฑ",
|
| 14 |
+
"api_base": "/api/v1/reports",
|
| 15 |
+
"order": 2,
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def render(base_ctx):
|
| 20 |
+
"""๋ฆฌํฌํธ feature ๋ ๋๋ง."""
|
| 21 |
+
# Summary card
|
| 22 |
+
summary.render_summary(base_ctx)
|
| 23 |
+
|
| 24 |
+
# Main content - delegates to overview which has all sub-tabs
|
| 25 |
+
overview.render(base_ctx)
|
features/reports/full_report.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""์ ์ฒด ๋ฆฌํฌํธ ์์ฑ ํญ."""
|
| 2 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 3 |
+
|
| 4 |
+
import pandas as pd
|
| 5 |
+
import requests
|
| 6 |
+
import streamlit as st
|
| 7 |
+
import streamlit.components.v1 as components
|
| 8 |
+
|
| 9 |
+
from core.api_client import ChainShiftClient
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _build_one(
|
| 13 |
+
api_key: str,
|
| 14 |
+
campaign_id: int,
|
| 15 |
+
feat_key: str,
|
| 16 |
+
start_date: str,
|
| 17 |
+
end_date: str,
|
| 18 |
+
enable_insights: bool,
|
| 19 |
+
homepage_urls: list[str] | None,
|
| 20 |
+
) -> tuple[str, dict | None, str | None]:
|
| 21 |
+
"""Worker thread โ st.* ํธ์ถ ๊ธ์ง. ๋
๋ฆฝ HTTP ํด๋ผ์ด์ธํธ๋ก feature ๋น๋."""
|
| 22 |
+
try:
|
| 23 |
+
thread_client = ChainShiftClient(api_key=api_key)
|
| 24 |
+
result = thread_client.build_html_feature(
|
| 25 |
+
campaign_id=campaign_id,
|
| 26 |
+
feature=feat_key,
|
| 27 |
+
start_date=start_date,
|
| 28 |
+
end_date=end_date,
|
| 29 |
+
enable_insights=enable_insights,
|
| 30 |
+
enable_action_items=True,
|
| 31 |
+
homepage_urls=homepage_urls if feat_key == "homepage-citations" else None,
|
| 32 |
+
)
|
| 33 |
+
return feat_key, result, None
|
| 34 |
+
except Exception as e:
|
| 35 |
+
return feat_key, None, str(e)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@st.cache_data(ttl=60)
|
| 39 |
+
def _fetch_report_history(
|
| 40 |
+
campaign_id: int, page: int = 1, page_size: int = 20,
|
| 41 |
+
_api_key: str = "", _access_token: str = "",
|
| 42 |
+
) -> dict:
|
| 43 |
+
"""Cached fetch for HTML report history."""
|
| 44 |
+
client = ChainShiftClient(api_key=_api_key or None, access_token=_access_token or None)
|
| 45 |
+
return client.get_html_report_history(campaign_id, page=page, page_size=page_size)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
AVAILABLE_FEATURES = [
|
| 49 |
+
("overview", "1. ๊ฐ์์ฑ ๋ถ์ ๊ฐ์"),
|
| 50 |
+
("visibility", "2. AI ๊ฒ์ ๊ฐ์์ฑ"),
|
| 51 |
+
("citations", "3. ์ธ์ฉ ์ถ์ฒ ๋ถ์"),
|
| 52 |
+
("citation-trends", "4. ์ธ์ฉ ์ถ์ฒ ์๊ณ์ด"),
|
| 53 |
+
("content-types", "5. ์ฝํ
์ธ ์ ํ"),
|
| 54 |
+
("sentiment", "6. ๋ธ๋๋ ๊ฐ์ "),
|
| 55 |
+
("homepage-citations", "7. ํํ์ด์ง ์ธ์ฉ๋ฅ "),
|
| 56 |
+
]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def render(client: ChainShiftClient, base_ctx: dict, start_date: str, end_date: str):
|
| 60 |
+
"""์ ์ฒด ๋ฆฌํฌํธ ์์ฑ ์น์
."""
|
| 61 |
+
st.markdown("#### ๐ ์ ์ฒด HTML ๋ฆฌํฌํธ ์์ฑ")
|
| 62 |
+
st.caption("7๊ฐ Feature๋ฅผ ํฌํจํ ํตํฉ HTML ๋ฆฌํฌํธ๋ฅผ ์์ฑํฉ๋๋ค. LLM ์ธ์ฌ์ดํธ๋ก ์ปจ์คํดํธ ํค์ ๋ถ์์ ์ถ๊ฐํ ์ ์์ต๋๋ค.")
|
| 63 |
+
|
| 64 |
+
with st.expander("โ๏ธ ๋ฆฌํฌํธ ์ต์
", expanded=True):
|
| 65 |
+
st.markdown("**ํฌํจํ Feature ์ ํ**")
|
| 66 |
+
selected_features = []
|
| 67 |
+
col1, col2 = st.columns(2)
|
| 68 |
+
for i, (feat_key, feat_label) in enumerate(AVAILABLE_FEATURES):
|
| 69 |
+
with col1 if i < 4 else col2:
|
| 70 |
+
if st.checkbox(feat_label, value=True, key=f"reports:full_feat_{feat_key}"):
|
| 71 |
+
selected_features.append(feat_key)
|
| 72 |
+
|
| 73 |
+
st.markdown("---")
|
| 74 |
+
|
| 75 |
+
enable_insights = st.checkbox(
|
| 76 |
+
"๐ค LLM ์ธ์ฌ์ดํธ ์์ฑ",
|
| 77 |
+
value=True,
|
| 78 |
+
help="Gemini API๋ฅผ ์ฌ์ฉํ์ฌ ์ปจ์คํดํธ ํค์ ๋ถ์ ์ธ์ฌ์ดํธ๋ฅผ ์ถ๊ฐํฉ๋๋ค (์์ฑ ์๊ฐ ์ฆ๊ฐ)",
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
st.text_area(
|
| 82 |
+
"Homepage URLs (์ค๋ฐ๊ฟ ๊ตฌ๋ถ, ์ ํ)",
|
| 83 |
+
help="ํํ์ด์ง ์ธ์ฉ๋ฅ ๋ถ์์ ์ฌ์ฉํ URL ๋ชฉ๋ก",
|
| 84 |
+
key="reports:homepage_urls_input",
|
| 85 |
+
height=80,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Parse homepage URLs from text area
|
| 89 |
+
homepage_urls_raw = st.session_state.get("reports:homepage_urls_input", "")
|
| 90 |
+
homepage_urls = [u.strip() for u in homepage_urls_raw.splitlines() if u.strip()] or None
|
| 91 |
+
|
| 92 |
+
# Feature display name lookup
|
| 93 |
+
_feat_display = dict(AVAILABLE_FEATURES)
|
| 94 |
+
|
| 95 |
+
if st.button("๐ ์ ์ฒด ๋ฆฌํฌํธ ์์ฑ", key="reports:generate_html_btn", type="primary", disabled=not selected_features):
|
| 96 |
+
campaign_id = base_ctx["campaign_id"]
|
| 97 |
+
total = len(selected_features)
|
| 98 |
+
progress_bar = st.progress(0, text="๋ฆฌํฌํธ ์์ฑ ์ค๋น ์ค...")
|
| 99 |
+
status_container = st.container()
|
| 100 |
+
|
| 101 |
+
built_features: list[dict] = []
|
| 102 |
+
skipped_features: list[str] = []
|
| 103 |
+
total_build_ms = 0
|
| 104 |
+
|
| 105 |
+
# Phase 1: Build features in parallel (I/O-bound HTTP calls)
|
| 106 |
+
with ThreadPoolExecutor(max_workers=total) as executor:
|
| 107 |
+
futures = {
|
| 108 |
+
executor.submit(
|
| 109 |
+
_build_one, client.api_key, campaign_id, feat_key,
|
| 110 |
+
start_date, end_date, enable_insights, homepage_urls,
|
| 111 |
+
): feat_key
|
| 112 |
+
for feat_key in selected_features
|
| 113 |
+
}
|
| 114 |
+
completed = 0
|
| 115 |
+
for future in as_completed(futures):
|
| 116 |
+
feat_key = futures[future]
|
| 117 |
+
feat_label = _feat_display.get(feat_key, feat_key)
|
| 118 |
+
completed += 1
|
| 119 |
+
fk, result, error = future.result()
|
| 120 |
+
if error:
|
| 121 |
+
skipped_features.append(feat_key)
|
| 122 |
+
with status_container:
|
| 123 |
+
st.caption(f" {feat_label} ์คํจ: {error}")
|
| 124 |
+
elif result and result.get("success"):
|
| 125 |
+
feat_resp = result["data"]
|
| 126 |
+
built_features.append(feat_resp["feature_data"])
|
| 127 |
+
build_ms = feat_resp.get("build_time_ms", 0)
|
| 128 |
+
total_build_ms += build_ms
|
| 129 |
+
insight_tag = " +์ธ์ฌ์ดํธ" if feat_resp.get("insights_generated") else ""
|
| 130 |
+
with status_container:
|
| 131 |
+
st.caption(f" {feat_label} ({build_ms/1000:.1f}s{insight_tag})")
|
| 132 |
+
else:
|
| 133 |
+
skipped_features.append(feat_key)
|
| 134 |
+
with status_container:
|
| 135 |
+
st.caption(f" {feat_label} ๊ฑด๋๋")
|
| 136 |
+
progress_bar.progress(
|
| 137 |
+
completed / (total + 1),
|
| 138 |
+
text=f"({completed}/{total}) ๋น๋ ์๋ฃ...",
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
# Restore original feature order for rendering
|
| 142 |
+
feat_order = {k: i for i, k in enumerate(selected_features)}
|
| 143 |
+
built_features.sort(key=lambda f: feat_order.get(f.get("feature_id", ""), 99))
|
| 144 |
+
|
| 145 |
+
if not built_features:
|
| 146 |
+
progress_bar.empty()
|
| 147 |
+
st.error("๋ชจ๋ Feature ์์ฑ์ ์คํจํ์ต๋๋ค.")
|
| 148 |
+
else:
|
| 149 |
+
# Phase 2: Render final report
|
| 150 |
+
progress_bar.progress(
|
| 151 |
+
total / (total + 1),
|
| 152 |
+
text="HTML ๋ฆฌํฌํธ ์กฐ๋ฆฝ ์ค...",
|
| 153 |
+
)
|
| 154 |
+
try:
|
| 155 |
+
render_result = client.render_html_report(
|
| 156 |
+
campaign_id=campaign_id,
|
| 157 |
+
features_data=built_features,
|
| 158 |
+
start_date=start_date,
|
| 159 |
+
end_date=end_date,
|
| 160 |
+
enable_insights=enable_insights,
|
| 161 |
+
enable_action_items=True,
|
| 162 |
+
output_mode="url",
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
if render_result.get("success"):
|
| 166 |
+
data = render_result.get("data") or {}
|
| 167 |
+
if not isinstance(data, dict):
|
| 168 |
+
progress_bar.empty()
|
| 169 |
+
st.error("๋ฆฌํฌํธ ๋ ๋๋ง ์คํจ: ์๋ฒ ์๋ต์ด ๋น์ ์์
๋๋ค.")
|
| 170 |
+
else:
|
| 171 |
+
render_ms = data.get("generation_time_ms", 0)
|
| 172 |
+
|
| 173 |
+
# Download HTML from Supabase Storage URL directly.
|
| 174 |
+
# url mode avoids Vercel 4.5MB response body limit.
|
| 175 |
+
html_content = ""
|
| 176 |
+
html_url = data.get("html_url") or ""
|
| 177 |
+
if html_url:
|
| 178 |
+
try:
|
| 179 |
+
dl_resp = requests.get(html_url, timeout=30)
|
| 180 |
+
dl_resp.raise_for_status()
|
| 181 |
+
dl_resp.encoding = "utf-8"
|
| 182 |
+
html_content = dl_resp.text
|
| 183 |
+
except Exception as dl_err:
|
| 184 |
+
st.warning(f"HTML ๋ค์ด๋ก๋ ์คํจ, URL ๋งํฌ๋ก ๋์ฒด: {dl_err}")
|
| 185 |
+
|
| 186 |
+
if not html_url and not html_content:
|
| 187 |
+
progress_bar.empty()
|
| 188 |
+
st.error("๋ฆฌํฌํธ ๋ ๋๋ง ์คํจ: ์คํ ๋ฆฌ์ง URL์ด ๋ฐํ๋์ง ์์์ต๋๋ค.")
|
| 189 |
+
else:
|
| 190 |
+
# Persist results in session_state for rerun survival
|
| 191 |
+
st.session_state["full_report_result"] = {
|
| 192 |
+
"html_content": html_content,
|
| 193 |
+
"html_url": html_url,
|
| 194 |
+
"report_id": data.get("report_id", "")[:8],
|
| 195 |
+
"features_generated": data.get("features_generated", []),
|
| 196 |
+
"file_size_kb": round(data.get("file_size_bytes", 0) / 1024, 1),
|
| 197 |
+
"total_sec": round((total_build_ms + render_ms) / 1000, 1),
|
| 198 |
+
"insights_generated": data.get("insights_generated", False),
|
| 199 |
+
"skipped_features": skipped_features,
|
| 200 |
+
"campaign_id": campaign_id,
|
| 201 |
+
"start_date": start_date,
|
| 202 |
+
"end_date": end_date,
|
| 203 |
+
}
|
| 204 |
+
progress_bar.empty()
|
| 205 |
+
st.rerun()
|
| 206 |
+
else:
|
| 207 |
+
progress_bar.empty()
|
| 208 |
+
st.error("๋ฆฌํฌํธ ๋ ๋๋ง ์คํจ: " + str(render_result.get("error", "Unknown error")))
|
| 209 |
+
|
| 210 |
+
except Exception as e:
|
| 211 |
+
progress_bar.empty()
|
| 212 |
+
st.error(f"๋ฆฌํฌํธ ๋ ๋๋ง ์ค๋ฅ: {e}")
|
| 213 |
+
elif not selected_features:
|
| 214 |
+
st.warning("์ต์ 1๊ฐ ์ด์์ Feature๋ฅผ ์ ํํ์ธ์.")
|
| 215 |
+
|
| 216 |
+
# โโ Results display (persists across reruns via session_state) โโ
|
| 217 |
+
report_state = st.session_state.get("full_report_result")
|
| 218 |
+
if report_state:
|
| 219 |
+
html_content = report_state["html_content"]
|
| 220 |
+
report_id = report_state["report_id"]
|
| 221 |
+
features_generated = report_state["features_generated"]
|
| 222 |
+
file_size_kb = report_state["file_size_kb"]
|
| 223 |
+
total_sec = report_state["total_sec"]
|
| 224 |
+
skipped = report_state["skipped_features"]
|
| 225 |
+
r_campaign_id = report_state["campaign_id"]
|
| 226 |
+
r_start = report_state["start_date"]
|
| 227 |
+
r_end = report_state["end_date"]
|
| 228 |
+
|
| 229 |
+
if skipped:
|
| 230 |
+
st.warning(f"์ผ๋ถ Feature๋ฅผ ๊ฑด๋๋ฐ๊ณ ๋ฆฌํฌํธ๋ฅผ ์์ฑํ์ต๋๋ค: {', '.join(skipped)}")
|
| 231 |
+
st.success("๋ฆฌํฌํธ๊ฐ ์์ฑ๋์์ต๋๋ค!")
|
| 232 |
+
|
| 233 |
+
st.markdown(f"""
|
| 234 |
+
<div style="background: linear-gradient(135deg, #E5E2FF 0%, #F0FDFA 100%);
|
| 235 |
+
padding: 20px; border-radius: 12px; margin: 16px 0;">
|
| 236 |
+
<h4 style="margin: 0 0 12px 0; color: #2B239B;">๋ฆฌํฌํธ ์์ฑ ์๋ฃ</h4>
|
| 237 |
+
<p style="margin: 8px 0;"><strong>Report ID:</strong> {report_id}...</p>
|
| 238 |
+
<p style="margin: 8px 0;"><strong>Features:</strong> {len(features_generated)}๊ฐ ({len(skipped)}๊ฐ ๊ฑด๋๋)</p>
|
| 239 |
+
<p style="margin: 8px 0;"><strong>ํ์ผ ํฌ๊ธฐ:</strong> {file_size_kb} KB</p>
|
| 240 |
+
<p style="margin: 8px 0;"><strong>์์ฑ ์๊ฐ:</strong> {total_sec}์ด</p>
|
| 241 |
+
<p style="margin: 8px 0;"><strong>LLM ์ธ์ฌ์ดํธ:</strong> {'ํฌํจ' if report_state.get('insights_generated') else '๋ฏธํฌํจ'}</p>
|
| 242 |
+
</div>
|
| 243 |
+
""", unsafe_allow_html=True)
|
| 244 |
+
|
| 245 |
+
html_url = report_state.get("html_url", "")
|
| 246 |
+
|
| 247 |
+
if html_content:
|
| 248 |
+
col_open, col_download, col_clear = st.columns(3)
|
| 249 |
+
with col_open:
|
| 250 |
+
if html_url:
|
| 251 |
+
st.link_button("์ ์ฐฝ์์ ๋ณด๊ธฐ", html_url, use_container_width=True)
|
| 252 |
+
else:
|
| 253 |
+
st.button("์ ์ฐฝ์์ ๋ณด๊ธฐ", disabled=True, use_container_width=True, key="reports:open_disabled")
|
| 254 |
+
with col_download:
|
| 255 |
+
file_name = f"AI_๊ฐ์์ฑ_๋ฆฌํฌํธ_{r_campaign_id}_{r_start}_{r_end}.html"
|
| 256 |
+
st.download_button(
|
| 257 |
+
label="HTML ๋ค์ด๋ก๋",
|
| 258 |
+
data=b'\xef\xbb\xbf' + html_content.lstrip('\ufeff').encode("utf-8"),
|
| 259 |
+
file_name=file_name,
|
| 260 |
+
mime="text/html; charset=utf-8",
|
| 261 |
+
use_container_width=True,
|
| 262 |
+
key="reports:full_report_download",
|
| 263 |
+
)
|
| 264 |
+
with col_clear:
|
| 265 |
+
if st.button("์ด๊ธฐํ", key="reports:clear_result", use_container_width=True):
|
| 266 |
+
del st.session_state["full_report_result"]
|
| 267 |
+
st.rerun()
|
| 268 |
+
|
| 269 |
+
with st.expander("๋ฆฌํฌํธ ๋ฏธ๋ฆฌ๋ณด๊ธฐ", expanded=False):
|
| 270 |
+
components.html(html_content, height=800, scrolling=True)
|
| 271 |
+
elif html_url:
|
| 272 |
+
# Fallback: HTML download failed, show direct link
|
| 273 |
+
col_link, col_clear = st.columns(2)
|
| 274 |
+
with col_link:
|
| 275 |
+
st.link_button("๋ฆฌํฌํธ ์ด๊ธฐ (์ธ๋ถ ๋งํฌ)", html_url, use_container_width=True)
|
| 276 |
+
with col_clear:
|
| 277 |
+
if st.button("์ด๊ธฐํ", key="reports:clear_result", use_container_width=True):
|
| 278 |
+
del st.session_state["full_report_result"]
|
| 279 |
+
st.rerun()
|
| 280 |
+
|
| 281 |
+
st.markdown("---")
|
| 282 |
+
|
| 283 |
+
# Report History
|
| 284 |
+
st.markdown("##### ๐ ์์ฑ ์ด๋ ฅ")
|
| 285 |
+
try:
|
| 286 |
+
history_result = _fetch_report_history(
|
| 287 |
+
base_ctx["campaign_id"],
|
| 288 |
+
_api_key=base_ctx.get("api_key", ""),
|
| 289 |
+
_access_token=base_ctx.get("access_token", ""),
|
| 290 |
+
)
|
| 291 |
+
if history_result.get("success"):
|
| 292 |
+
history_data = history_result["data"]
|
| 293 |
+
reports = history_data.get("items", [])
|
| 294 |
+
total = history_data.get("total", 0)
|
| 295 |
+
|
| 296 |
+
if reports:
|
| 297 |
+
st.markdown(f"์ด **{total}**๊ฑด์ ๋ฆฌํฌํธ๊ฐ ์์ฑ๋์์ต๋๋ค.")
|
| 298 |
+
|
| 299 |
+
history_rows = []
|
| 300 |
+
for r in reports:
|
| 301 |
+
status_emoji = "โ
" if r.get("status") == "completed" else "โ"
|
| 302 |
+
features = r.get("features_included", [])
|
| 303 |
+
file_size_kb = round(r.get("file_size_bytes", 0) / 1024, 1)
|
| 304 |
+
history_rows.append({
|
| 305 |
+
"์์ฑ์ผ": r.get("created_at", "")[:16].replace("T", " "),
|
| 306 |
+
"์ํ": f"{status_emoji}",
|
| 307 |
+
"Features": f"{len(features)}/{len(AVAILABLE_FEATURES)}",
|
| 308 |
+
"๊ธฐ๊ฐ": f"{r.get('start_date', '?')} ~ {r.get('end_date', '?')}",
|
| 309 |
+
"ํฌ๊ธฐ": f"{file_size_kb} KB",
|
| 310 |
+
"๋งํฌ": r.get("html_url") or "-",
|
| 311 |
+
})
|
| 312 |
+
|
| 313 |
+
df = pd.DataFrame(history_rows)
|
| 314 |
+
st.dataframe(
|
| 315 |
+
df,
|
| 316 |
+
use_container_width=True,
|
| 317 |
+
hide_index=True,
|
| 318 |
+
column_config={
|
| 319 |
+
"๋งํฌ": st.column_config.LinkColumn("๋งํฌ", display_text="์ด๊ธฐ"),
|
| 320 |
+
},
|
| 321 |
+
)
|
| 322 |
+
else:
|
| 323 |
+
st.info("์์ง ์์ฑ๋ ๋ฆฌํฌํธ๊ฐ ์์ต๋๋ค.")
|
| 324 |
+
except Exception as e:
|
| 325 |
+
st.warning(f"์ด๋ ฅ ๋ก๋ ์คํจ: {e}")
|
features/reports/overview.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""๋ฆฌํฌํธ ํญ.
|
| 2 |
+
|
| 3 |
+
Feature๋ณ ๋ฏธ๋ฆฌ๋ณด๊ธฐ, HTML/CSV ๋ค์ด๋ก๋, ์ ์ฒด ๋ฆฌํฌํธ ์์ฑ.
|
| 4 |
+
"""
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
|
| 7 |
+
import streamlit as st
|
| 8 |
+
|
| 9 |
+
from core.api_client import ChainShiftClient
|
| 10 |
+
from core.supabase_client import get_campaign_date_range
|
| 11 |
+
|
| 12 |
+
from .utils import render_feature_section
|
| 13 |
+
from . import full_report
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# Period presets: (label, days or None for "all")
|
| 17 |
+
PERIOD_PRESETS = [
|
| 18 |
+
("์ต๊ทผ 1์ผ", 1),
|
| 19 |
+
("์ต๊ทผ 7์ผ", 7),
|
| 20 |
+
("์ต๊ทผ 30์ผ", 30),
|
| 21 |
+
("์ต๊ทผ 90์ผ", 90),
|
| 22 |
+
("์ต๊ทผ 180์ผ", 180),
|
| 23 |
+
("์ ์ฒด ๊ธฐ๊ฐ", None),
|
| 24 |
+
("์ง์ ์ ํ", -1),
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def render(base_ctx: dict):
|
| 29 |
+
"""๋ฆฌํฌํธ ํญ ๋ ๋๋ง."""
|
| 30 |
+
st.markdown("##### ๋ฆฌํฌํธ")
|
| 31 |
+
st.caption("๊ธฐ๊ฐ๋ณ AI ๊ฐ์์ฑ ๋ถ์ ๋ฆฌํฌํธ๋ฅผ ์์ฑํ๊ณ , HTML๋ก ๋ค์ด๋ก๋ํ ์ ์์ต๋๋ค.")
|
| 32 |
+
|
| 33 |
+
# Get campaign date range for presets
|
| 34 |
+
campaign_date_range = get_campaign_date_range(base_ctx["campaign_id"])
|
| 35 |
+
if campaign_date_range:
|
| 36 |
+
first_date_str, last_date_str = campaign_date_range
|
| 37 |
+
first_date = datetime.strptime(first_date_str, "%Y-%m-%d")
|
| 38 |
+
last_date = datetime.strptime(last_date_str, "%Y-%m-%d")
|
| 39 |
+
total_days = (last_date - first_date).days + 1
|
| 40 |
+
else:
|
| 41 |
+
first_date = datetime.now() - timedelta(days=30)
|
| 42 |
+
last_date = datetime.now()
|
| 43 |
+
first_date_str = first_date.strftime("%Y-%m-%d")
|
| 44 |
+
last_date_str = last_date.strftime("%Y-%m-%d")
|
| 45 |
+
total_days = 31
|
| 46 |
+
|
| 47 |
+
# Period selection
|
| 48 |
+
start_date_str, end_date_str = _render_period_selector(
|
| 49 |
+
first_date, last_date, first_date_str, last_date_str, total_days
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Show selected period info
|
| 53 |
+
selected_days = (datetime.strptime(end_date_str, "%Y-%m-%d") - datetime.strptime(start_date_str, "%Y-%m-%d")).days + 1
|
| 54 |
+
st.caption(f"๐
์ ํ๋ ๊ธฐ๊ฐ: **{start_date_str} ~ {end_date_str}** ({selected_days}์ผ)")
|
| 55 |
+
|
| 56 |
+
st.markdown("---")
|
| 57 |
+
|
| 58 |
+
# 4 Sub-tabs for Features + 1 for Full Report
|
| 59 |
+
report_tab_summary, report_tab_visibility, report_tab_citation, report_tab_full = st.tabs([
|
| 60 |
+
"๐ Executive Summary",
|
| 61 |
+
"๐๏ธ Visibility & Content",
|
| 62 |
+
"๐ Citation Analysis",
|
| 63 |
+
"๐ ์ ์ฒด ๋ฆฌํฌํธ",
|
| 64 |
+
])
|
| 65 |
+
|
| 66 |
+
client = ChainShiftClient(api_key=base_ctx.get("api_key"), access_token=base_ctx.get("access_token"))
|
| 67 |
+
|
| 68 |
+
# Tab 1: Executive Summary
|
| 69 |
+
with report_tab_summary:
|
| 70 |
+
render_feature_section(
|
| 71 |
+
client=client,
|
| 72 |
+
campaign_id=base_ctx["campaign_id"],
|
| 73 |
+
feature_key="overview",
|
| 74 |
+
title="๊ฐ์์ฑ ๊ฐ์",
|
| 75 |
+
description="AI ํ๋ซํผ๋ณ ๋ธ๋๋ ๋
ธ์ถ ํํฉ๊ณผ ํต์ฌ ์งํ",
|
| 76 |
+
start_date=start_date_str,
|
| 77 |
+
end_date=end_date_str,
|
| 78 |
+
api_key=base_ctx.get("api_key") or "",
|
| 79 |
+
access_token=base_ctx.get("access_token") or "",
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
# Tab 2: Visibility & Content
|
| 83 |
+
with report_tab_visibility:
|
| 84 |
+
render_feature_section(
|
| 85 |
+
client=client,
|
| 86 |
+
campaign_id=base_ctx["campaign_id"],
|
| 87 |
+
feature_key="visibility",
|
| 88 |
+
title="ํ๋ซํผ๋ณ ๊ฐ์์ฑ",
|
| 89 |
+
description="ChatGPT, Gemini ๋ฑ AI ํ๋ซํผ๋ณ ์์ฌ vs ๊ฒฝ์์ฌ ๋
ธ์ถ ๋น๊ต",
|
| 90 |
+
start_date=start_date_str,
|
| 91 |
+
end_date=end_date_str,
|
| 92 |
+
api_key=base_ctx.get("api_key") or "",
|
| 93 |
+
access_token=base_ctx.get("access_token") or "",
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
render_feature_section(
|
| 97 |
+
client=client,
|
| 98 |
+
campaign_id=base_ctx["campaign_id"],
|
| 99 |
+
feature_key="content-types",
|
| 100 |
+
title="์ฝํ
์ธ ์ ํ ๋ถํฌ",
|
| 101 |
+
description="AI๊ฐ ์ธ์ฉํ๋ ์ฝํ
์ธ ์ ํ (๋ธ๋ก๊ทธ, ๋ด์ค, ๊ณต์ ์ฌ์ดํธ ๋ฑ)",
|
| 102 |
+
start_date=start_date_str,
|
| 103 |
+
end_date=end_date_str,
|
| 104 |
+
api_key=base_ctx.get("api_key") or "",
|
| 105 |
+
access_token=base_ctx.get("access_token") or "",
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
render_feature_section(
|
| 109 |
+
client=client,
|
| 110 |
+
campaign_id=base_ctx["campaign_id"],
|
| 111 |
+
feature_key="sentiment",
|
| 112 |
+
title="๋ธ๋๋ ๊ฐ์ ๋ถ์",
|
| 113 |
+
description="๋ธ๋๋๋ณ ๊ธ์ /๋ถ์ /์ค๋ฆฝ ๊ฐ์ ๋ถํฌ",
|
| 114 |
+
start_date=start_date_str,
|
| 115 |
+
end_date=end_date_str,
|
| 116 |
+
api_key=base_ctx.get("api_key") or "",
|
| 117 |
+
access_token=base_ctx.get("access_token") or "",
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
# Tab 3: Citation Analysis
|
| 121 |
+
with report_tab_citation:
|
| 122 |
+
|
| 123 |
+
render_feature_section(
|
| 124 |
+
client=client,
|
| 125 |
+
campaign_id=base_ctx["campaign_id"],
|
| 126 |
+
feature_key="citations",
|
| 127 |
+
title="์ธ์ฉ ์ถ์ฒ ์์",
|
| 128 |
+
description="AI ๋ต๋ณ์์ ๊ฐ์ฅ ๋ง์ด ์ธ์ฉ๋๋ ๋๋ฉ์ธ๊ณผ ์ถ์ฒ",
|
| 129 |
+
start_date=start_date_str,
|
| 130 |
+
end_date=end_date_str,
|
| 131 |
+
api_key=base_ctx.get("api_key") or "",
|
| 132 |
+
access_token=base_ctx.get("access_token") or "",
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
render_feature_section(
|
| 136 |
+
client=client,
|
| 137 |
+
campaign_id=base_ctx["campaign_id"],
|
| 138 |
+
feature_key="citation-trends",
|
| 139 |
+
title="์ธ์ฉ ์ถ์ด",
|
| 140 |
+
description="์๊ฐ์ ๋ฐ๋ฅธ ์ธ์ฉ ์ถ์ฒ ๋ณํ ํธ๋ ๋",
|
| 141 |
+
start_date=start_date_str,
|
| 142 |
+
end_date=end_date_str,
|
| 143 |
+
api_key=base_ctx.get("api_key") or "",
|
| 144 |
+
access_token=base_ctx.get("access_token") or "",
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
render_feature_section(
|
| 148 |
+
client=client,
|
| 149 |
+
campaign_id=base_ctx["campaign_id"],
|
| 150 |
+
feature_key="homepage-citations",
|
| 151 |
+
title="ํํ์ด์ง ์ธ์ฉ๋ฅ ",
|
| 152 |
+
description="์์ฌ ํํ์ด์ง๊ฐ AI ๋ต๋ณ์ ์ง์ ์ธ์ฉ๋๋ ๋น์จ",
|
| 153 |
+
start_date=start_date_str,
|
| 154 |
+
end_date=end_date_str,
|
| 155 |
+
api_key=base_ctx.get("api_key") or "",
|
| 156 |
+
access_token=base_ctx.get("access_token") or "",
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# Tab 4: Full Report
|
| 160 |
+
with report_tab_full:
|
| 161 |
+
full_report.render(client, base_ctx, start_date_str, end_date_str)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _render_period_selector(
|
| 165 |
+
first_date: datetime,
|
| 166 |
+
last_date: datetime,
|
| 167 |
+
first_date_str: str,
|
| 168 |
+
last_date_str: str,
|
| 169 |
+
total_days: int,
|
| 170 |
+
) -> tuple[str, str]:
|
| 171 |
+
"""๊ธฐ๊ฐ ์ ํ UI ๋ ๋๋ง. (start_date, end_date) ๋ฐํ."""
|
| 172 |
+
col_period, col_date1, col_date2 = st.columns([1.5, 1, 1])
|
| 173 |
+
|
| 174 |
+
with col_period:
|
| 175 |
+
period_options = [label for label, _ in PERIOD_PRESETS]
|
| 176 |
+
selected_period = st.selectbox(
|
| 177 |
+
"๋ถ์ ๊ธฐ๊ฐ",
|
| 178 |
+
options=period_options,
|
| 179 |
+
index=5, # Default to "์ ์ฒด ๊ธฐ๊ฐ"
|
| 180 |
+
key="reports:period_select",
|
| 181 |
+
help=f"์บ ํ์ธ ๋ฐ์ดํฐ: {first_date_str} ~ {last_date_str} (์ด {total_days}์ผ)",
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
period_idx = period_options.index(selected_period)
|
| 185 |
+
_, period_days = PERIOD_PRESETS[period_idx]
|
| 186 |
+
|
| 187 |
+
if period_days == -1: # Custom selection
|
| 188 |
+
with col_date1:
|
| 189 |
+
report_start = st.date_input(
|
| 190 |
+
"์์์ผ",
|
| 191 |
+
value=first_date,
|
| 192 |
+
min_value=first_date,
|
| 193 |
+
max_value=last_date,
|
| 194 |
+
key="reports:start_date",
|
| 195 |
+
)
|
| 196 |
+
with col_date2:
|
| 197 |
+
report_end = st.date_input(
|
| 198 |
+
"์ข
๋ฃ์ผ",
|
| 199 |
+
value=last_date,
|
| 200 |
+
min_value=first_date,
|
| 201 |
+
max_value=last_date,
|
| 202 |
+
key="reports:end_date",
|
| 203 |
+
)
|
| 204 |
+
return str(report_start), str(report_end)
|
| 205 |
+
elif period_days is None: # All data
|
| 206 |
+
with col_date1:
|
| 207 |
+
st.text_input("์์์ผ", value=first_date_str, disabled=True, key="reports:start_display")
|
| 208 |
+
with col_date2:
|
| 209 |
+
st.text_input("์ข
๋ฃ์ผ", value=last_date_str, disabled=True, key="reports:end_display")
|
| 210 |
+
return first_date_str, last_date_str
|
| 211 |
+
else: # Preset days
|
| 212 |
+
end_date = last_date
|
| 213 |
+
start_date = max(first_date, end_date - timedelta(days=period_days - 1))
|
| 214 |
+
start_date_str = start_date.strftime("%Y-%m-%d")
|
| 215 |
+
end_date_str = end_date.strftime("%Y-%m-%d")
|
| 216 |
+
with col_date1:
|
| 217 |
+
st.text_input("์์์ผ", value=start_date_str, disabled=True, key="reports:start_display")
|
| 218 |
+
with col_date2:
|
| 219 |
+
st.text_input("์ข
๋ฃ์ผ", value=end_date_str, disabled=True, key="reports:end_display")
|
| 220 |
+
return start_date_str, end_date_str
|
features/reports/summary.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""๋ฆฌํฌํธ Feature ์์ฝ ์นด๋."""
|
| 2 |
+
import streamlit as st
|
| 3 |
+
|
| 4 |
+
from core.supabase_client import get_campaign_date_range, get_report_history_count
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def render_summary(base_ctx: dict):
|
| 8 |
+
"""๋ฆฌํฌํธ ์์ฝ ์นด๋."""
|
| 9 |
+
campaign_id = base_ctx["campaign_id"]
|
| 10 |
+
date_range = get_campaign_date_range(campaign_id)
|
| 11 |
+
report_count = get_report_history_count(campaign_id)
|
| 12 |
+
|
| 13 |
+
col1, col2, col3 = st.columns(3)
|
| 14 |
+
with col1:
|
| 15 |
+
if date_range:
|
| 16 |
+
days = _calc_days(date_range[0], date_range[1])
|
| 17 |
+
st.metric("๋ฐ์ดํฐ ์์ง ๊ธฐ๊ฐ", f"{days}์ผ", help=f"{date_range[0]} ~ {date_range[1]}")
|
| 18 |
+
else:
|
| 19 |
+
st.metric("๋ฐ์ดํฐ ์์ง ๊ธฐ๊ฐ", "N/A")
|
| 20 |
+
with col2:
|
| 21 |
+
st.metric("์์ฑ๋ ๋ฆฌํฌํธ", f"{report_count}๊ฑด")
|
| 22 |
+
with col3:
|
| 23 |
+
st.metric("๋ถ์ ํญ๋ชฉ", "๊ฐ์์ฑ / ์ธ์ฉ / ๊ฐ์ / ์ฝํ
์ธ ")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _calc_days(start: str, end: str) -> int:
|
| 27 |
+
"""๋ ๋ ์ง ๋ฌธ์์ด ๊ฐ ์ผ์ ๊ณ์ฐ."""
|
| 28 |
+
from datetime import datetime
|
| 29 |
+
d1 = datetime.strptime(start, "%Y-%m-%d")
|
| 30 |
+
d2 = datetime.strptime(end, "%Y-%m-%d")
|
| 31 |
+
return (d2 - d1).days + 1
|
features/reports/utils.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""๋ฆฌํฌํธ ํญ ๊ณตํต ์ ํธ๋ฆฌํฐ.
|
| 2 |
+
|
| 3 |
+
Feature๋ณ ๋ฏธ๋ฆฌ๋ณด๊ธฐ, HTML ์์ฑ, CSV ๋ณํ ๋ฑ ๊ณตํต ํจ์.
|
| 4 |
+
"""
|
| 5 |
+
import io
|
| 6 |
+
import csv
|
| 7 |
+
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import requests
|
| 10 |
+
import streamlit as st
|
| 11 |
+
import streamlit.components.v1 as components
|
| 12 |
+
|
| 13 |
+
from core.api_client import ChainShiftClient
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def render_feature_section(
|
| 17 |
+
client: ChainShiftClient,
|
| 18 |
+
campaign_id: int,
|
| 19 |
+
feature_key: str,
|
| 20 |
+
title: str,
|
| 21 |
+
description: str,
|
| 22 |
+
start_date: str,
|
| 23 |
+
end_date: str,
|
| 24 |
+
api_key: str = "",
|
| 25 |
+
access_token: str = "",
|
| 26 |
+
):
|
| 27 |
+
"""๋จ์ผ Feature ์น์
๋ ๋๋ง."""
|
| 28 |
+
html_state_key = f"html_content_{feature_key}_{campaign_id}"
|
| 29 |
+
insights_key = f"insights_enabled_{feature_key}_{campaign_id}"
|
| 30 |
+
|
| 31 |
+
with st.container(border=True):
|
| 32 |
+
# Header
|
| 33 |
+
c1, c2 = st.columns([4, 1])
|
| 34 |
+
with c1:
|
| 35 |
+
st.markdown(f"**{title}**")
|
| 36 |
+
st.caption(description)
|
| 37 |
+
|
| 38 |
+
# Preview Section (Lazy loaded)
|
| 39 |
+
with st.expander(f"๐๏ธ ๋ฏธ๋ฆฌ๋ณด๊ธฐ", expanded=False):
|
| 40 |
+
try:
|
| 41 |
+
result = get_report_feature_data(api_key, campaign_id, feature_key, start_date, end_date, access_token=access_token)
|
| 42 |
+
if result.get("success"):
|
| 43 |
+
data = result.get("data", {})
|
| 44 |
+
render_feature_preview(feature_key, data)
|
| 45 |
+
else:
|
| 46 |
+
st.warning(f"๋ฐ์ดํฐ ๋ก๋ ์คํจ: {result.get('error', 'Unknown')}")
|
| 47 |
+
except Exception as e:
|
| 48 |
+
st.error(f"๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ค๋ฅ: {e}")
|
| 49 |
+
|
| 50 |
+
# LLM Insights checkbox
|
| 51 |
+
enable_insights = st.checkbox(
|
| 52 |
+
"๐ค LLM ์ธ์ฌ์ดํธ ํฌํจ",
|
| 53 |
+
value=st.session_state.get(insights_key, True),
|
| 54 |
+
key=f"insights_cb_{feature_key}",
|
| 55 |
+
help="์ปจ์คํดํธ ํค์ ๋ถ์ ์ฝ๋ฉํธ๋ฅผ ์ถ๊ฐํฉ๋๋ค",
|
| 56 |
+
)
|
| 57 |
+
st.session_state[insights_key] = enable_insights
|
| 58 |
+
|
| 59 |
+
# Generated HTML display section
|
| 60 |
+
if html_state_key in st.session_state:
|
| 61 |
+
html_data = st.session_state[html_state_key]
|
| 62 |
+
html_content = html_data.get("content", "")
|
| 63 |
+
html_url = html_data.get("url", "")
|
| 64 |
+
|
| 65 |
+
st.success(f"โ
HTML ๋ฆฌํฌํธ ์์ฑ ์๋ฃ" + (" (LLM ์ธ์ฌ์ดํธ ํฌํจ)" if html_data.get("insights") else ""))
|
| 66 |
+
|
| 67 |
+
if html_content:
|
| 68 |
+
# Action buttons
|
| 69 |
+
col_open, col_dl, col_csv, col_reset = st.columns(4)
|
| 70 |
+
|
| 71 |
+
with col_open:
|
| 72 |
+
if html_url:
|
| 73 |
+
st.link_button("๐ ์ ์ฐฝ์์ ๋ณด๊ธฐ", html_url, use_container_width=True)
|
| 74 |
+
else:
|
| 75 |
+
st.button("๐ ์ ์ฐฝ์์ ๋ณด๊ธฐ", disabled=True, use_container_width=True, key=f"html_open_{feature_key}_disabled")
|
| 76 |
+
|
| 77 |
+
with col_dl:
|
| 78 |
+
st.download_button(
|
| 79 |
+
label="๐ฅ HTML ๋ค์ด๋ก๋",
|
| 80 |
+
data=b'\xef\xbb\xbf' + html_content.lstrip('\ufeff').encode("utf-8"),
|
| 81 |
+
file_name=f"{feature_key}_{campaign_id}_{start_date}_{end_date}.html",
|
| 82 |
+
mime="text/html; charset=utf-8",
|
| 83 |
+
use_container_width=True,
|
| 84 |
+
key=f"html_dl_{feature_key}",
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
with col_csv:
|
| 88 |
+
try:
|
| 89 |
+
result = get_report_feature_data(api_key, campaign_id, feature_key, start_date, end_date, access_token=access_token)
|
| 90 |
+
if result.get("success"):
|
| 91 |
+
csv_data = convert_report_data_to_csv(feature_key, result.get("data", {}))
|
| 92 |
+
st.download_button(
|
| 93 |
+
label="๐ CSV ๋ค์ด๋ก๋",
|
| 94 |
+
data=csv_data.encode("utf-8-sig"),
|
| 95 |
+
file_name=f"{feature_key}_{campaign_id}_{start_date}_{end_date}.csv",
|
| 96 |
+
mime="text/csv",
|
| 97 |
+
use_container_width=True,
|
| 98 |
+
key=f"csv_{feature_key}_post",
|
| 99 |
+
)
|
| 100 |
+
else:
|
| 101 |
+
st.button("๐ CSV ๋ค์ด๋ก๋", disabled=True, use_container_width=True, key=f"csv_{feature_key}_post_disabled")
|
| 102 |
+
except Exception:
|
| 103 |
+
st.button("๐ CSV ๋ค์ด๋ก๋", disabled=True, use_container_width=True, key=f"csv_{feature_key}_post_error")
|
| 104 |
+
|
| 105 |
+
with col_reset:
|
| 106 |
+
if st.button("๐ ๋ค์ ์์ฑ", key=f"html_reset_{feature_key}", use_container_width=True):
|
| 107 |
+
del st.session_state[html_state_key]
|
| 108 |
+
st.rerun()
|
| 109 |
+
|
| 110 |
+
# Inline preview
|
| 111 |
+
with st.expander("๐๏ธ HTML ๋ฏธ๋ฆฌ๋ณด๊ธฐ", expanded=False):
|
| 112 |
+
components.html(html_content, height=500, scrolling=True)
|
| 113 |
+
|
| 114 |
+
elif html_url:
|
| 115 |
+
# Fallback: HTML download failed, show direct link
|
| 116 |
+
col_link, col_csv, col_reset = st.columns(3)
|
| 117 |
+
with col_link:
|
| 118 |
+
st.link_button("๐ ๋ฆฌํฌํธ ์ด๊ธฐ (์ธ๋ถ ๋งํฌ)", html_url, use_container_width=True)
|
| 119 |
+
with col_csv:
|
| 120 |
+
try:
|
| 121 |
+
result = get_report_feature_data(api_key, campaign_id, feature_key, start_date, end_date, access_token=access_token)
|
| 122 |
+
if result.get("success"):
|
| 123 |
+
csv_data = convert_report_data_to_csv(feature_key, result.get("data", {}))
|
| 124 |
+
st.download_button(
|
| 125 |
+
label="๐ CSV ๋ค์ด๋ก๋",
|
| 126 |
+
data=csv_data.encode("utf-8-sig"),
|
| 127 |
+
file_name=f"{feature_key}_{campaign_id}_{start_date}_{end_date}.csv",
|
| 128 |
+
mime="text/csv",
|
| 129 |
+
use_container_width=True,
|
| 130 |
+
key=f"csv_{feature_key}_fallback",
|
| 131 |
+
)
|
| 132 |
+
else:
|
| 133 |
+
st.button("๐ CSV ๋ค์ด๋ก๋", disabled=True, use_container_width=True, key=f"csv_{feature_key}_fallback_disabled")
|
| 134 |
+
except Exception:
|
| 135 |
+
st.button("๐ CSV ๋ค์ด๋ก๋", disabled=True, use_container_width=True, key=f"csv_{feature_key}_fallback_error")
|
| 136 |
+
with col_reset:
|
| 137 |
+
if st.button("๐ ๋ค์ ์์ฑ", key=f"html_reset_{feature_key}", use_container_width=True):
|
| 138 |
+
del st.session_state[html_state_key]
|
| 139 |
+
st.rerun()
|
| 140 |
+
|
| 141 |
+
else:
|
| 142 |
+
# Generate button
|
| 143 |
+
col_html, col_csv = st.columns(2)
|
| 144 |
+
|
| 145 |
+
with col_html:
|
| 146 |
+
if st.button(f"๐ HTML ์์ฑ", key=f"html_{feature_key}", use_container_width=True):
|
| 147 |
+
spinner_text = "HTML ์์ฑ ์ค..." + (" (LLM ์ธ์ฌ์ดํธ ํฌํจ)" if enable_insights else "")
|
| 148 |
+
with st.spinner(spinner_text):
|
| 149 |
+
try:
|
| 150 |
+
# Use url mode to avoid Vercel 4.5MB response limit.
|
| 151 |
+
# Download HTML from Supabase Storage directly.
|
| 152 |
+
result_url = client.generate_html_report(
|
| 153 |
+
campaign_id=campaign_id,
|
| 154 |
+
start_date=start_date,
|
| 155 |
+
end_date=end_date,
|
| 156 |
+
features=[feature_key],
|
| 157 |
+
enable_insights=enable_insights,
|
| 158 |
+
output_mode="url",
|
| 159 |
+
)
|
| 160 |
+
if result_url.get("success"):
|
| 161 |
+
data = result_url.get("data") or {}
|
| 162 |
+
html_url = data.get("html_url", "") if isinstance(data, dict) else ""
|
| 163 |
+
html_content = ""
|
| 164 |
+
if html_url:
|
| 165 |
+
try:
|
| 166 |
+
dl_resp = requests.get(html_url, timeout=30)
|
| 167 |
+
dl_resp.raise_for_status()
|
| 168 |
+
dl_resp.encoding = "utf-8"
|
| 169 |
+
html_content = dl_resp.text
|
| 170 |
+
except Exception as dl_err:
|
| 171 |
+
st.warning(f"HTML ๋ค์ด๋ก๋ ์คํจ, URL ๋งํฌ๋ก ๋์ฒด: {dl_err}")
|
| 172 |
+
if not html_url and not html_content:
|
| 173 |
+
st.error("HTML ์์ฑ ์คํจ: ์คํ ๋ฆฌ์ง URL์ด ๋ฐํ๋์ง ์์์ต๋๋ค.")
|
| 174 |
+
else:
|
| 175 |
+
st.session_state[html_state_key] = {
|
| 176 |
+
"content": html_content,
|
| 177 |
+
"url": html_url,
|
| 178 |
+
"insights": enable_insights,
|
| 179 |
+
}
|
| 180 |
+
st.rerun()
|
| 181 |
+
else:
|
| 182 |
+
st.error("HTML ์์ฑ ์คํจ: " + str(result_url.get("error", "Unknown")))
|
| 183 |
+
except Exception as e:
|
| 184 |
+
st.error(f"์ค๋ฅ: {e}")
|
| 185 |
+
|
| 186 |
+
with col_csv:
|
| 187 |
+
try:
|
| 188 |
+
result = get_report_feature_data(api_key, campaign_id, feature_key, start_date, end_date, access_token=access_token)
|
| 189 |
+
if result.get("success"):
|
| 190 |
+
csv_data = convert_report_data_to_csv(feature_key, result.get("data", {}))
|
| 191 |
+
st.download_button(
|
| 192 |
+
label="๐ CSV ๋ค์ด๋ก๋",
|
| 193 |
+
data=csv_data.encode("utf-8-sig"),
|
| 194 |
+
file_name=f"{feature_key}_{campaign_id}_{start_date}_{end_date}.csv",
|
| 195 |
+
mime="text/csv",
|
| 196 |
+
use_container_width=True,
|
| 197 |
+
key=f"csv_{feature_key}",
|
| 198 |
+
)
|
| 199 |
+
else:
|
| 200 |
+
st.button("๐ CSV ๋ค์ด๋ก๋", disabled=True, use_container_width=True, key=f"csv_{feature_key}_disabled")
|
| 201 |
+
except Exception:
|
| 202 |
+
st.button("๐ CSV ๋ค์ด๋ก๋", disabled=True, use_container_width=True, key=f"csv_{feature_key}_error")
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def render_feature_preview(feature_key: str, data: dict):
|
| 206 |
+
"""Feature๋ณ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์๊ฐํ."""
|
| 207 |
+
if feature_key == "overview":
|
| 208 |
+
cols = st.columns(4)
|
| 209 |
+
with cols[0]:
|
| 210 |
+
st.metric("์ด ์ง๋ฌธ ์", data.get("total_tasks", 0))
|
| 211 |
+
with cols[1]:
|
| 212 |
+
st.metric("์ด ๋ต๋ณ ์", data.get("total_answers", 0))
|
| 213 |
+
with cols[2]:
|
| 214 |
+
st.metric("๊ฐ์์ฑ", f"{data.get('overall_visibility_pct', 0):.1f}%")
|
| 215 |
+
with cols[3]:
|
| 216 |
+
dr = data.get("date_range", {})
|
| 217 |
+
period = f"{dr.get('start', '?')} ~ {dr.get('end', '?')}"
|
| 218 |
+
st.metric("๋ถ์ ๊ธฐ๊ฐ", period[:20])
|
| 219 |
+
|
| 220 |
+
elif feature_key == "visibility":
|
| 221 |
+
platforms = data.get("platforms", [])
|
| 222 |
+
if platforms:
|
| 223 |
+
rows = []
|
| 224 |
+
for p in platforms:
|
| 225 |
+
for b in p.get("brands", []):
|
| 226 |
+
rows.append({
|
| 227 |
+
"ํ๋ซํผ": p.get("platform", ""),
|
| 228 |
+
"๋ธ๋๋": b.get("brand_name", ""),
|
| 229 |
+
"๊ฐ์์ฑ (%)": b.get("visibility_pct", 0),
|
| 230 |
+
})
|
| 231 |
+
if rows:
|
| 232 |
+
df = pd.DataFrame(rows)
|
| 233 |
+
st.dataframe(df, use_container_width=True, hide_index=True)
|
| 234 |
+
else:
|
| 235 |
+
st.info("ํ๋ซํผ ๋ฐ์ดํฐ ์์")
|
| 236 |
+
|
| 237 |
+
elif feature_key == "citations":
|
| 238 |
+
sources = data.get("sources", [])[:10]
|
| 239 |
+
if sources:
|
| 240 |
+
df = pd.DataFrame(sources)
|
| 241 |
+
cols = [c for c in ["source_host_url", "total_citations", "pct_of_total"] if c in df.columns]
|
| 242 |
+
if cols:
|
| 243 |
+
st.dataframe(df[cols], use_container_width=True, hide_index=True)
|
| 244 |
+
else:
|
| 245 |
+
st.info("์ธ์ฉ ๋ฐ์ดํฐ ์์")
|
| 246 |
+
|
| 247 |
+
elif feature_key == "citation-trends":
|
| 248 |
+
sources = data.get("sources", [])
|
| 249 |
+
if sources:
|
| 250 |
+
rows = []
|
| 251 |
+
for s in sources:
|
| 252 |
+
for pt in s.get("trend", []):
|
| 253 |
+
rows.append({
|
| 254 |
+
"date": pt.get("task_date", ""),
|
| 255 |
+
"source": s.get("source_host_url", ""),
|
| 256 |
+
"citations": pt.get("citation_count", 0),
|
| 257 |
+
})
|
| 258 |
+
if rows:
|
| 259 |
+
df = pd.DataFrame(rows)
|
| 260 |
+
pivot = df.pivot_table(index="date", columns="source", values="citations", aggfunc="sum").fillna(0)
|
| 261 |
+
st.line_chart(pivot)
|
| 262 |
+
else:
|
| 263 |
+
st.info("์๊ณ์ด ๋ฐ์ดํฐ ์์")
|
| 264 |
+
|
| 265 |
+
elif feature_key == "content-types":
|
| 266 |
+
types = data.get("content_types", [])
|
| 267 |
+
if types:
|
| 268 |
+
df = pd.DataFrame(types)
|
| 269 |
+
if "content_type" in df.columns and "total_citations" in df.columns:
|
| 270 |
+
st.bar_chart(df.set_index("content_type")["total_citations"])
|
| 271 |
+
else:
|
| 272 |
+
st.info("์ฝํ
์ธ ์ ํ ๋ฐ์ดํฐ ์์")
|
| 273 |
+
|
| 274 |
+
elif feature_key == "sentiment":
|
| 275 |
+
in_house = data.get("in_house_brands", [])
|
| 276 |
+
competitor = data.get("competitor_brands", [])
|
| 277 |
+
|
| 278 |
+
if in_house:
|
| 279 |
+
st.markdown("**๐ข ์์ฌ ๋ธ๋๋**")
|
| 280 |
+
df_ih = pd.DataFrame(in_house)
|
| 281 |
+
cols_ih = ["brand_name", "total_mentions", "positive_rate", "negative_rate"]
|
| 282 |
+
cols_ih = [c for c in cols_ih if c in df_ih.columns]
|
| 283 |
+
if cols_ih:
|
| 284 |
+
st.dataframe(df_ih[cols_ih], use_container_width=True, hide_index=True)
|
| 285 |
+
|
| 286 |
+
if competitor:
|
| 287 |
+
st.markdown("**๐ฏ ๊ฒฝ์์ฌ ๋ธ๋๋**")
|
| 288 |
+
df_comp = pd.DataFrame(competitor)
|
| 289 |
+
cols_comp = ["brand_name", "total_mentions", "positive_rate", "negative_rate"]
|
| 290 |
+
cols_comp = [c for c in cols_comp if c in df_comp.columns]
|
| 291 |
+
if cols_comp:
|
| 292 |
+
st.dataframe(df_comp[cols_comp], use_container_width=True, hide_index=True)
|
| 293 |
+
|
| 294 |
+
if not in_house and not competitor:
|
| 295 |
+
brands = data.get("brands", [])
|
| 296 |
+
if brands:
|
| 297 |
+
df = pd.DataFrame(brands)
|
| 298 |
+
cols = [c for c in ["brand_name", "brand_type", "positive_rate", "negative_rate"] if c in df.columns]
|
| 299 |
+
if cols:
|
| 300 |
+
st.dataframe(df[cols], use_container_width=True, hide_index=True)
|
| 301 |
+
else:
|
| 302 |
+
st.info("๊ฐ์ ๋ถ์ ๋ฐ์ดํฐ ์์")
|
| 303 |
+
|
| 304 |
+
elif feature_key == "homepage-citations":
|
| 305 |
+
daily_data = data.get("daily_data", [])[:10]
|
| 306 |
+
if daily_data:
|
| 307 |
+
rows = []
|
| 308 |
+
for day in daily_data:
|
| 309 |
+
for entry in day.get("entries", []):
|
| 310 |
+
rows.append({
|
| 311 |
+
"๋ ์ง": day.get("task_date", ""),
|
| 312 |
+
"ํ๋ซํผ": entry.get("platform", ""),
|
| 313 |
+
"์ธ์ฉ ํ์": entry.get("citation_count", 0),
|
| 314 |
+
})
|
| 315 |
+
if rows:
|
| 316 |
+
df = pd.DataFrame(rows)
|
| 317 |
+
st.dataframe(df, use_container_width=True, hide_index=True)
|
| 318 |
+
else:
|
| 319 |
+
st.info("ํํ์ด์ง ์ธ์ฉ ๋ฐ์ดํฐ ์์")
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
@st.cache_data(ttl=300)
|
| 323 |
+
def get_report_feature_data(
|
| 324 |
+
api_key: str,
|
| 325 |
+
campaign_id: int,
|
| 326 |
+
feature: str,
|
| 327 |
+
start_date: str | None = None,
|
| 328 |
+
end_date: str | None = None,
|
| 329 |
+
access_token: str = "",
|
| 330 |
+
):
|
| 331 |
+
"""Fetch report feature data with caching."""
|
| 332 |
+
client = ChainShiftClient(api_key=api_key or None, access_token=access_token or None)
|
| 333 |
+
|
| 334 |
+
if feature == "overview":
|
| 335 |
+
return client.get_report_overview(campaign_id, start_date, end_date)
|
| 336 |
+
elif feature == "visibility":
|
| 337 |
+
return client.get_report_visibility(campaign_id, start_date, end_date)
|
| 338 |
+
elif feature == "citations":
|
| 339 |
+
return client.get_report_citations(campaign_id, start_date, end_date, limit=50)
|
| 340 |
+
elif feature == "citation-trends":
|
| 341 |
+
return client.get_report_citation_trends(campaign_id, start_date, end_date)
|
| 342 |
+
elif feature == "content-types":
|
| 343 |
+
return client.get_report_content_types(campaign_id, start_date, end_date)
|
| 344 |
+
elif feature == "sentiment":
|
| 345 |
+
return client.get_report_sentiment(campaign_id)
|
| 346 |
+
elif feature == "homepage-citations":
|
| 347 |
+
return client.get_report_homepage_citations(campaign_id, start_date, end_date)
|
| 348 |
+
else:
|
| 349 |
+
return {"success": False, "error": f"Unknown feature: {feature}"}
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def convert_report_data_to_csv(feature: str, data: dict) -> str:
|
| 353 |
+
"""Convert report feature data to CSV format."""
|
| 354 |
+
output = io.StringIO()
|
| 355 |
+
writer = csv.writer(output)
|
| 356 |
+
|
| 357 |
+
if feature == "overview":
|
| 358 |
+
dr = data.get("date_range", {})
|
| 359 |
+
writer.writerow(["ํญ๋ชฉ", "๊ฐ"])
|
| 360 |
+
writer.writerow(["์บ ํ์ธ ID", data.get("campaign_id", "")])
|
| 361 |
+
writer.writerow(["๋ถ์ ๊ธฐ๊ฐ", f"{dr.get('start', '')} ~ {dr.get('end', '')}"])
|
| 362 |
+
writer.writerow(["์ด ์ง๋ฌธ ์", data.get("total_tasks", 0)])
|
| 363 |
+
writer.writerow(["์ด ๋ต๋ณ ์", data.get("total_answers", 0)])
|
| 364 |
+
writer.writerow(["๊ฐ์์ฑ ๋น์จ (%)", data.get("overall_visibility_pct", 0)])
|
| 365 |
+
|
| 366 |
+
elif feature == "visibility":
|
| 367 |
+
writer.writerow(["ํ๋ซํผ", "๋ธ๋๋", "์ ํ", "๊ฐ์์ฑ (%)", "๋ธ๋๋ ์ธ๊ธ ์", "์ด ๋ต๋ณ ์"])
|
| 368 |
+
for platform in data.get("platforms", []):
|
| 369 |
+
for brand in platform.get("brands", []):
|
| 370 |
+
writer.writerow([
|
| 371 |
+
platform.get("platform", ""),
|
| 372 |
+
brand.get("brand_name", ""),
|
| 373 |
+
brand.get("brand_type", ""),
|
| 374 |
+
brand.get("visibility_pct", 0),
|
| 375 |
+
brand.get("brand_mentions", 0),
|
| 376 |
+
platform.get("total_answers", 0),
|
| 377 |
+
])
|
| 378 |
+
|
| 379 |
+
elif feature == "citations":
|
| 380 |
+
writer.writerow(["๋๋ฉ์ธ", "์ ํ", "์ธ์ฉ ํ์", "๋ต๋ณ ์ธ๊ธ ์", "๋น์จ (%)"])
|
| 381 |
+
for item in data.get("sources", []):
|
| 382 |
+
writer.writerow([
|
| 383 |
+
item.get("source_host_url", ""),
|
| 384 |
+
item.get("source_host_type", ""),
|
| 385 |
+
item.get("total_citations", 0),
|
| 386 |
+
item.get("total_answer_mentions", 0),
|
| 387 |
+
item.get("pct_of_total", 0),
|
| 388 |
+
])
|
| 389 |
+
|
| 390 |
+
elif feature == "citation-trends":
|
| 391 |
+
writer.writerow(["์ธ์ฉ ์ถ์ฒ", "์ ํ", "๋ ์ง", "์ธ์ฉ ํ์", "๋ต๋ณ ์ธ๊ธ ์", "๋น์จ (%)"])
|
| 392 |
+
for source in data.get("sources", []):
|
| 393 |
+
host = source.get("source_host_url", "")
|
| 394 |
+
host_type = source.get("source_host_type", "")
|
| 395 |
+
for point in source.get("trend", []):
|
| 396 |
+
writer.writerow([
|
| 397 |
+
host,
|
| 398 |
+
host_type,
|
| 399 |
+
point.get("task_date", ""),
|
| 400 |
+
point.get("citation_count", 0),
|
| 401 |
+
point.get("answer_mention_count", 0),
|
| 402 |
+
point.get("citation_pct", 0),
|
| 403 |
+
])
|
| 404 |
+
|
| 405 |
+
elif feature == "content-types":
|
| 406 |
+
writer.writerow(["์ฝํ
์ธ ์ ํ", "์ธ์ฉ ํ์", "๋ต๋ณ ์ธ๊ธ ์", "๋น์จ (%)"])
|
| 407 |
+
for item in data.get("content_types", []):
|
| 408 |
+
writer.writerow([
|
| 409 |
+
item.get("content_type", ""),
|
| 410 |
+
item.get("total_citations", 0),
|
| 411 |
+
item.get("total_answer_mentions", 0),
|
| 412 |
+
item.get("pct_of_total", 0),
|
| 413 |
+
])
|
| 414 |
+
|
| 415 |
+
elif feature == "sentiment":
|
| 416 |
+
writer.writerow(["๋ธ๋๋", "์ ํ", "์ด ๋ฉ์
", "๊ธ์ %", "๋ถ์ %", "์ค๋ฆฝ %"])
|
| 417 |
+
|
| 418 |
+
for item in data.get("in_house_brands", []):
|
| 419 |
+
t = item.get("total_mentions", 0)
|
| 420 |
+
neutral = round(item.get("neutral_count", 0) / t * 100, 1) if t > 0 else 0.0
|
| 421 |
+
writer.writerow([
|
| 422 |
+
item.get("brand_name", ""),
|
| 423 |
+
"์์ฌ",
|
| 424 |
+
t,
|
| 425 |
+
f"{item.get('positive_rate', 0):.1f}",
|
| 426 |
+
f"{item.get('negative_rate', 0):.1f}",
|
| 427 |
+
f"{neutral:.1f}",
|
| 428 |
+
])
|
| 429 |
+
|
| 430 |
+
for item in data.get("competitor_brands", []):
|
| 431 |
+
t = item.get("total_mentions", 0)
|
| 432 |
+
neutral = round(item.get("neutral_count", 0) / t * 100, 1) if t > 0 else 0.0
|
| 433 |
+
writer.writerow([
|
| 434 |
+
item.get("brand_name", ""),
|
| 435 |
+
"๊ฒฝ์์ฌ",
|
| 436 |
+
item.get("total_mentions", 0),
|
| 437 |
+
f"{item.get('positive_rate', 0):.1f}",
|
| 438 |
+
f"{item.get('negative_rate', 0):.1f}",
|
| 439 |
+
f"{neutral:.1f}",
|
| 440 |
+
])
|
| 441 |
+
|
| 442 |
+
if not data.get("in_house_brands") and not data.get("competitor_brands"):
|
| 443 |
+
for item in data.get("brands", []):
|
| 444 |
+
pos = item.get("positive_rate", item.get("positive", 0))
|
| 445 |
+
neg = item.get("negative_rate", item.get("negative", 0))
|
| 446 |
+
neutral = 100 - pos - neg
|
| 447 |
+
writer.writerow([
|
| 448 |
+
item.get("brand_name", item.get("name", "")),
|
| 449 |
+
item.get("brand_type", item.get("type", "")),
|
| 450 |
+
item.get("total_mentions", 0),
|
| 451 |
+
f"{pos:.1f}",
|
| 452 |
+
f"{neg:.1f}",
|
| 453 |
+
f"{neutral:.1f}",
|
| 454 |
+
])
|
| 455 |
+
|
| 456 |
+
elif feature == "homepage-citations":
|
| 457 |
+
writer.writerow(["๋ ์ง", "ํ๋ซํผ", "์ธ์ฉ ์ถ์ฒ", "์ธ์ฉ ํ์", "๋ต๋ณ ์ธ๊ธ ์"])
|
| 458 |
+
for day in data.get("daily_data", []):
|
| 459 |
+
task_date = day.get("task_date", "")
|
| 460 |
+
for entry in day.get("entries", []):
|
| 461 |
+
writer.writerow([
|
| 462 |
+
task_date,
|
| 463 |
+
entry.get("platform", ""),
|
| 464 |
+
entry.get("source_host_url", ""),
|
| 465 |
+
entry.get("citation_count", 0),
|
| 466 |
+
entry.get("answer_mention_count", 0),
|
| 467 |
+
])
|
| 468 |
+
|
| 469 |
+
return output.getvalue()
|
features/research/__init__.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ํ ํฝ ์ธํ
๋ฆฌ์ ์ค Feature Plugin.
|
| 2 |
+
|
| 3 |
+
API: /api/v1/research
|
| 4 |
+
ADR-013 Phase 4 + ADR-014 Phase 3 (Multi-Model UX).
|
| 5 |
+
"""
|
| 6 |
+
import streamlit as st
|
| 7 |
+
|
| 8 |
+
from core.supabase_client import (
|
| 9 |
+
get_topic_clusters, get_topic_map_snapshot, find_cross_model_pair,
|
| 10 |
+
)
|
| 11 |
+
from . import topic_map, opportunities, distribution, guide, summary
|
| 12 |
+
from .cross_model import render_cross_model
|
| 13 |
+
from .content_actions import render_content_actions
|
| 14 |
+
from .keyword_suggest import render_keyword_suggestions
|
| 15 |
+
from .unified_scoring import render_unified_scoring
|
| 16 |
+
|
| 17 |
+
FEATURE_CONFIG = {
|
| 18 |
+
"key": "research",
|
| 19 |
+
"name": "ํ ํฝ ์ธํ
๋ฆฌ์ ์ค",
|
| 20 |
+
"icon": "๐ฌ",
|
| 21 |
+
"description": "AI๊ฐ ์ด๋ค ํ ํฝ์ ๊ด์ฌ์ ๊ฐ๊ณ ์๋์ง, ์ด๋์ ์ฝํ
์ธ ๊ธฐํ๊ฐ ์๋์ง ๋ถ์",
|
| 22 |
+
"api_base": "/api/v1/research",
|
| 23 |
+
"order": 3,
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
# Source configs: label, description, help text
|
| 27 |
+
SOURCE_OPTIONS = {
|
| 28 |
+
"์ ์ฒด": {
|
| 29 |
+
"source": None,
|
| 30 |
+
"desc": "ChatGPT + Gemini ์ ์ฒด ํ ํฝ์ ํตํฉํ์ฌ ๋ถ์ํฉ๋๋ค.",
|
| 31 |
+
"fanout_label": "Fanout/Citation",
|
| 32 |
+
"frame": "all",
|
| 33 |
+
},
|
| 34 |
+
"ChatGPT (Demand)": {
|
| 35 |
+
"source": "chatgpt",
|
| 36 |
+
"desc": "์๋น์๊ฐ AI์๊ฒ **๋ฌด์์ ๋ฌผ์ด๋ณด๋์ง** ๋ถ์ํฉ๋๋ค. ChatGPT์ sub-query ๋ถํด ๋ฐ์ดํฐ ๊ธฐ๋ฐ.",
|
| 37 |
+
"fanout_label": "Fanout",
|
| 38 |
+
"frame": "demand",
|
| 39 |
+
},
|
| 40 |
+
"Gemini (Supply)": {
|
| 41 |
+
"source": "gemini",
|
| 42 |
+
"desc": "AI๊ฐ **๋ฌด์์ ๊ทผ๊ฑฐ๋ก ๋ต๋ณํ๋์ง** ๋ถ์ํฉ๋๋ค. Gemini์ citation quote ๋ฐ์ดํฐ ๊ธฐ๋ฐ.",
|
| 43 |
+
"fanout_label": "Citation",
|
| 44 |
+
"frame": "supply",
|
| 45 |
+
},
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def render(base_ctx):
|
| 50 |
+
"""ํ ํฝ ์ธํ
๋ฆฌ์ ์ค feature ๋ ๋๋ง."""
|
| 51 |
+
campaign_id = base_ctx["campaign_id"]
|
| 52 |
+
|
| 53 |
+
st.caption("AI๊ฐ ์ด๋ค ํ ํฝ์ ๊ด์ฌ์ ๊ฐ๊ณ ์๋์ง, ์ด๋์ ์ฝํ
์ธ ๊ธฐํ๊ฐ ์๋์ง ๋ถ์ํฉ๋๋ค.")
|
| 54 |
+
|
| 55 |
+
# --- Model Selector ---
|
| 56 |
+
selected = st.radio(
|
| 57 |
+
"๋ถ์ ๋ชจ๋ธ",
|
| 58 |
+
list(SOURCE_OPTIONS.keys()),
|
| 59 |
+
horizontal=True,
|
| 60 |
+
key="research:model_selector",
|
| 61 |
+
help="ChatGPT๋ ์๋น์ ๊ฒ์ ์๋(Demand), Gemini๋ AI ์ธ์ฉ ๊ทผ๊ฑฐ(Supply)๋ฅผ ๋ํ๋
๋๋ค.",
|
| 62 |
+
)
|
| 63 |
+
source_cfg = SOURCE_OPTIONS[selected]
|
| 64 |
+
source = source_cfg["source"]
|
| 65 |
+
|
| 66 |
+
st.caption(source_cfg["desc"])
|
| 67 |
+
|
| 68 |
+
# Education expander
|
| 69 |
+
with st.expander("์ด๋ป๊ฒ ์๋ํ๋์?", expanded=False):
|
| 70 |
+
if source_cfg["frame"] == "demand":
|
| 71 |
+
st.markdown("""
|
| 72 |
+
**ChatGPT Demand ๋ถ์**
|
| 73 |
+
|
| 74 |
+
ChatGPT๋ ์ฌ์ฉ์ ์ง๋ฌธ์ 8-15๊ฐ์ ์ธ๋ถ ์ง๋ฌธ(fanout)์ผ๋ก ๋ถํดํ์ฌ Bing์์ ๊ฒ์ํฉ๋๋ค.
|
| 75 |
+
์ด fanout ํจํด์ ๋ถ์ํ๋ฉด **์๋น์๊ฐ AI์๊ฒ ๋ฌด์์ ๋ฌผ์ด๋ณด๋์ง** ํ์
ํ ์ ์์ต๋๋ค.
|
| 76 |
+
|
| 77 |
+
```
|
| 78 |
+
์ฌ์ฉ์ ์ง๋ฌธ โ ChatGPT๊ฐ 8-15๊ฐ sub-query ์์ฑ โ Bing ๊ฒ์ โ ๋ต๋ณ ํฉ์ฑ
|
| 79 |
+
โ
|
| 80 |
+
sub-query ํด๋ฌ์คํฐ๋ง โ Demand ํ ํฝ
|
| 81 |
+
```
|
| 82 |
+
""")
|
| 83 |
+
elif source_cfg["frame"] == "supply":
|
| 84 |
+
st.markdown("""
|
| 85 |
+
**Gemini Supply ๋ถ์**
|
| 86 |
+
|
| 87 |
+
Gemini๋ ๋ต๋ณ ์ ์น ์ฝํ
์ธ ์์ ์ง์ ๋ฌธ์ฅ์ ์ถ์ถ(extractive summarization)ํ์ฌ ์ธ์ฉํฉ๋๋ค.
|
| 88 |
+
์ธ์ฉ ํจํด์ ๋ถ์ํ๋ฉด **AI๊ฐ ์ด๋ค ์ฝํ
์ธ ๋ฅผ ๊ทผ๊ฑฐ๋ก ์ ํํ๋์ง** ํ์
ํ ์ ์์ต๋๋ค.
|
| 89 |
+
|
| 90 |
+
```
|
| 91 |
+
์ฌ์ฉ์ ์ง๋ฌธ โ Gemini ๊ฒ์ ํ๋จ โ Google Search โ 2000๋จ์ด ์์ฐ ๋ด ์ธ์ฉ ์ถ์ถ
|
| 92 |
+
โ
|
| 93 |
+
citation quote ํด๋ฌ์คํฐ๋ง โ Supply ํ ํฝ
|
| 94 |
+
```
|
| 95 |
+
""")
|
| 96 |
+
else:
|
| 97 |
+
st.markdown("""
|
| 98 |
+
AI ๋ชจ๋ธ์ด ์ฌ์ฉ์ ์ง๋ฌธ์ ๋ต๋ณํ ๋, ๋ด๋ถ์ ์ผ๋ก ์ฌ๋ฌ ๊ฐ์ ์ธ๋ถ ์ง๋ฌธ(์ถ๊ฐ ์ง๋ฌธ)์
|
| 99 |
+
๋ง๋ค์ด ์กฐ์ฌํฉ๋๋ค. ์ด ์ถ๊ฐ ์ง๋ฌธ๋ค์ ๋ถ์ํ๋ฉด **AI๊ฐ ์ด๋ค ์ฃผ์ ์ ๊ด์ฌ์ ๊ฐ๊ณ ์๋์ง**,
|
| 100 |
+
์ด๋ค ๋ถ์ผ์์ **๊ฒฝ์์ด ์น์ดํ์ง**๋ฅผ ํ์
ํ ์ ์์ต๋๋ค.
|
| 101 |
+
|
| 102 |
+
**๋ฐ์ดํฐ ํ๋ฆ:**
|
| 103 |
+
```
|
| 104 |
+
์ฌ์ฉ์ ์ง๋ฌธ โ AI๊ฐ ์ถ๊ฐ ์ง๋ฌธ ์์ฑ โ ๋ต๋ณ ์์ฑ โ ์ถ์ฒ ์ธ์ฉ
|
| 105 |
+
โ
|
| 106 |
+
์ ์ฌํ ์ถ๊ฐ ์ง๋ฌธ๋ผ๋ฆฌ ๋ฌถ๊ธฐ (ํด๋ฌ์คํฐ๋ง)
|
| 107 |
+
โ
|
| 108 |
+
๊ฐ ํ ํฝ์ ์ ์ ๊ณ์ฐ
|
| 109 |
+
ยท AI ๊ด์ฌ๋: AI๊ฐ ์ด ์ฃผ์ ๋ฅผ ์ผ๋ง๋ ์์ฃผ ๋ฌผ์ด๋ณด๋๊ฐ
|
| 110 |
+
ยท ๊ฒฝ์ ๋ฐ๋: ์ด ์ฃผ์ ์ ์ผ๋ง๋ ๋ง์ ์ถ์ฒ๊ฐ ์ธ์ฉ๋๋๊ฐ
|
| 111 |
+
ยท ๊ธฐํ ์ ์: ๊ด์ฌ์ ๋์ง๋ง ๊ฒฝ์์ด ๋ฎ์ ์์ญ
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
์์ธํ ๋ด์ฉ์ **"๋ถ์ ๊ฐ์ด๋"** ํญ์ ์ฐธ์กฐํ์ธ์.
|
| 115 |
+
""")
|
| 116 |
+
|
| 117 |
+
clusters = get_topic_clusters(campaign_id, source=source)
|
| 118 |
+
|
| 119 |
+
if not clusters:
|
| 120 |
+
if source:
|
| 121 |
+
st.warning(f"{selected} ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค. ํด๋น ๋ชจ๋ธ์ ํด๋ฌ์คํฐ๋ง์ ๋จผ์ ์คํํ์ธ์.")
|
| 122 |
+
else:
|
| 123 |
+
st.warning("ํ ํฝ ํด๋ฌ์คํฐ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค. ํด๋ฌ์คํฐ๋ง์ ๋จผ์ ์คํํ์ธ์.")
|
| 124 |
+
return
|
| 125 |
+
|
| 126 |
+
snapshot = get_topic_map_snapshot(campaign_id, source=source)
|
| 127 |
+
|
| 128 |
+
# Summary metrics
|
| 129 |
+
summary.render_summary(clusters, frame=source_cfg["frame"])
|
| 130 |
+
|
| 131 |
+
st.markdown("---")
|
| 132 |
+
|
| 133 |
+
# Cross-model pair (one DB call, reused across tabs)
|
| 134 |
+
pair = find_cross_model_pair(campaign_id)
|
| 135 |
+
|
| 136 |
+
# Build tab list โ Unified Scoring only in "์ ์ฒด" mode with pair
|
| 137 |
+
tab_names = [
|
| 138 |
+
"๐บ๏ธ ํ ํฝ ๋งต",
|
| 139 |
+
"๐ฏ ๊ธฐํ ์์ญ",
|
| 140 |
+
"๐ ํ ํฝ ๋ถํฌ",
|
| 141 |
+
"๐ Cross-Model",
|
| 142 |
+
"๐ก ์ฝํ
์ธ ๊ฐ์ด๋",
|
| 143 |
+
"๐ ํค์๋ ์ถ์ฒ",
|
| 144 |
+
]
|
| 145 |
+
show_unified = source_cfg["frame"] == "all" and pair is not None
|
| 146 |
+
if show_unified:
|
| 147 |
+
tab_names.append("โ๏ธ Unified Score")
|
| 148 |
+
tab_names.append("๐ ๋ถ์ ๊ฐ์ด๋")
|
| 149 |
+
|
| 150 |
+
tabs = st.tabs(tab_names)
|
| 151 |
+
idx = 0
|
| 152 |
+
|
| 153 |
+
with tabs[idx]:
|
| 154 |
+
topic_map.render_topic_map(clusters, snapshot, frame=source_cfg["frame"])
|
| 155 |
+
idx += 1
|
| 156 |
+
|
| 157 |
+
with tabs[idx]:
|
| 158 |
+
opportunities.render_opportunities(clusters, frame=source_cfg["frame"])
|
| 159 |
+
idx += 1
|
| 160 |
+
|
| 161 |
+
with tabs[idx]:
|
| 162 |
+
distribution.render_distribution(clusters, frame=source_cfg["frame"])
|
| 163 |
+
idx += 1
|
| 164 |
+
|
| 165 |
+
with tabs[idx]:
|
| 166 |
+
if pair:
|
| 167 |
+
render_cross_model(base_ctx, pair)
|
| 168 |
+
else:
|
| 169 |
+
st.info("์ด ์บ ํ์ธ์ ๋ํ Cross-Model ๋ถ์์ด ์์ง ์์ต๋๋ค.")
|
| 170 |
+
idx += 1
|
| 171 |
+
|
| 172 |
+
with tabs[idx]:
|
| 173 |
+
if pair:
|
| 174 |
+
render_content_actions(base_ctx, pair)
|
| 175 |
+
else:
|
| 176 |
+
st.info("Cross-Model ๋ถ์ ์๋ฃ ํ ์ฝํ
์ธ ๊ฐ์ด๋๋ฅผ ์ฌ์ฉํ ์ ์์ต๋๋ค.")
|
| 177 |
+
idx += 1
|
| 178 |
+
|
| 179 |
+
with tabs[idx]:
|
| 180 |
+
render_keyword_suggestions(clusters, frame=source_cfg["frame"])
|
| 181 |
+
idx += 1
|
| 182 |
+
|
| 183 |
+
if show_unified:
|
| 184 |
+
with tabs[idx]:
|
| 185 |
+
render_unified_scoring(base_ctx, pair)
|
| 186 |
+
idx += 1
|
| 187 |
+
|
| 188 |
+
with tabs[idx]:
|
| 189 |
+
guide.render_data_flow()
|
features/research/content_actions.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""์ฝํ
์ธ ์ก์
๊ฐ์ด๋ (R-5).
|
| 2 |
+
|
| 3 |
+
GapScore OPPORTUNITY ํ ํฝ ๊ธฐ๋ฐ ์ฝํ
์ธ ์ ์ ์ ์.
|
| 4 |
+
Cross-Model ๋ถ์ ๋ฐ์ดํฐ๋ฅผ ํ์ฉํ์ฌ ๊ตฌ์ฒด์ ์ก์
์์ดํ
์์ฑ.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import streamlit as st
|
| 8 |
+
import pandas as pd
|
| 9 |
+
|
| 10 |
+
from core.supabase_client import get_gap_scores, get_topic_clusters
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# Content strategy templates per quadrant
|
| 14 |
+
STRATEGY_TEMPLATES = {
|
| 15 |
+
"OPPORTUNITY": {
|
| 16 |
+
"priority": "๐ด ๋์",
|
| 17 |
+
"action": "์ฝํ
์ธ ์ ์",
|
| 18 |
+
"detail": (
|
| 19 |
+
"์ฌ์ฉ์๊ฐ ์์ฃผ ๊ฒ์ํ์ง๋ง AI๊ฐ ์ธ์ฉํ ๋งํ ์ฝํ
์ธ ๊ฐ ๋ถ์กฑํฉ๋๋ค. "
|
| 20 |
+
"์ด ํ ํฝ์ ๋ํ ์ ๋ฌธ ์ฝํ
์ธ ๋ฅผ ์ ์ํ๋ฉด AI ๋ต๋ณ์ ์ธ์ฉ๋ ํ๋ฅ ์ด ๋์ต๋๋ค."
|
| 21 |
+
),
|
| 22 |
+
"tactics": [
|
| 23 |
+
"FAQ ํ์ด์ง์ ์ด ํ ํฝ ๊ด๋ จ ์ง๋ฌธ-๋ต๋ณ ์ถ๊ฐ",
|
| 24 |
+
"ํต๊ณ/๋ฐ์ดํฐ ํฌํจ โ AI ์ธ์ฉ ํ๋ฅ +41% (GEO ์ฐ๊ตฌ)",
|
| 25 |
+
"30-50๋จ์ด ์๊ธฐ ์๊ฒฐํ ๋ต๋ณ ๋ฌธ๋จ ํฌํจ (Answer Capsule)",
|
| 26 |
+
"Schema Markup ์ถ๊ฐ โ AI ์ธ์ฉ ํ๋ฅ 2.5๋ฐฐ ์ฆ๊ฐ",
|
| 27 |
+
],
|
| 28 |
+
},
|
| 29 |
+
"SATURATED": {
|
| 30 |
+
"priority": "๐ก ์ค๊ฐ",
|
| 31 |
+
"action": "์ฐจ๋ณํ ๊ฐํ",
|
| 32 |
+
"detail": (
|
| 33 |
+
"์์์ ๊ณต๊ธ ๋ชจ๋ ๋์ ๊ฒฝ์ ํ ํฝ์
๋๋ค. "
|
| 34 |
+
"๊ธฐ์กด ์ฝํ
์ธ ์ ์ฐจ๋ณํ๋ ์ ๋ฌธ์ฑ์ด๋ ๊ณ ์ ๋ฐ์ดํฐ๊ฐ ํ์ํฉ๋๋ค."
|
| 35 |
+
),
|
| 36 |
+
"tactics": [
|
| 37 |
+
"์์ฒด ์ฐ๊ตฌ ๋ฐ์ดํฐ/์ผ์ด์ค ์คํฐ๋ ์ถ๊ฐ",
|
| 38 |
+
"๊ธฐ์กด ์ธ์ฉ ์์ค ๋ถ์ โ ๋น ์ง ๊ฐ๋(angle) ๋ฐ๊ตด",
|
| 39 |
+
"E-E-A-T ์ ํธ ๊ฐํ (์ ์ ์ ๋ฌธ์ฑ, ์ธ์ฉ ์ถ์ฒ ๋ช
์)",
|
| 40 |
+
"๋น๊ตํ/๋ฐ์ดํฐ ์๊ฐํ๋ก ์ ๋ณด ๋ฐ๋ ๋์ด๊ธฐ",
|
| 41 |
+
],
|
| 42 |
+
},
|
| 43 |
+
"LATENT_AUTHORITY": {
|
| 44 |
+
"priority": "๐ข ๋ฎ์",
|
| 45 |
+
"action": "์ ์ง + ๋ชจ๋ํฐ๋ง",
|
| 46 |
+
"detail": (
|
| 47 |
+
"์ด๋ฏธ AI์ ์ธ์ฉ๋๊ณ ์์ง๋ง ๊ฒ์ ์์๊ฐ ๋ฎ์ต๋๋ค. "
|
| 48 |
+
"๊ธฐ์กด ์ฝํ
์ธ ๋ฅผ ์ ์งํ๋ฉฐ ์์ ๋ณํ๋ฅผ ๋ชจ๋ํฐ๋งํ์ธ์."
|
| 49 |
+
),
|
| 50 |
+
"tactics": [
|
| 51 |
+
"๊ธฐ์กด ์ธ์ฉ ์ฝํ
์ธ ์ ์ต์ ์
๋ฐ์ดํธ ์ ์ง",
|
| 52 |
+
"Demand ์ฆ๊ฐ ์ถ์ธ ๊ฐ์ง ์ ์ฝํ
์ธ ํ์ฅ",
|
| 53 |
+
"์ธ์ฉ๋๋ ๊ตฌ์ฒด์ ๋ฌธ์ฅ/๊ตฌ์ ํ์
โ ๊ฐํ",
|
| 54 |
+
],
|
| 55 |
+
},
|
| 56 |
+
"NICHE": {
|
| 57 |
+
"priority": "โช ๊ด๋ง",
|
| 58 |
+
"action": "์ ํ์ ์คํ",
|
| 59 |
+
"detail": (
|
| 60 |
+
"์์์ ๊ณต๊ธ ๋ชจ๋ ๋ฎ์ ํ์ ์์ญ์
๋๋ค. "
|
| 61 |
+
"์์ฅ์ด ์ฑ์ฅํ๋ฉด ์ ์ ํจ๊ณผ๋ฅผ ๋ณผ ์ ์์ต๋๋ค."
|
| 62 |
+
),
|
| 63 |
+
"tactics": [
|
| 64 |
+
"๋ฎ์ ๋น์ฉ์ผ๋ก ๊ธฐ๋ณธ ์ฝํ
์ธ ๋ง๋ จ (์ ์ )",
|
| 65 |
+
"๊ด๋ จ ํค์๋ ํธ๋ ๋ ๋ชจ๋ํฐ๋ง",
|
| 66 |
+
],
|
| 67 |
+
},
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def render_content_actions(base_ctx: dict, pair: dict):
|
| 72 |
+
"""Render content action guide based on GapScore analysis."""
|
| 73 |
+
campaign_chatgpt = pair["campaign_chatgpt"]
|
| 74 |
+
campaign_gemini = pair["campaign_gemini"]
|
| 75 |
+
|
| 76 |
+
st.caption(
|
| 77 |
+
"Demand-Supply Gap ๋ถ์ ๊ฒฐ๊ณผ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก **๊ตฌ์ฒด์ ์ฝํ
์ธ ์ ๋ต**์ ์ ์ํฉ๋๋ค."
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
matches = get_gap_scores(campaign_chatgpt, campaign_gemini)
|
| 81 |
+
if not matches:
|
| 82 |
+
st.warning("GapScore ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.")
|
| 83 |
+
return
|
| 84 |
+
|
| 85 |
+
# Fetch cluster details for sample_fanouts and top_sources
|
| 86 |
+
chatgpt_clusters = get_topic_clusters(campaign_chatgpt, source="chatgpt")
|
| 87 |
+
gemini_clusters = get_topic_clusters(campaign_gemini, source="gemini")
|
| 88 |
+
|
| 89 |
+
chatgpt_map = {c["id"]: c for c in chatgpt_clusters}
|
| 90 |
+
gemini_map = {c["id"]: c for c in gemini_clusters}
|
| 91 |
+
|
| 92 |
+
# --- Overview: Quadrant distribution ---
|
| 93 |
+
quadrant_counts = {}
|
| 94 |
+
for m in matches:
|
| 95 |
+
q = m.get("quadrant", "NICHE")
|
| 96 |
+
quadrant_counts[q] = quadrant_counts.get(q, 0) + 1
|
| 97 |
+
|
| 98 |
+
opp_count = quadrant_counts.get("OPPORTUNITY", 0)
|
| 99 |
+
sat_count = quadrant_counts.get("SATURATED", 0)
|
| 100 |
+
|
| 101 |
+
col1, col2, col3 = st.columns(3)
|
| 102 |
+
with col1:
|
| 103 |
+
st.metric("์ฝํ
์ธ ์ ์ ํ์", f"{opp_count}๊ฐ ํ ํฝ",
|
| 104 |
+
help="OPPORTUNITY: Demand ๋์ + Supply ๋ฎ์")
|
| 105 |
+
with col2:
|
| 106 |
+
st.metric("์ฐจ๋ณํ ํ์", f"{sat_count}๊ฐ ํ ํฝ",
|
| 107 |
+
help="SATURATED: Demand ๋์ + Supply ๋์")
|
| 108 |
+
with col3:
|
| 109 |
+
st.metric("์ด ๋ถ์ ํ ํฝ", f"{len(matches)}๊ฐ")
|
| 110 |
+
|
| 111 |
+
st.markdown("---")
|
| 112 |
+
|
| 113 |
+
# --- Priority Action List ---
|
| 114 |
+
st.markdown("### ์ฐ์ ์ก์
๋ฆฌ์คํธ")
|
| 115 |
+
st.caption("GapScore ์์ผ๋ก ์ ๋ ฌ. OPPORTUNITY ํ ํฝ์ด ์ต์ฐ์ ์
๋๋ค.")
|
| 116 |
+
|
| 117 |
+
# Summary table
|
| 118 |
+
rows = []
|
| 119 |
+
for i, m in enumerate(matches, 1):
|
| 120 |
+
q = m.get("quadrant", "NICHE")
|
| 121 |
+
strategy = STRATEGY_TEMPLATES.get(q, STRATEGY_TEMPLATES["NICHE"])
|
| 122 |
+
rows.append({
|
| 123 |
+
"์์": i,
|
| 124 |
+
"ํ ํฝ (Demand)": (m.get("chatgpt_label") or "")[:35],
|
| 125 |
+
"ํ ํฝ (Supply)": (m.get("gemini_label") or "")[:35],
|
| 126 |
+
"GapScore": f"{float(m.get('gap_score', 0)):.4f}",
|
| 127 |
+
"Quadrant": q.replace("_", " ").title(),
|
| 128 |
+
"์ก์
": strategy["action"],
|
| 129 |
+
"์ฐ์ ์์": strategy["priority"],
|
| 130 |
+
})
|
| 131 |
+
|
| 132 |
+
df = pd.DataFrame(rows)
|
| 133 |
+
st.dataframe(df, use_container_width=True, hide_index=True)
|
| 134 |
+
|
| 135 |
+
st.markdown("---")
|
| 136 |
+
|
| 137 |
+
# --- Detailed Action Cards for Top OPPORTUNITY topics ---
|
| 138 |
+
opportunity_matches = [m for m in matches if m.get("quadrant") == "OPPORTUNITY"]
|
| 139 |
+
|
| 140 |
+
if opportunity_matches:
|
| 141 |
+
st.markdown("### OPPORTUNITY ํ ํฝ ์์ธ ๊ฐ์ด๋")
|
| 142 |
+
st.caption(
|
| 143 |
+
"์ฝํ
์ธ ์ ์ ROI๊ฐ ๊ฐ์ฅ ๋์ ํ ํฝ์
๋๋ค. "
|
| 144 |
+
"์ฌ์ฉ์๊ฐ ์์ฃผ ๋ฌป์ง๋ง AI๊ฐ ์ธ์ฉํ ์ฝํ
์ธ ๊ฐ ๋ถ์กฑํฉ๋๋ค."
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
for i, m in enumerate(opportunity_matches[:10], 1):
|
| 148 |
+
chatgpt_label = m.get("chatgpt_label", "Unknown")
|
| 149 |
+
gap = float(m.get("gap_score") or 0)
|
| 150 |
+
demand = float(m.get("demand_percentile") or 0)
|
| 151 |
+
supply = float(m.get("supply_percentile") or 0)
|
| 152 |
+
|
| 153 |
+
chatgpt_detail = chatgpt_map.get(m["chatgpt_cluster_id"], {})
|
| 154 |
+
gemini_detail = gemini_map.get(m["gemini_cluster_id"], {})
|
| 155 |
+
|
| 156 |
+
with st.expander(f"#{i} {chatgpt_label} (GapScore: {gap:.4f})", key=f"research:opp_action:{m['id']}"):
|
| 157 |
+
left, right = st.columns(2)
|
| 158 |
+
|
| 159 |
+
with left:
|
| 160 |
+
st.markdown("**Gap ๋ถ์**")
|
| 161 |
+
st.write(f"- Demand (ChatGPT): {demand:.0%}")
|
| 162 |
+
st.write(f"- Supply (Gemini): {supply:.0%}")
|
| 163 |
+
st.write(f"- Gap: Demand {demand:.0%} vs Supply {supply:.0%}")
|
| 164 |
+
|
| 165 |
+
# Sample fanouts = what users ask
|
| 166 |
+
samples = chatgpt_detail.get("sample_fanouts") or []
|
| 167 |
+
if samples:
|
| 168 |
+
st.markdown("**์ฌ์ฉ์๊ฐ ๋ฌป๋ ์ง๋ฌธ๋ค:**")
|
| 169 |
+
for s in samples[:5]:
|
| 170 |
+
st.write(f" - {s}")
|
| 171 |
+
|
| 172 |
+
with right:
|
| 173 |
+
st.markdown("**์ฝํ
์ธ ์ ๋ต**")
|
| 174 |
+
strategy = STRATEGY_TEMPLATES["OPPORTUNITY"]
|
| 175 |
+
st.info(strategy["detail"])
|
| 176 |
+
|
| 177 |
+
st.markdown("**๊ตฌ์ฒด์ ์คํ ํญ๋ชฉ:**")
|
| 178 |
+
for tactic in strategy["tactics"]:
|
| 179 |
+
st.write(f"- {tactic}")
|
| 180 |
+
|
| 181 |
+
# Show Gemini citation examples if available
|
| 182 |
+
gemini_samples = gemini_detail.get("sample_fanouts") or []
|
| 183 |
+
if gemini_samples:
|
| 184 |
+
st.markdown("**AI๊ฐ ํ์ฌ ์ธ์ฉํ๋ ๋ฌธ๊ตฌ ์์:**")
|
| 185 |
+
for s in gemini_samples[:3]:
|
| 186 |
+
st.write(f" > {s}")
|
| 187 |
+
st.caption("์ด๋ฐ ํํ์ ๋ฌธ์ฅ์ ์ฝํ
์ธ ์ ํฌํจํ์ธ์.")
|
| 188 |
+
|
| 189 |
+
# Show top sources if available
|
| 190 |
+
top_sources = gemini_detail.get("top_sources")
|
| 191 |
+
if top_sources and isinstance(top_sources, list):
|
| 192 |
+
domains = [s.get("host_url", s.get("url", "")) for s in top_sources[:5]]
|
| 193 |
+
if domains:
|
| 194 |
+
st.markdown("**ํ์ฌ AI ์ธ์ฉ ์ถ์ฒ:**")
|
| 195 |
+
for d in domains:
|
| 196 |
+
st.write(f" - {d}")
|
| 197 |
+
st.caption("์ด ์ถ์ฒ๋ค์ด ๋ค๋ฃจ์ง ์๋ ๊ฐ๋๋ฅผ ์ฐพ์ผ์ธ์.")
|
| 198 |
+
else:
|
| 199 |
+
st.success("๋ชจ๋ ํ ํฝ์ ์ถฉ๋ถํ Supply๊ฐ ์์ต๋๋ค. ์ฐจ๋ณํ ์ ๋ต์ ์ง์คํ์ธ์.")
|
| 200 |
+
|
| 201 |
+
# --- SATURATED topics brief ---
|
| 202 |
+
saturated_matches = [m for m in matches if m.get("quadrant") == "SATURATED"]
|
| 203 |
+
if saturated_matches:
|
| 204 |
+
st.markdown("---")
|
| 205 |
+
st.markdown("### SATURATED ํ ํฝ ์์ฝ")
|
| 206 |
+
st.caption("์ฐจ๋ณํ๊ฐ ํ์ํ ๊ฒฝ์ ํ ํฝ์
๋๋ค.")
|
| 207 |
+
|
| 208 |
+
for i, m in enumerate(saturated_matches[:5], 1):
|
| 209 |
+
label = m.get("chatgpt_label", "Unknown")
|
| 210 |
+
gap = float(m.get("gap_score", 0))
|
| 211 |
+
|
| 212 |
+
with st.expander(f"#{i} {label} (GapScore: {gap:.4f})", key=f"research:sat_action:{m['id']}"):
|
| 213 |
+
strategy = STRATEGY_TEMPLATES["SATURATED"]
|
| 214 |
+
st.info(strategy["detail"])
|
| 215 |
+
st.markdown("**์คํ ํญ๋ชฉ:**")
|
| 216 |
+
for tactic in strategy["tactics"]:
|
| 217 |
+
st.write(f"- {tactic}")
|
features/research/cross_model.py
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cross-Model Quadrant Matrix + Convergence Analysis (ADR-014 Phase 3).
|
| 2 |
+
|
| 3 |
+
Demand (ChatGPT fanout) vs Supply (Gemini citation) gap analysis.
|
| 4 |
+
Scatter plot + GapScore Top 10 + Convergence (Venn, matched/unmatched topics).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import statistics
|
| 8 |
+
|
| 9 |
+
import streamlit as st
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import plotly.graph_objects as go
|
| 12 |
+
|
| 13 |
+
from core.supabase_client import (
|
| 14 |
+
get_cross_model_analysis, get_gap_scores, get_topic_clusters,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# Quadrant colors matching ADR-014 spec
|
| 19 |
+
QUADRANT_COLORS = {
|
| 20 |
+
"OPPORTUNITY": "#10B981", # Green
|
| 21 |
+
"SATURATED": "#3B82F6", # Blue
|
| 22 |
+
"LATENT_AUTHORITY": "#F59E0B", # Amber
|
| 23 |
+
"NICHE": "#9CA3AF", # Gray
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
QUADRANT_LABELS = {
|
| 27 |
+
"OPPORTUNITY": "Opportunity",
|
| 28 |
+
"SATURATED": "Saturated",
|
| 29 |
+
"LATENT_AUTHORITY": "Latent Authority",
|
| 30 |
+
"NICHE": "Niche",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
QUADRANT_ACTIONS = {
|
| 34 |
+
"OPPORTUNITY": "์ฌ์ฉ์ ๊ด์ฌ์ด ๋์ง๋ง ์ธ์ฉ ์ฝํ
์ธ ๊ฐ ๋ถ์กฑํฉ๋๋ค. "
|
| 35 |
+
"์ด ์ฃผ์ ์ ์ ๋ฌธ ์ฝํ
์ธ ๋ฅผ ์ ์ํ๋ฉด AI ๋ต๋ณ์ ์ธ์ฉ๋ ๊ฐ๋ฅ์ฑ์ด ๋์ต๋๋ค.",
|
| 36 |
+
"SATURATED": "์์์ ๊ณต๊ธ ๋ชจ๋ ๋์ ๊ฒฝ์ ์์ญ์
๋๋ค. "
|
| 37 |
+
"์ฐจ๋ณํ๋ ์ ๋ฌธ์ฑ์ด๋ ๊ณ ์ ๋ฐ์ดํฐ๋ก ๊ธฐ์กด ์ฝํ
์ธ ์ ์ฐจ๋ณํํ์ธ์.",
|
| 38 |
+
"LATENT_AUTHORITY": "์ด๋ฏธ ์ธ์ฉ๋๊ณ ์์ง๋ง ๊ฒ์ ์์๋ ๋ฎ์ต๋๋ค. "
|
| 39 |
+
"๊ธฐ์กด ์ฝํ
์ธ ๋ฅผ ํ์ฉํ์ฌ ๋ธ๋๋ ๊ถ์๋ฅผ ๊ฐํํ์ธ์.",
|
| 40 |
+
"NICHE": "์์์ ๊ณต๊ธ ๋ชจ๋ ๋ฎ์ ํ์ ์์ญ์
๋๋ค. "
|
| 41 |
+
"์์ฅ ๋ณํ๋ฅผ ๋ชจ๋ํฐ๋งํ๋ฉฐ ๊ธฐํ๊ฐ ์ปค์ง๋ฉด ์ง์
์ ๊ฒํ ํ์ธ์.",
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def render_cross_model(base_ctx: dict, pair: dict):
|
| 46 |
+
"""Render cross-model quadrant matrix UI.
|
| 47 |
+
|
| 48 |
+
Args:
|
| 49 |
+
base_ctx: Dashboard base context with campaign_id etc.
|
| 50 |
+
pair: Cross-model pair dict from find_cross_model_pair().
|
| 51 |
+
"""
|
| 52 |
+
campaign_chatgpt = pair["campaign_chatgpt"]
|
| 53 |
+
campaign_gemini = pair["campaign_gemini"]
|
| 54 |
+
|
| 55 |
+
st.caption(
|
| 56 |
+
"ChatGPT์ Gemini ๋ AI ๋ชจ๋ธ์ ํ ํฝ์ ๋น๊ตํ์ฌ "
|
| 57 |
+
"**์ฝํ
์ธ ์์-๊ณต๊ธ Gap**์ ๋ถ์ํฉ๋๋ค."
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
with st.expander("Cross-Model ๋ถ์์ด๋?", expanded=False):
|
| 61 |
+
st.markdown("""
|
| 62 |
+
**์ ๋ ๋ชจ๋ธ์ ๋น๊ตํ๋์?**
|
| 63 |
+
|
| 64 |
+
ChatGPT์ Gemini๋ ๊ฐ์ ์ฃผ์ ์ ๋ํด ์๋ก ๋ค๋ฅธ ๋ฐฉ์์ผ๋ก ์ ๋ณด๋ฅผ ํ์ํฉ๋๋ค:
|
| 65 |
+
- **ChatGPT (Demand)**: ์ฌ์ฉ์ ์ง๋ฌธ์ ์ฌ๋ฌ ํ์ ์ง๋ฌธ์ผ๋ก ๋ถํดํ์ฌ ๊ฒ์ํฉ๋๋ค.
|
| 66 |
+
AI๊ฐ ์์ฃผ ๊ฒ์ํ๋ ํ ํฝ = **์ฌ์ฉ์ ๊ด์ฌ์ด ๋์ ํ ํฝ**
|
| 67 |
+
- **Gemini (Supply)**: ๋ต๋ณ์ ์ค์ ์น ์ฝํ
์ธ ๋ฅผ ์ธ์ฉํฉ๋๋ค.
|
| 68 |
+
AI๊ฐ ์์ฃผ ์ธ์ฉํ๋ ํ ํฝ = **์ฝํ
์ธ ๊ณต๊ธ์ด ์ถฉ๋ถํ ํ ํฝ**
|
| 69 |
+
|
| 70 |
+
**๋ ์ ํธ๋ฅผ ๊ต์ฐจ ๋ถ์**ํ๋ฉด, "์ฌ๋๋ค์ด ๋ง์ด ๋ฌผ์ด๋ณด์ง๋ง ์์ง ์ข์ ์ฝํ
์ธ ๊ฐ ์๋ ์์ญ"์
|
| 71 |
+
๋ฐ์ดํฐ ๊ธฐ๋ฐ์ผ๋ก ๋ฐ๊ฒฌํ ์ ์์ต๋๋ค.
|
| 72 |
+
|
| 73 |
+
**๋ถ์ ํ๋ก์ธ์ค:**
|
| 74 |
+
```
|
| 75 |
+
ChatGPT ํ์ ์ง๋ฌธ ํด๋ฌ์คํฐ๋ง (Demand ํ ํฝ)
|
| 76 |
+
โ
|
| 77 |
+
Gemini ์ธ์ฉ ๋ฌธ๊ตฌ ํด๋ฌ์คํฐ๋ง (Supply ํ ํฝ)
|
| 78 |
+
โ
|
| 79 |
+
๋ ๋ชจ๋ธ์ ์ ์ฌ ํ ํฝ ๋งค์นญ (Label + Centroid ์ ์ฌ๋)
|
| 80 |
+
โ
|
| 81 |
+
Demand-Supply Gap ๊ณ์ฐ โ Quadrant ๋ถ๋ฅ
|
| 82 |
+
```
|
| 83 |
+
""")
|
| 84 |
+
|
| 85 |
+
# Fetch data
|
| 86 |
+
analysis = get_cross_model_analysis(campaign_chatgpt, campaign_gemini)
|
| 87 |
+
matches = get_gap_scores(campaign_chatgpt, campaign_gemini)
|
| 88 |
+
|
| 89 |
+
if not analysis or not matches:
|
| 90 |
+
st.warning("Cross-Model ๋ถ์ ๋ฐ์ดํฐ๋ฅผ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค.")
|
| 91 |
+
return
|
| 92 |
+
|
| 93 |
+
# Fetch clusters for convergence analysis
|
| 94 |
+
chatgpt_clusters = get_topic_clusters(campaign_chatgpt, source="chatgpt")
|
| 95 |
+
gemini_clusters = get_topic_clusters(campaign_gemini, source="gemini")
|
| 96 |
+
|
| 97 |
+
# --- A) Alignment Overview ---
|
| 98 |
+
_render_overview(analysis, matches)
|
| 99 |
+
|
| 100 |
+
st.markdown("---")
|
| 101 |
+
|
| 102 |
+
# --- B) Quadrant Scatter Plot ---
|
| 103 |
+
_render_scatter(matches)
|
| 104 |
+
|
| 105 |
+
st.markdown("---")
|
| 106 |
+
|
| 107 |
+
# --- C) GapScore Top 10 ---
|
| 108 |
+
_render_top_gaps(matches)
|
| 109 |
+
|
| 110 |
+
st.markdown("---")
|
| 111 |
+
|
| 112 |
+
# --- D) Convergence Analysis (Phase 3.4) ---
|
| 113 |
+
_render_convergence(
|
| 114 |
+
analysis, matches, chatgpt_clusters, gemini_clusters,
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _render_overview(analysis: dict, matches: list[dict]):
|
| 119 |
+
"""Render alignment overview metrics."""
|
| 120 |
+
alignment = float(analysis.get("nmi_score", 0))
|
| 121 |
+
total_matched = int(analysis.get("total_matched_topics", 0))
|
| 122 |
+
|
| 123 |
+
# Count by quadrant
|
| 124 |
+
quadrant_counts = {}
|
| 125 |
+
gap_scores = []
|
| 126 |
+
for m in matches:
|
| 127 |
+
q = m.get("quadrant", "NICHE")
|
| 128 |
+
quadrant_counts[q] = quadrant_counts.get(q, 0) + 1
|
| 129 |
+
gap_scores.append(float(m.get("gap_score", 0)))
|
| 130 |
+
|
| 131 |
+
opp_count = quadrant_counts.get("OPPORTUNITY", 0)
|
| 132 |
+
mean_gap = sum(gap_scores) / len(gap_scores) if gap_scores else 0
|
| 133 |
+
|
| 134 |
+
# Color-code alignment
|
| 135 |
+
if alignment >= 0.8:
|
| 136 |
+
align_color = "green"
|
| 137 |
+
align_label = "Strong"
|
| 138 |
+
elif alignment >= 0.6:
|
| 139 |
+
align_color = "orange"
|
| 140 |
+
align_label = "Moderate"
|
| 141 |
+
else:
|
| 142 |
+
align_color = "red"
|
| 143 |
+
align_label = "Weak"
|
| 144 |
+
|
| 145 |
+
c1, c2, c3, c4 = st.columns(4)
|
| 146 |
+
with c1:
|
| 147 |
+
st.metric(
|
| 148 |
+
"๋ชจ๋ธ ์ ํฉ๋", f"{alignment:.4f}",
|
| 149 |
+
help="ChatGPT์ Gemini ํ ํฝ ๋งค์นญ ํ์ง. "
|
| 150 |
+
"0.5 ์ด์์ด๋ฉด ๋ ๋ชจ๋ธ์ด ์ ์ฌํ ์ฃผ์ ๋ฅผ ๋ค๋ฃจ๊ณ ์์ด Gap ๋ถ์์ด ์ ๋ขฐํ ์ ์์ต๋๋ค.",
|
| 151 |
+
)
|
| 152 |
+
st.caption(f":{align_color}[{align_label}]")
|
| 153 |
+
with c2:
|
| 154 |
+
st.metric(
|
| 155 |
+
"๋งค์นญ๋ ํ ํฝ", f"{total_matched}์",
|
| 156 |
+
help="๋ AI ๋ชจ๋ธ์์ ๋์ผํ ์ฃผ์ ๋ก ๋งค์นญ๋ ํ ํฝ ์ ์",
|
| 157 |
+
)
|
| 158 |
+
with c3:
|
| 159 |
+
st.metric(
|
| 160 |
+
"์ฝํ
์ธ ๊ธฐํ", f"{opp_count}๊ฐ",
|
| 161 |
+
help="Demand ๋์ + Supply ๋ฎ์์ธ ํ ํฝ ์. "
|
| 162 |
+
"์ด ํ ํฝ๋ค์ ์ฝํ
์ธ ๋ฅผ ๋ง๋ค๋ฉด AI ์ธ์ฉ ๊ฐ๋ฅ์ฑ์ด ๋์ต๋๋ค.",
|
| 163 |
+
)
|
| 164 |
+
with c4:
|
| 165 |
+
st.metric(
|
| 166 |
+
"ํ๊ท GapScore", f"{mean_gap:.4f}",
|
| 167 |
+
help="์ ์ฒด ๋งค์นญ ํ ํฝ์ ํ๊ท Demand-Supply Gap. "
|
| 168 |
+
"๋์์๋ก ์ ๋ฐ์ ์ผ๋ก ์ฝํ
์ธ ๊ธฐํ๊ฐ ๋ง์์ ์๋ฏธํฉ๋๋ค.",
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _render_scatter(matches: list[dict]):
|
| 173 |
+
"""Render demand vs supply quadrant scatter plot."""
|
| 174 |
+
st.markdown("""
|
| 175 |
+
**ChatGPT๊ฐ ์์ฃผ ๊ฒ์ํ๋ ํ ํฝ**(Demand)๊ณผ **Gemini๊ฐ ์ค์ ์ธ์ฉํ๋ ํ ํฝ**(Supply)์ ๋งค์นญํ์ฌ
|
| 176 |
+
์ฝํ
์ธ ๊ธฐํ๋ฅผ ์๊ฐํํฉ๋๋ค. ๊ฐ ์ ์ ๋ AI ๋ชจ๋ธ์์ ๋์ผํ ์ฃผ์ ๋ก ๋งค์นญ๋ ํ ํฝ ์์
๋๋ค.
|
| 177 |
+
|
| 178 |
+
| Quadrant | ์์น | ์๋ฏธ | ์ ๋ต |
|
| 179 |
+
|----------|------|------|------|
|
| 180 |
+
| **Opportunity** | ์ข์๋จ | Demand ๋์ + Supply ๋ฎ์ | ์ฝํ
์ธ ์ ์ ๊ธฐํ -- ์ฐ์ ์ ์ |
|
| 181 |
+
| **Saturated** | ์ฐ์๋จ | Demand ๋์ + Supply ๋์ | ์ฐจ๋ณํ ํ์ -- ์ ๋ฌธ์ฑ ๊ฐํ |
|
| 182 |
+
| **Latent Authority** | ์ฐํ๋จ | Demand ๋ฎ์ + Supply ๋์ | ์ด๋ฏธ ์ธ์ฉ๋จ -- ๋ธ๋๋ ๊ถ์ ํ์ฉ |
|
| 183 |
+
| **Niche** | ์ขํ๋จ | Demand ๋ฎ์ + Supply ๋ฎ์ | ๋ฎ์ ์ฐ์ ์์ -- ๋ณํ ๋ชจ๋ํฐ๋ง |
|
| 184 |
+
""")
|
| 185 |
+
|
| 186 |
+
xs, ys, colors, hover_texts, sizes = [], [], [], [], []
|
| 187 |
+
|
| 188 |
+
for m in matches:
|
| 189 |
+
supply = float(m.get("supply_percentile", 0))
|
| 190 |
+
demand = float(m.get("demand_percentile", 0))
|
| 191 |
+
quadrant = m.get("quadrant", "NICHE")
|
| 192 |
+
gap = float(m.get("gap_score", 0))
|
| 193 |
+
match_score = float(m.get("match_score", 0))
|
| 194 |
+
chatgpt_label = m.get("chatgpt_label", "")
|
| 195 |
+
gemini_label = m.get("gemini_label", "")
|
| 196 |
+
|
| 197 |
+
xs.append(supply)
|
| 198 |
+
ys.append(demand)
|
| 199 |
+
colors.append(QUADRANT_COLORS.get(quadrant, "#9CA3AF"))
|
| 200 |
+
sizes.append(max(8, min(30, gap * 300)))
|
| 201 |
+
|
| 202 |
+
hover_texts.append(
|
| 203 |
+
f"<b>{chatgpt_label}</b><br>"
|
| 204 |
+
f"Gemini: {gemini_label}<br>"
|
| 205 |
+
f"Demand: {demand:.2%}<br>"
|
| 206 |
+
f"Supply: {supply:.2%}<br>"
|
| 207 |
+
f"GapScore: {gap:.4f}<br>"
|
| 208 |
+
f"Match: {match_score:.4f}<br>"
|
| 209 |
+
f"Quadrant: {QUADRANT_LABELS.get(quadrant, quadrant)}"
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
fig = go.Figure()
|
| 213 |
+
|
| 214 |
+
fig.add_trace(go.Scatter(
|
| 215 |
+
x=xs,
|
| 216 |
+
y=ys,
|
| 217 |
+
mode="markers",
|
| 218 |
+
marker=dict(
|
| 219 |
+
size=sizes,
|
| 220 |
+
color=colors,
|
| 221 |
+
opacity=0.7,
|
| 222 |
+
line=dict(width=0.5, color="#333"),
|
| 223 |
+
),
|
| 224 |
+
text=hover_texts,
|
| 225 |
+
hoverinfo="text",
|
| 226 |
+
showlegend=False,
|
| 227 |
+
))
|
| 228 |
+
|
| 229 |
+
# Compute actual medians from data (matches quadrant_method=p50_median in gap scorer)
|
| 230 |
+
demand_median = statistics.median(ys) if len(ys) > 1 else 0.5
|
| 231 |
+
supply_median = statistics.median(xs) if len(xs) > 1 else 0.5
|
| 232 |
+
|
| 233 |
+
fig.add_hline(y=demand_median, line_dash="dash", line_color="#9CA3AF", opacity=0.5)
|
| 234 |
+
fig.add_vline(x=supply_median, line_dash="dash", line_color="#9CA3AF", opacity=0.5)
|
| 235 |
+
|
| 236 |
+
# Quadrant annotations โ axes: X=Supply, Y=Demand
|
| 237 |
+
# OPPORTUNITY: high demand (top) + low supply (left) โ top-left
|
| 238 |
+
# SATURATED: high demand (top) + high supply (right) โ top-right
|
| 239 |
+
# LATENT_AUTHORITY: low demand (bottom) + high supply (right) โ bottom-right
|
| 240 |
+
# NICHE: low demand (bottom) + low supply (left) โ bottom-left
|
| 241 |
+
fig.add_annotation(x=0.05, y=0.95, text="Opportunity",
|
| 242 |
+
showarrow=False, font=dict(size=11, color=QUADRANT_COLORS["OPPORTUNITY"]))
|
| 243 |
+
fig.add_annotation(x=0.95, y=0.95, text="Saturated",
|
| 244 |
+
showarrow=False, font=dict(size=11, color=QUADRANT_COLORS["SATURATED"]))
|
| 245 |
+
fig.add_annotation(x=0.95, y=0.05, text="Latent Authority",
|
| 246 |
+
showarrow=False, font=dict(size=11, color=QUADRANT_COLORS["LATENT_AUTHORITY"]))
|
| 247 |
+
fig.add_annotation(x=0.05, y=0.05, text="Niche",
|
| 248 |
+
showarrow=False, font=dict(size=11, color=QUADRANT_COLORS["NICHE"]))
|
| 249 |
+
|
| 250 |
+
fig.update_layout(
|
| 251 |
+
title="Demand vs Supply Quadrant Matrix",
|
| 252 |
+
xaxis_title="Supply Percentile (Gemini Citation)",
|
| 253 |
+
yaxis_title="Demand Percentile (ChatGPT Fanout)",
|
| 254 |
+
xaxis=dict(range=[-0.05, 1.05]),
|
| 255 |
+
yaxis=dict(range=[-0.05, 1.05]),
|
| 256 |
+
height=600,
|
| 257 |
+
template="plotly_white",
|
| 258 |
+
hoverlabel=dict(bgcolor="white", font_size=12),
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
st.plotly_chart(fig, use_container_width=True, key="cross_model:scatter", config={"displayModeBar": False})
|
| 262 |
+
|
| 263 |
+
# Quadrant count summary
|
| 264 |
+
quadrant_counts = {}
|
| 265 |
+
for m in matches:
|
| 266 |
+
q = m.get("quadrant", "NICHE")
|
| 267 |
+
quadrant_counts[q] = quadrant_counts.get(q, 0) + 1
|
| 268 |
+
|
| 269 |
+
q1, q2, q3, q4 = st.columns(4)
|
| 270 |
+
with q1:
|
| 271 |
+
st.metric("๐ข Opportunity", f"{quadrant_counts.get('OPPORTUNITY', 0)}๊ฐ",
|
| 272 |
+
help="AI๊ฐ ์์ฃผ ๊ฒ์ํ์ง๋ง ์ธ์ฉ ์ฝํ
์ธ ๊ฐ ๋ถ์กฑํ ํ ํฝ. ์ฝํ
์ธ ์ ์ ๊ธฐํ.")
|
| 273 |
+
with q2:
|
| 274 |
+
st.metric("๐ต Saturated", f"{quadrant_counts.get('SATURATED', 0)}๊ฐ",
|
| 275 |
+
help="๊ฒ์๋ ๋ง๊ณ ์ธ์ฉ๋ ๋ง์ ๊ฒฝ์ ํ ํฝ. ์ฐจ๋ณํ ์ ๋ต ํ์.")
|
| 276 |
+
with q3:
|
| 277 |
+
st.metric("๐ก Latent Authority", f"{quadrant_counts.get('LATENT_AUTHORITY', 0)}๊ฐ",
|
| 278 |
+
help="์ด๋ฏธ ์ธ์ฉ๋๊ณ ์์ง๋ง ๊ฒ์ ์์๋ ๋ฎ์ ํ ํฝ. ๋ธ๋๋ ๊ถ์ ํ์ฉ.")
|
| 279 |
+
with q4:
|
| 280 |
+
st.metric("โช Niche", f"{quadrant_counts.get('NICHE', 0)}๊ฐ",
|
| 281 |
+
help="์์์ ๊ณต๊ธ ๋ชจ๋ ๋ฎ์ ํ์ ์์ญ. ๋ณํ ๋ชจ๋ํฐ๋ง.")
|
| 282 |
+
|
| 283 |
+
st.caption(f"Demand Median: {demand_median:.4f} | Supply Median: {supply_median:.4f}")
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _render_top_gaps(matches: list[dict]):
|
| 287 |
+
"""Render GapScore Top 10 with detail expanders."""
|
| 288 |
+
st.markdown("#### GapScore Top 10")
|
| 289 |
+
st.caption("Demand-Supply Gap์ด ํฐ ํ ํฝ์ผ์๋ก ์ฝํ
์ธ ๊ธฐํ๊ฐ ๋์ต๋๋ค.")
|
| 290 |
+
|
| 291 |
+
top10 = matches[:10]
|
| 292 |
+
|
| 293 |
+
for i, m in enumerate(top10, 1):
|
| 294 |
+
chatgpt_label = m.get("chatgpt_label", "Unknown")
|
| 295 |
+
gemini_label = m.get("gemini_label", "Unknown")
|
| 296 |
+
gap = float(m.get("gap_score", 0))
|
| 297 |
+
quadrant = m.get("quadrant", "NICHE")
|
| 298 |
+
|
| 299 |
+
with st.expander(
|
| 300 |
+
f"#{i} {chatgpt_label} | GapScore: {gap:.4f}",
|
| 301 |
+
key=f"cross_model:gap_{m['id']}",
|
| 302 |
+
):
|
| 303 |
+
left, right = st.columns(2)
|
| 304 |
+
|
| 305 |
+
with left:
|
| 306 |
+
demand = float(m.get("demand_percentile") or 0)
|
| 307 |
+
supply = float(m.get("supply_percentile") or 0)
|
| 308 |
+
match_score = float(m.get("match_score") or 0)
|
| 309 |
+
|
| 310 |
+
st.markdown("**Metrics**")
|
| 311 |
+
st.write(f"- Demand Percentile: {demand:.2%}")
|
| 312 |
+
st.write(f"- Supply Percentile: {supply:.2%}")
|
| 313 |
+
st.write(f"- Match Score: {match_score:.4f}")
|
| 314 |
+
st.write(f"- ChatGPT Topic: {chatgpt_label}")
|
| 315 |
+
st.write(f"- Gemini Topic: {gemini_label}")
|
| 316 |
+
|
| 317 |
+
with right:
|
| 318 |
+
q_label = QUADRANT_LABELS.get(quadrant, quadrant)
|
| 319 |
+
action = QUADRANT_ACTIONS.get(quadrant, "")
|
| 320 |
+
|
| 321 |
+
st.markdown("**Quadrant & Action**")
|
| 322 |
+
st.markdown(f":{_st_color(quadrant)}[**{q_label}**]")
|
| 323 |
+
st.info(action)
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def _st_color(quadrant: str) -> str:
|
| 327 |
+
"""Map quadrant to Streamlit markdown color name."""
|
| 328 |
+
return {
|
| 329 |
+
"OPPORTUNITY": "green",
|
| 330 |
+
"SATURATED": "blue",
|
| 331 |
+
"LATENT_AUTHORITY": "orange",
|
| 332 |
+
"NICHE": "gray",
|
| 333 |
+
}.get(quadrant, "gray")
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
# ---------------------------------------------------------------------------
|
| 337 |
+
# Phase 3.4: Convergence Analysis
|
| 338 |
+
# ---------------------------------------------------------------------------
|
| 339 |
+
|
| 340 |
+
def _render_convergence(
|
| 341 |
+
analysis: dict,
|
| 342 |
+
matches: list[dict],
|
| 343 |
+
chatgpt_clusters: list[dict],
|
| 344 |
+
gemini_clusters: list[dict],
|
| 345 |
+
):
|
| 346 |
+
"""Render convergence analysis: Venn, matched/unmatched topic lists."""
|
| 347 |
+
st.markdown("#### ์๋ ด ๋ถ์ (Convergence)")
|
| 348 |
+
st.caption(
|
| 349 |
+
"ChatGPT(Demand)์ Gemini(Supply) ํ ํฝ์ด ์ผ๋ง๋ ๊ฒน์น๋์ง ๋ถ์ํฉ๋๋ค. "
|
| 350 |
+
"๋งค์นญ๋์ง ์์ ํ ํฝ์ ํ์ชฝ ๋ชจ๋ธ์์๋ง ๋ํ๋๋ ๊ณ ์ ์ ํธ์
๋๋ค."
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
# Compute matched / unmatched sets
|
| 354 |
+
matched_chatgpt_ids = {m["chatgpt_cluster_id"] for m in matches}
|
| 355 |
+
matched_gemini_ids = {m["gemini_cluster_id"] for m in matches}
|
| 356 |
+
|
| 357 |
+
all_chatgpt_ids = {c["id"] for c in chatgpt_clusters}
|
| 358 |
+
all_gemini_ids = {c["id"] for c in gemini_clusters}
|
| 359 |
+
|
| 360 |
+
unmatched_chatgpt_ids = all_chatgpt_ids - matched_chatgpt_ids
|
| 361 |
+
unmatched_gemini_ids = all_gemini_ids - matched_gemini_ids
|
| 362 |
+
|
| 363 |
+
n_chatgpt_only = len(unmatched_chatgpt_ids)
|
| 364 |
+
n_matched = len(matches)
|
| 365 |
+
n_gemini_only = len(unmatched_gemini_ids)
|
| 366 |
+
n_total = n_chatgpt_only + n_matched + n_gemini_only
|
| 367 |
+
|
| 368 |
+
# --- Venn-style overlap chart ---
|
| 369 |
+
_render_venn_chart(n_chatgpt_only, n_matched, n_gemini_only)
|
| 370 |
+
|
| 371 |
+
# --- Alignment Score gauge ---
|
| 372 |
+
alignment = float(analysis.get("nmi_score") or 0)
|
| 373 |
+
_render_alignment_gauge(alignment, n_matched, n_total)
|
| 374 |
+
|
| 375 |
+
st.markdown("---")
|
| 376 |
+
|
| 377 |
+
# --- Matched topics table ---
|
| 378 |
+
_render_matched_topics(matches)
|
| 379 |
+
|
| 380 |
+
st.markdown("---")
|
| 381 |
+
|
| 382 |
+
# --- Unmatched topics per model ---
|
| 383 |
+
_render_unmatched_topics(
|
| 384 |
+
chatgpt_clusters, gemini_clusters,
|
| 385 |
+
unmatched_chatgpt_ids, unmatched_gemini_ids,
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def _render_venn_chart(
|
| 390 |
+
n_chatgpt_only: int, n_matched: int, n_gemini_only: int,
|
| 391 |
+
):
|
| 392 |
+
"""Render Venn-style horizontal stacked bar showing overlap proportions."""
|
| 393 |
+
n_total = n_chatgpt_only + n_matched + n_gemini_only
|
| 394 |
+
if n_total == 0:
|
| 395 |
+
return
|
| 396 |
+
|
| 397 |
+
pct_chatgpt = n_chatgpt_only / n_total * 100
|
| 398 |
+
pct_matched = n_matched / n_total * 100
|
| 399 |
+
pct_gemini = n_gemini_only / n_total * 100
|
| 400 |
+
|
| 401 |
+
fig = go.Figure()
|
| 402 |
+
|
| 403 |
+
fig.add_trace(go.Bar(
|
| 404 |
+
y=["ํ ํฝ ๋ถํฌ"],
|
| 405 |
+
x=[pct_chatgpt],
|
| 406 |
+
name=f"ChatGPT ๊ณ ์ ({n_chatgpt_only})",
|
| 407 |
+
orientation="h",
|
| 408 |
+
marker_color="#3B82F6",
|
| 409 |
+
text=f"{pct_chatgpt:.0f}%",
|
| 410 |
+
textposition="inside",
|
| 411 |
+
hovertemplate=(
|
| 412 |
+
f"ChatGPT ๊ณ ์ ํ ํฝ: {n_chatgpt_only}๊ฐ<br>"
|
| 413 |
+
f"๋น์จ: {pct_chatgpt:.1f}%<extra></extra>"
|
| 414 |
+
),
|
| 415 |
+
))
|
| 416 |
+
fig.add_trace(go.Bar(
|
| 417 |
+
y=["ํ ํฝ ๋ถํฌ"],
|
| 418 |
+
x=[pct_matched],
|
| 419 |
+
name=f"๊ณตํต ๋งค์นญ ({n_matched})",
|
| 420 |
+
orientation="h",
|
| 421 |
+
marker_color="#10B981",
|
| 422 |
+
text=f"{pct_matched:.0f}%",
|
| 423 |
+
textposition="inside",
|
| 424 |
+
hovertemplate=(
|
| 425 |
+
f"๊ณตํต ๋งค์นญ ํ ํฝ: {n_matched}๊ฐ<br>"
|
| 426 |
+
f"๋น์จ: {pct_matched:.1f}%<extra></extra>"
|
| 427 |
+
),
|
| 428 |
+
))
|
| 429 |
+
fig.add_trace(go.Bar(
|
| 430 |
+
y=["ํ ํฝ ๋ถํฌ"],
|
| 431 |
+
x=[pct_gemini],
|
| 432 |
+
name=f"Gemini ๊ณ ์ ({n_gemini_only})",
|
| 433 |
+
orientation="h",
|
| 434 |
+
marker_color="#F59E0B",
|
| 435 |
+
text=f"{pct_gemini:.0f}%",
|
| 436 |
+
textposition="inside",
|
| 437 |
+
hovertemplate=(
|
| 438 |
+
f"Gemini ๊ณ ์ ํ ํฝ: {n_gemini_only}๊ฐ<br>"
|
| 439 |
+
f"๋น์จ: {pct_gemini:.1f}%<extra></extra>"
|
| 440 |
+
),
|
| 441 |
+
))
|
| 442 |
+
|
| 443 |
+
fig.update_layout(
|
| 444 |
+
barmode="stack",
|
| 445 |
+
height=120,
|
| 446 |
+
margin=dict(l=0, r=0, t=30, b=0),
|
| 447 |
+
title="ํ ํฝ ๊ฒน์นจ ๋ถํฌ (Venn)",
|
| 448 |
+
xaxis=dict(title="๋น์จ (%)", range=[0, 100]),
|
| 449 |
+
yaxis=dict(visible=False),
|
| 450 |
+
template="plotly_white",
|
| 451 |
+
legend=dict(orientation="h", yanchor="bottom", y=-0.5),
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
st.plotly_chart(fig, use_container_width=True, key="cross_model:venn", config={"displayModeBar": False})
|
| 455 |
+
|
| 456 |
+
# Summary metrics
|
| 457 |
+
c1, c2, c3 = st.columns(3)
|
| 458 |
+
with c1:
|
| 459 |
+
st.metric(
|
| 460 |
+
"ChatGPT ๊ณ ์ ",
|
| 461 |
+
f"{n_chatgpt_only}๊ฐ",
|
| 462 |
+
help="ChatGPT์์๋ง ๋ฐ๊ฒฌ๋ Demand ํ ํฝ. "
|
| 463 |
+
"์๋น์๊ฐ ๊ด์ฌ ์์ง๋ง Gemini๊ฐ ์์ง ์ธ์ฉํ์ง ์๋ ์์ญ.",
|
| 464 |
+
)
|
| 465 |
+
with c2:
|
| 466 |
+
st.metric(
|
| 467 |
+
"๊ณตํต ๋งค์นญ",
|
| 468 |
+
f"{n_matched}๊ฐ",
|
| 469 |
+
help="๋ ๋ชจ๋ธ ๋ชจ๋์์ ๋ฐ๊ฒฌ๋ ํ ํฝ. "
|
| 470 |
+
"Demand์ Supply๊ฐ ๋ง๋๋ ํต์ฌ ์์ญ.",
|
| 471 |
+
)
|
| 472 |
+
with c3:
|
| 473 |
+
st.metric(
|
| 474 |
+
"Gemini ๊ณ ์ ",
|
| 475 |
+
f"{n_gemini_only}๊ฐ",
|
| 476 |
+
help="Gemini์์๋ง ์ธ์ฉ๋๋ Supply ํ ํฝ. "
|
| 477 |
+
"AI๊ฐ ๊ทผ๊ฑฐ๋ก ์ฌ์ฉํ์ง๋ง ์๋น์ ๊ฒ์ ์์๊ฐ ๋ฎ์ ์์ญ.",
|
| 478 |
+
)
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
def _render_alignment_gauge(alignment: float, n_matched: int, n_total: int):
|
| 482 |
+
"""Render alignment score as a gauge chart with interpretation."""
|
| 483 |
+
coverage = n_matched / n_total * 100 if n_total > 0 else 0
|
| 484 |
+
|
| 485 |
+
fig = go.Figure(go.Indicator(
|
| 486 |
+
mode="gauge+number",
|
| 487 |
+
value=alignment,
|
| 488 |
+
number=dict(suffix="", valueformat=".4f"),
|
| 489 |
+
gauge=dict(
|
| 490 |
+
axis=dict(range=[0, 1], tickvals=[0, 0.3, 0.6, 0.8, 1.0]),
|
| 491 |
+
bar=dict(color="#059669"),
|
| 492 |
+
steps=[
|
| 493 |
+
dict(range=[0, 0.3], color="#FEE2E2"),
|
| 494 |
+
dict(range=[0.3, 0.6], color="#FEF3C7"),
|
| 495 |
+
dict(range=[0.6, 0.8], color="#D1FAE5"),
|
| 496 |
+
dict(range=[0.8, 1.0], color="#A7F3D0"),
|
| 497 |
+
],
|
| 498 |
+
threshold=dict(
|
| 499 |
+
line=dict(color="#059669", width=2),
|
| 500 |
+
thickness=0.75,
|
| 501 |
+
value=alignment,
|
| 502 |
+
),
|
| 503 |
+
),
|
| 504 |
+
title=dict(text="Alignment Score"),
|
| 505 |
+
))
|
| 506 |
+
|
| 507 |
+
fig.update_layout(
|
| 508 |
+
height=250,
|
| 509 |
+
margin=dict(l=30, r=30, t=50, b=10),
|
| 510 |
+
template="plotly_white",
|
| 511 |
+
)
|
| 512 |
+
|
| 513 |
+
left, right = st.columns([2, 1])
|
| 514 |
+
with left:
|
| 515 |
+
st.plotly_chart(fig, use_container_width=True, key="cross_model:gauge", config={"displayModeBar": False})
|
| 516 |
+
with right:
|
| 517 |
+
if alignment >= 0.8:
|
| 518 |
+
st.success(
|
| 519 |
+
f"**Strong** โ ๋ ๋ชจ๋ธ์ด ๋งค์ฐ ์ ์ฌํ ํ ํฝ์ ๋ค๋ฃจ๊ณ ์์ต๋๋ค. "
|
| 520 |
+
f"Gap ๋ถ์์ ์ ๋ขฐ๋๊ฐ ๋์ต๋๋ค."
|
| 521 |
+
)
|
| 522 |
+
elif alignment >= 0.6:
|
| 523 |
+
st.warning(
|
| 524 |
+
f"**Moderate** โ ๋ถ๋ถ์ ์ผ๋ก ๊ฒน์น๋ ํ ํฝ์ด ์์ต๋๋ค. "
|
| 525 |
+
f"Gap ๋ถ์์ ์ฐธ๊ณ ์ฉ์ผ๋ก ํ์ฉํ์ธ์."
|
| 526 |
+
)
|
| 527 |
+
else:
|
| 528 |
+
st.error(
|
| 529 |
+
f"**Weak** โ ๋ ๋ชจ๋ธ์ ํ ํฝ ์ ์ฌ๋๊ฐ ๋ฎ์ต๋๋ค. "
|
| 530 |
+
f"๊ฐ ๋ชจ๋ธ์ ๊ฐ๋ณ ๋ทฐ๋ฅผ ์ฐ์ ์ฐธ๊ณ ํ์ธ์."
|
| 531 |
+
)
|
| 532 |
+
st.caption(f"ํ ํฝ ์ปค๋ฒ๋ฆฌ์ง: {coverage:.1f}% ({n_matched}/{n_total})")
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
def _render_matched_topics(matches: list[dict]):
|
| 536 |
+
"""Render matched topic pairs table with scores."""
|
| 537 |
+
st.markdown("#### ๋งค์นญ๋ ํ ํฝ ์")
|
| 538 |
+
st.caption(
|
| 539 |
+
"๋ ๋ชจ๋ธ์์ ๋์ผํ ์ฃผ์ ๋ก ๋งค์นญ๋ ํ ํฝ์
๋๋ค. "
|
| 540 |
+
"Match Score๊ฐ ๋์์๋ก ๋ ํ ํฝ์ ์ ์ฌ๋๊ฐ ๋์ต๋๋ค."
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
+
rows = []
|
| 544 |
+
for i, m in enumerate(matches, 1):
|
| 545 |
+
rows.append({
|
| 546 |
+
"#": i,
|
| 547 |
+
"ChatGPT ํ ํฝ": (m.get("chatgpt_label") or "")[:35],
|
| 548 |
+
"Gemini ํ ํฝ": (m.get("gemini_label") or "")[:35],
|
| 549 |
+
"Match Score": f"{float(m.get('match_score') or 0):.4f}",
|
| 550 |
+
"Label Sim": f"{float(m.get('label_similarity') or 0):.4f}",
|
| 551 |
+
"Centroid Sim": f"{float(m.get('centroid_similarity') or 0):.4f}",
|
| 552 |
+
"GapScore": f"{float(m.get('gap_score') or 0):.4f}",
|
| 553 |
+
"Quadrant": QUADRANT_LABELS.get(m.get("quadrant", "NICHE"), "Niche"),
|
| 554 |
+
})
|
| 555 |
+
|
| 556 |
+
if rows:
|
| 557 |
+
df = pd.DataFrame(rows)
|
| 558 |
+
st.dataframe(df, use_container_width=True, hide_index=True)
|
| 559 |
+
|
| 560 |
+
# Match quality stats
|
| 561 |
+
if matches:
|
| 562 |
+
scores = [float(m.get("match_score") or 0) for m in matches]
|
| 563 |
+
avg_score = sum(scores) / len(scores)
|
| 564 |
+
min_score = min(scores)
|
| 565 |
+
max_score = max(scores)
|
| 566 |
+
st.caption(
|
| 567 |
+
f"Match Score โ ํ๊ท : {avg_score:.4f} | "
|
| 568 |
+
f"์ต์: {min_score:.4f} | ์ต๋: {max_score:.4f}"
|
| 569 |
+
)
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
def _render_unmatched_topics(
|
| 573 |
+
chatgpt_clusters: list[dict],
|
| 574 |
+
gemini_clusters: list[dict],
|
| 575 |
+
unmatched_chatgpt_ids: set,
|
| 576 |
+
unmatched_gemini_ids: set,
|
| 577 |
+
):
|
| 578 |
+
"""Render unmatched (model-specific) topics."""
|
| 579 |
+
st.markdown("#### ๋ชจ๋ธ๋ณ ๊ณ ์ ํ ํฝ")
|
| 580 |
+
st.caption(
|
| 581 |
+
"ํ์ชฝ ๋ชจ๋ธ์์๋ง ๋ํ๋๋ ํ ํฝ์
๋๋ค. "
|
| 582 |
+
"๋งค์นญ๋์ง ์์ ํ ํฝ์ ํด๋น ๋ชจ๋ธ ๊ณ ์ ์ ์ ํธ๋ฅผ ๋ํ๋
๋๋ค."
|
| 583 |
+
)
|
| 584 |
+
|
| 585 |
+
left, right = st.columns(2)
|
| 586 |
+
|
| 587 |
+
with left:
|
| 588 |
+
st.markdown("**ChatGPT ๊ณ ์ ํ ํฝ (Demand Only)**")
|
| 589 |
+
st.caption(
|
| 590 |
+
"์๋น์๊ฐ ๊ด์ฌ ์์ง๋ง Gemini๊ฐ ์ธ์ฉํ์ง ์๋ ํ ํฝ. "
|
| 591 |
+
"์์ง ์ฝํ
์ธ ๊ฐ ๋ถ์กฑํ์ฌ AI๊ฐ ๊ทผ๊ฑฐ๋ฅผ ์ฐพ์ง ๋ชปํ๋ ์์ญ์ผ ์ ์์ต๋๋ค."
|
| 592 |
+
)
|
| 593 |
+
|
| 594 |
+
unmatched_chatgpt = [
|
| 595 |
+
c for c in chatgpt_clusters if c["id"] in unmatched_chatgpt_ids
|
| 596 |
+
]
|
| 597 |
+
# Sort by opportunity_score DESC (already sorted from DB, but filter may reorder)
|
| 598 |
+
unmatched_chatgpt.sort(
|
| 599 |
+
key=lambda c: float(c.get("opportunity_score") or 0), reverse=True,
|
| 600 |
+
)
|
| 601 |
+
|
| 602 |
+
if unmatched_chatgpt:
|
| 603 |
+
rows = []
|
| 604 |
+
for c in unmatched_chatgpt[:20]:
|
| 605 |
+
rows.append({
|
| 606 |
+
"ํ ํฝ": (c.get("cluster_label") or f"Cluster-{c['id'][:8]}")[:30],
|
| 607 |
+
"Opportunity": f"{float(c.get('opportunity_score') or 0):.4f}",
|
| 608 |
+
"Attention": f"{float(c.get('attention_score') or 0):.4f}",
|
| 609 |
+
"Fanouts": c.get("fanout_count", 0),
|
| 610 |
+
})
|
| 611 |
+
st.dataframe(
|
| 612 |
+
pd.DataFrame(rows),
|
| 613 |
+
use_container_width=True,
|
| 614 |
+
hide_index=True,
|
| 615 |
+
)
|
| 616 |
+
if len(unmatched_chatgpt) > 20:
|
| 617 |
+
st.caption(f"... ์ธ {len(unmatched_chatgpt) - 20}๊ฐ")
|
| 618 |
+
else:
|
| 619 |
+
st.info("๋ชจ๋ ChatGPT ํ ํฝ์ด Gemini์ ๋งค์นญ๋์์ต๋๋ค.")
|
| 620 |
+
|
| 621 |
+
with right:
|
| 622 |
+
st.markdown("**Gemini ๊ณ ์ ํ ํฝ (Supply Only)**")
|
| 623 |
+
st.caption(
|
| 624 |
+
"AI๊ฐ ์ธ์ฉํ์ง๋ง ์๋น์ ๊ฒ์ ์์๊ฐ ๋ฎ์ ํ ํฝ. "
|
| 625 |
+
"์ ์ฌ์ ๊ถ์(Latent Authority) ์์ญ์ด๊ฑฐ๋, ํฅํ ์์๊ฐ ์ฆ๊ฐํ ์ ์์ต๋๋ค."
|
| 626 |
+
)
|
| 627 |
+
|
| 628 |
+
unmatched_gemini = [
|
| 629 |
+
c for c in gemini_clusters if c["id"] in unmatched_gemini_ids
|
| 630 |
+
]
|
| 631 |
+
unmatched_gemini.sort(
|
| 632 |
+
key=lambda c: float(c.get("opportunity_score") or 0), reverse=True,
|
| 633 |
+
)
|
| 634 |
+
|
| 635 |
+
if unmatched_gemini:
|
| 636 |
+
rows = []
|
| 637 |
+
for c in unmatched_gemini[:20]:
|
| 638 |
+
rows.append({
|
| 639 |
+
"ํ ํฝ": (c.get("cluster_label") or f"Cluster-{c['id'][:8]}")[:30],
|
| 640 |
+
"Opportunity": f"{float(c.get('opportunity_score') or 0):.4f}",
|
| 641 |
+
"Density": f"{float(c.get('citation_density') or 0):.4f}",
|
| 642 |
+
"Citations": c.get("fanout_count", 0),
|
| 643 |
+
})
|
| 644 |
+
st.dataframe(
|
| 645 |
+
pd.DataFrame(rows),
|
| 646 |
+
use_container_width=True,
|
| 647 |
+
hide_index=True,
|
| 648 |
+
)
|
| 649 |
+
if len(unmatched_gemini) > 20:
|
| 650 |
+
st.caption(f"... ์ธ {len(unmatched_gemini) - 20}๊ฐ")
|
| 651 |
+
else:
|
| 652 |
+
st.info("๋ชจ๋ Gemini ํ ํฝ์ด ChatGPT์ ๋งค์นญ๋์์ต๋๋ค.")
|
features/research/distribution.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Attention vs Density 4-Quadrant Scatter.
|
| 2 |
+
|
| 3 |
+
X-axis: attention_score
|
| 4 |
+
Y-axis: citation_density
|
| 5 |
+
Quadrant lines at median values.
|
| 6 |
+
Point size: fanout_count, hover: cluster_label.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import statistics
|
| 10 |
+
|
| 11 |
+
import streamlit as st
|
| 12 |
+
import plotly.graph_objects as go
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# Quadrant colors
|
| 16 |
+
QUAD_COLORS = {
|
| 17 |
+
"high_attn_low_density": "#10B981", # Green - Opportunity
|
| 18 |
+
"high_attn_high_density": "#3B82F6", # Blue - Competitive
|
| 19 |
+
"low_attn_low_density": "#9CA3AF", # Gray - Niche
|
| 20 |
+
"low_attn_high_density": "#EF4444", # Red - Crowded
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def render_distribution(clusters: list[dict], frame: str = "all"):
|
| 25 |
+
"""Render attention vs density quadrant scatter chart."""
|
| 26 |
+
scored = [
|
| 27 |
+
c for c in clusters
|
| 28 |
+
if c.get("attention_score") is not None
|
| 29 |
+
and c.get("citation_density") is not None
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
if not scored:
|
| 33 |
+
st.info("์ค์ฝ์ด๊ฐ ๊ณ์ฐ๋ ํด๋ฌ์คํฐ๊ฐ ์์ต๋๋ค.")
|
| 34 |
+
return
|
| 35 |
+
|
| 36 |
+
# 4-Quadrant business interpretation (frame-specific)
|
| 37 |
+
if frame == "demand":
|
| 38 |
+
st.markdown("""
|
| 39 |
+
ChatGPT Demand ํ ํฝ์ **๊ฒ์ ๋น๋**(๊ฐ๋ก์ถ)์ **์ถ์ฒ ๊ฒฝ์**(์ธ๋ก์ถ)๋ก ๋ถ๋ฅํฉ๋๋ค:
|
| 40 |
+
- **Opportunity** (์ฐํ๋จ): ๊ฒ์ ๋น๋ ๋์ + ๊ฒฝ์ ๋ฎ์ โ ์ฝํ
์ธ ์ ์ ๊ธฐํ
|
| 41 |
+
- **Competitive** (์ฐ์๋จ): ๊ฒ์ ๋น๋ ๋์ + ๊ฒฝ์ ๋์ โ ์ฐจ๋ณํ ํ์
|
| 42 |
+
- **Niche** (์ขํ๋จ): ๊ฒ์ ๋น๋ ๋ฎ์ + ๊ฒฝ์ ๋ฎ์ โ ํ์ ์์ญ
|
| 43 |
+
- **Crowded** (์ข์๋จ): ๊ฒ์ ๋น๋ ๋ฎ์ + ๊ฒฝ์ ๋์ โ ํฌํ ์์ญ
|
| 44 |
+
""")
|
| 45 |
+
elif frame == "supply":
|
| 46 |
+
st.markdown("""
|
| 47 |
+
Gemini Supply ํ ํฝ์ **์ธ์ฉ ๋น๋**(๊ฐ๋ก์ถ)์ **์ถ์ฒ ์ง์ค๋**(์ธ๋ก์ถ)๋ก ๋ถ๋ฅํฉ๋๋ค:
|
| 48 |
+
- **Opportunity** (์ฐํ๋จ): ์ธ์ฉ ๋น๋ ๋์ + ์ถ์ฒ ๋ถ์ฐ โ ์ ์ถ์ฒ ์ง์
๊ธฐํ
|
| 49 |
+
- **Competitive** (์ฐ์๋จ): ์ธ์ฉ ๋น๋ ๋์ + ์ถ์ฒ ์ง์ค โ ๊ธฐ์กด ๊ถ์์ ์ง๋ฐฐ
|
| 50 |
+
- **Niche** (์ขํ๋จ): ์ธ์ฉ ๋น๋ ๋ฎ์ + ์ถ์ฒ ๋ถ์ฐ โ ํ์ ์์ญ
|
| 51 |
+
- **Crowded** (์ข์๋จ): ์ธ์ฉ ๋น๋ ๋ฎ์ + ์ถ์ฒ ์ง์ค โ ํฌํ ์์ญ
|
| 52 |
+
""")
|
| 53 |
+
else:
|
| 54 |
+
st.markdown("""
|
| 55 |
+
ํ ํฝ์ **AI ๊ด์ฌ๋**(๊ฐ๋ก์ถ)์ **๊ฒฝ์ ๋ฐ๋**(์ธ๋ก์ถ)๋ก ๋ถ๋ฅํฉ๋๋ค:
|
| 56 |
+
- **Opportunity** (์ฐํ๋จ): AI ๊ด์ฌ ๋์ + ๊ฒฝ์ ๋ฎ์ โ ์ฝํ
์ธ ์ ์ ๊ธฐํ
|
| 57 |
+
- **Competitive** (์ฐ์๋จ): AI ๊ด์ฌ ๋์ + ๊ฒฝ์ ๋์ โ ์ฐจ๋ณํ ํ์
|
| 58 |
+
- **Niche** (์ขํ๋จ): AI ๊ด์ฌ ๋ฎ์ + ๊ฒฝ์ ๋ฎ์ โ ํ์ ์์ญ
|
| 59 |
+
- **Crowded** (์ข์๋จ): AI ๊ด์ฌ ๋ฎ์ + ๊ฒฝ์ ๋์ โ ํฌํ ์์ญ
|
| 60 |
+
""")
|
| 61 |
+
|
| 62 |
+
attns = [float(c["attention_score"]) for c in scored]
|
| 63 |
+
densities = [float(c["citation_density"]) for c in scored]
|
| 64 |
+
|
| 65 |
+
median_attn = statistics.median(attns)
|
| 66 |
+
median_density = statistics.median(densities)
|
| 67 |
+
|
| 68 |
+
# Classify each point into quadrant
|
| 69 |
+
xs, ys, sizes, colors, hover_texts = [], [], [], [], []
|
| 70 |
+
quadrant_counts = {"opportunity": 0, "competitive": 0, "niche": 0, "crowded": 0}
|
| 71 |
+
|
| 72 |
+
for c in scored:
|
| 73 |
+
attn = float(c["attention_score"])
|
| 74 |
+
density = float(c["citation_density"])
|
| 75 |
+
fanout_count = c.get("fanout_count", 10)
|
| 76 |
+
label = c.get("cluster_label") or f"Cluster-{c['id'][:8]}"
|
| 77 |
+
|
| 78 |
+
xs.append(attn)
|
| 79 |
+
ys.append(density)
|
| 80 |
+
sizes.append(max(5, min(35, fanout_count / 5)))
|
| 81 |
+
|
| 82 |
+
if attn >= median_attn and density < median_density:
|
| 83 |
+
color = QUAD_COLORS["high_attn_low_density"]
|
| 84 |
+
quad = "Opportunity"
|
| 85 |
+
quadrant_counts["opportunity"] += 1
|
| 86 |
+
elif attn >= median_attn and density >= median_density:
|
| 87 |
+
color = QUAD_COLORS["high_attn_high_density"]
|
| 88 |
+
quad = "Competitive"
|
| 89 |
+
quadrant_counts["competitive"] += 1
|
| 90 |
+
elif attn < median_attn and density < median_density:
|
| 91 |
+
color = QUAD_COLORS["low_attn_low_density"]
|
| 92 |
+
quad = "Niche"
|
| 93 |
+
quadrant_counts["niche"] += 1
|
| 94 |
+
else:
|
| 95 |
+
color = QUAD_COLORS["low_attn_high_density"]
|
| 96 |
+
quad = "Crowded"
|
| 97 |
+
quadrant_counts["crowded"] += 1
|
| 98 |
+
|
| 99 |
+
colors.append(color)
|
| 100 |
+
opp = float(c.get("opportunity_score", 0) or 0)
|
| 101 |
+
hover_texts.append(
|
| 102 |
+
f"<b>{label}</b><br>"
|
| 103 |
+
f"Attention: {attn:.4f}<br>"
|
| 104 |
+
f"Density: {density:.4f}<br>"
|
| 105 |
+
f"Opportunity: {opp:.4f}<br>"
|
| 106 |
+
f"Fanouts: {fanout_count}<br>"
|
| 107 |
+
f"Quadrant: {quad}"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
fig = go.Figure()
|
| 111 |
+
|
| 112 |
+
# Data points
|
| 113 |
+
fig.add_trace(go.Scatter(
|
| 114 |
+
x=xs,
|
| 115 |
+
y=ys,
|
| 116 |
+
mode="markers",
|
| 117 |
+
marker=dict(
|
| 118 |
+
size=sizes,
|
| 119 |
+
color=colors,
|
| 120 |
+
opacity=0.7,
|
| 121 |
+
line=dict(width=0.5, color="#333"),
|
| 122 |
+
),
|
| 123 |
+
text=hover_texts,
|
| 124 |
+
hoverinfo="text",
|
| 125 |
+
showlegend=False,
|
| 126 |
+
))
|
| 127 |
+
|
| 128 |
+
# Quadrant lines (add padding to avoid collapse when all values are identical)
|
| 129 |
+
attn_span = max(attns) - min(attns) or 0.001
|
| 130 |
+
density_span = max(densities) - min(densities) or 0.1
|
| 131 |
+
x_range = [min(attns) - attn_span * 0.1, max(attns) + attn_span * 0.1]
|
| 132 |
+
y_range = [min(densities) - density_span * 0.1, max(densities) + density_span * 0.1]
|
| 133 |
+
|
| 134 |
+
fig.add_hline(y=median_density, line_dash="dash", line_color="#9CA3AF", opacity=0.5)
|
| 135 |
+
fig.add_vline(x=median_attn, line_dash="dash", line_color="#9CA3AF", opacity=0.5)
|
| 136 |
+
|
| 137 |
+
# Quadrant labels
|
| 138 |
+
fig.add_annotation(x=x_range[1], y=y_range[0], text="๐ข Opportunity",
|
| 139 |
+
showarrow=False, font=dict(size=11, color=QUAD_COLORS["high_attn_low_density"]))
|
| 140 |
+
fig.add_annotation(x=x_range[1], y=y_range[1], text="๐ต Competitive",
|
| 141 |
+
showarrow=False, font=dict(size=11, color=QUAD_COLORS["high_attn_high_density"]))
|
| 142 |
+
fig.add_annotation(x=x_range[0], y=y_range[0], text="โช Niche",
|
| 143 |
+
showarrow=False, font=dict(size=11, color=QUAD_COLORS["low_attn_low_density"]))
|
| 144 |
+
fig.add_annotation(x=x_range[0], y=y_range[1], text="๐ด Crowded",
|
| 145 |
+
showarrow=False, font=dict(size=11, color=QUAD_COLORS["low_attn_high_density"]))
|
| 146 |
+
|
| 147 |
+
fig.update_layout(
|
| 148 |
+
title="Attention vs Citation Density (4-Quadrant)",
|
| 149 |
+
xaxis_title="Attention Score",
|
| 150 |
+
yaxis_title="Citation Density",
|
| 151 |
+
height=600,
|
| 152 |
+
template="plotly_white",
|
| 153 |
+
hoverlabel=dict(bgcolor="white", font_size=12),
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
|
| 157 |
+
|
| 158 |
+
# Quadrant summary with action guides
|
| 159 |
+
st.markdown("#### Quadrant ์์ฝ")
|
| 160 |
+
q1, q2, q3, q4 = st.columns(4)
|
| 161 |
+
with q1:
|
| 162 |
+
st.metric(
|
| 163 |
+
"๐ข Opportunity", f"{quadrant_counts['opportunity']}๊ฐ",
|
| 164 |
+
help="AI ๊ด์ฌ๋ ๋์ + ๊ฒฝ์ ๋ฎ์ โ ์ฝํ
์ธ ์ ์ ๊ธฐํ. "
|
| 165 |
+
"์ด ํ ํฝ์ ๋ํ ์ ๋ฌธ ์ฝํ
์ธ ๋ฅผ ์ ์ํ๋ฉด AI ๋ต๋ณ์ ์ธ์ฉ๋ ๊ฐ๋ฅ์ฑ์ด ๋์ต๋๋ค.",
|
| 166 |
+
)
|
| 167 |
+
with q2:
|
| 168 |
+
st.metric(
|
| 169 |
+
"๐ต Competitive", f"{quadrant_counts['competitive']}๊ฐ",
|
| 170 |
+
help="AI ๊ด์ฌ๋ ๋์ + ๊ฒฝ์ ๋์ โ ๊ฒฝ์ ์น์ด. "
|
| 171 |
+
"์ฐจ๋ณํ๋ ์ฝํ
์ธ ๋ ์ ๋ฌธ์ฑ์ด ํ์ํฉ๋๋ค.",
|
| 172 |
+
)
|
| 173 |
+
with q3:
|
| 174 |
+
st.metric(
|
| 175 |
+
"โช Niche", f"{quadrant_counts['niche']}๊ฐ",
|
| 176 |
+
help="AI ๊ด์ฌ๋ ๋ฎ์ + ๊ฒฝ์ ๋ฎ์ โ ํ์ ์์ญ. "
|
| 177 |
+
"์์ฅ์ด ์ฑ์ฅํ๋ฉด ์ ์ ํจ๊ณผ๋ฅผ ๋ณผ ์ ์์ต๋๋ค.",
|
| 178 |
+
)
|
| 179 |
+
with q4:
|
| 180 |
+
st.metric(
|
| 181 |
+
"๐ด Crowded", f"{quadrant_counts['crowded']}๊ฐ",
|
| 182 |
+
help="AI ๊ด์ฌ๋ ๋ฎ์ + ๊ฒฝ์ ๋์ โ ํฌํ ์์ญ. "
|
| 183 |
+
"์๋ก์ด ์ง์ถ๋ณด๋ค ๊ธฐ์กด ์ฝํ
์ธ ์ ์ง์ ์ง์คํ์ธ์.",
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
st.caption(f"Median Attention: {median_attn:.4f} | Median Density: {median_density:.4f}")
|
features/research/guide.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""๋ถ์ ๊ฐ์ด๋ - ํ ํฝ ์ธํ
๋ฆฌ์ ์ค ๋ฐ์ดํฐ ํ๋ฆ ์ค๋ช
.
|
| 2 |
+
|
| 3 |
+
๋น๊ธฐ์ ์ฌ์ฉ์๋ฅผ ์ํ ํ์ดํ๋ผ์ธ, ์ ์ ๊ณ์ฐ, ์ฉ์ด, FAQ.
|
| 4 |
+
v9.0 ์ ๊ท. (renamed from data_flow.py)
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import streamlit as st
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def render_data_flow():
|
| 11 |
+
"""ํ ํฝ ์ธํ
๋ฆฌ์ ์ค ๋ฐ์ดํฐ ํ๋ฆ ๊ฐ์ด๋."""
|
| 12 |
+
|
| 13 |
+
st.markdown("### ํ ํฝ ์ธํ
๋ฆฌ์ ์ค ๋ถ์ ๊ฐ์ด๋")
|
| 14 |
+
st.caption("์ด ํ์ด์ง๋ ํ ํฝ ์ธํ
๋ฆฌ์ ์ค์ ์๋ ๋ฐฉ์๊ณผ ์ฃผ์ ์งํ๋ฅผ ์ค๋ช
ํฉ๋๋ค.")
|
| 15 |
+
|
| 16 |
+
# 1. Pipeline diagram
|
| 17 |
+
st.markdown("#### 1. ๋ฐ์ดํฐ ํ์ดํ๋ผ์ธ")
|
| 18 |
+
st.markdown("""
|
| 19 |
+
```
|
| 20 |
+
์ฌ์ฉ์๊ฐ AI์ ์ง๋ฌธ
|
| 21 |
+
โ
|
| 22 |
+
AI๊ฐ ๋ด๋ถ์ ์ผ๋ก ์ถ๊ฐ ์ง๋ฌธ(Fanout) ์์ฑ
|
| 23 |
+
์: "best moisturizer for dry skin"
|
| 24 |
+
โ
|
| 25 |
+
AI๊ฐ ์ถ๊ฐ ์ง๋ฌธ๋ณ๋ก ์น์ ๊ฒ์ํ๊ณ ์ถ์ฒ๋ฅผ ์ธ์ฉ
|
| 26 |
+
โ
|
| 27 |
+
AI๊ฐ ์ข
ํฉํ์ฌ ์ต์ข
๋ต๋ณ ์์ฑ
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
์ ํฌ๋ ์ด ๊ณผ์ ์์ ์์ฑ๋ **์ถ๊ฐ ์ง๋ฌธ**๊ณผ **์ธ์ฉ ์ถ์ฒ**๋ฅผ ์์งํ์ฌ ๋ถ์ํฉ๋๋ค.
|
| 31 |
+
|
| 32 |
+
```
|
| 33 |
+
์์ง๋ ์ถ๊ฐ ์ง๋ฌธ๋ค
|
| 34 |
+
โ
|
| 35 |
+
์ ์ฌํ ์ง๋ฌธ๋ผ๋ฆฌ ์๋ ๊ทธ๋ฃนํ (ํด๋ฌ์คํฐ๋ง)
|
| 36 |
+
โ ๊ฐ ๊ทธ๋ฃน = ํ๋์ "ํ ํฝ"
|
| 37 |
+
โ
|
| 38 |
+
๊ฐ ํ ํฝ๋ณ ์ ์ ๊ณ์ฐ
|
| 39 |
+
โ AI ๊ด์ฌ๋, ๊ฒฝ์ ๋ฐ๋, ๊ธฐํ ์ ์
|
| 40 |
+
โ
|
| 41 |
+
ํ ํฝ ๋งต / ๊ธฐํ ์์ญ / ๋ถํฌ ์๊ฐํ
|
| 42 |
+
```
|
| 43 |
+
""")
|
| 44 |
+
|
| 45 |
+
st.markdown("---")
|
| 46 |
+
|
| 47 |
+
# 2. Score explanations
|
| 48 |
+
st.markdown("#### 2. ์ ์ ๊ณ์ฐ ๋ฐฉ๋ฒ")
|
| 49 |
+
|
| 50 |
+
score_col1, score_col2, score_col3 = st.columns(3)
|
| 51 |
+
|
| 52 |
+
with score_col1:
|
| 53 |
+
st.markdown("""
|
| 54 |
+
**AI ๊ด์ฌ๋ (Attention)**
|
| 55 |
+
|
| 56 |
+
ํด๋น ํ ํฝ์ ์ถ๊ฐ ์ง๋ฌธ ์๊ฐ ์ ์ฒด์์
|
| 57 |
+
์ฐจ์งํ๋ ๋น์ค์
๋๋ค.
|
| 58 |
+
|
| 59 |
+
`ํ ํฝ์ ์ถ๊ฐ ์ง๋ฌธ ์ / ์ ์ฒด ์ถ๊ฐ ์ง๋ฌธ ์`
|
| 60 |
+
|
| 61 |
+
๊ฐ์ด ๋์์๋ก AI๊ฐ ์ด ์ฃผ์ ์ ๋ํด
|
| 62 |
+
์์ฃผ ์ง๋ฌธํ๋ค๋ ์๋ฏธ์
๋๋ค.
|
| 63 |
+
""")
|
| 64 |
+
|
| 65 |
+
with score_col2:
|
| 66 |
+
st.markdown("""
|
| 67 |
+
**๊ฒฝ์ ๋ฐ๋ (Density)**
|
| 68 |
+
|
| 69 |
+
ํด๋น ํ ํฝ์์ AI๊ฐ ์ธ์ฉํ๋
|
| 70 |
+
ํ๊ท ์ถ์ฒ ์์
๋๋ค.
|
| 71 |
+
|
| 72 |
+
๊ฐ์ด ๋์์๋ก ์ด๋ฏธ ๋ง์ ์น์ฌ์ดํธ๊ฐ
|
| 73 |
+
์ด ์ฃผ์ ์ ๋ํ ์ฝํ
์ธ ๋ฅผ ๊ฐ๊ณ ์์ด
|
| 74 |
+
๊ฒฝ์์ด ์น์ดํ๋ค๋ ์๋ฏธ์
๋๋ค.
|
| 75 |
+
""")
|
| 76 |
+
|
| 77 |
+
with score_col3:
|
| 78 |
+
st.markdown("""
|
| 79 |
+
**๊ธฐํ ์ ์ (Opportunity)**
|
| 80 |
+
|
| 81 |
+
AI ๊ด์ฌ๋์ ๊ฒฝ์ ๋ฐ๋๋ฅผ ๊ฒฐํฉํ
|
| 82 |
+
์ข
ํฉ ์งํ์
๋๋ค.
|
| 83 |
+
|
| 84 |
+
`AI ๊ด์ฌ๋ x (1 - ๊ฒฝ์ ๋ฐ๋)`
|
| 85 |
+
|
| 86 |
+
**๊ด์ฌ์ ๋์ง๋ง ๊ฒฝ์์ด ๋ฎ์** ์์ญ์
|
| 87 |
+
์ฐพ์์ค๋๋ค.
|
| 88 |
+
""")
|
| 89 |
+
|
| 90 |
+
st.markdown("---")
|
| 91 |
+
|
| 92 |
+
# 3. Glossary
|
| 93 |
+
st.markdown("#### 3. ์ฉ์ด ์ฌ์ ")
|
| 94 |
+
|
| 95 |
+
glossary = {
|
| 96 |
+
"Fanout (์ถ๊ฐ ์ง๋ฌธ)": "AI๊ฐ ๋ต๋ณ์ ์์ฑํ๊ธฐ ์ํด ๋ด๋ถ์ ์ผ๋ก ๋ง๋๋ ์ธ๋ถ ์ง๋ฌธ. ์: ์ฌ์ฉ์๊ฐ '์ข์ ์ ํฌ๋ฆผ ์ถ์ฒํด์ค'๋ผ๊ณ ๋ฌผ์ผ๋ฉด AI๋ 'best sunscreen for sensitive skin', 'sunscreen SPF comparison' ๋ฑ์ ์ถ๊ฐ ์ง๋ฌธ์ ์์ฑํฉ๋๋ค.",
|
| 97 |
+
"Cluster (ํ ํฝ ๊ทธ๋ฃน)": "์ ์ฌํ ์ถ๊ฐ ์ง๋ฌธ๋ค์ AI๊ฐ ์๋์ผ๋ก ๋ฌถ์ ๊ฒ. ํ๋์ ํด๋ฌ์คํฐ = ํ๋์ ํ ํฝ.",
|
| 98 |
+
"Citation (์ธ์ฉ ์ถ์ฒ)": "AI๊ฐ ๋ต๋ณ์์ ์ฐธ์กฐํ ์น ํ์ด์ง. ํน์ ๋๋ฉ์ธ์ด ์์ฃผ ์ธ์ฉ๋๋ฉด ํด๋น ์ฃผ์ ์ ๊ถ์ ์๋ ์ถ์ฒ๋ก ์ธ์๋จ.",
|
| 99 |
+
"UMAP (ํ ํฝ ๋งต)": "๊ณ ์ฐจ์ ๋ฐ์ดํฐ๋ฅผ 2D๋ก ํฌ์ํ์ฌ ํ ํฝ ๊ฐ ์ ์ฌ๋๋ฅผ ์๊ฐ์ ์ผ๋ก ๋ณด์ฌ์ฃผ๋ ๊ธฐ๋ฒ. ๊ฐ๊น์ด ์ = ์ ์ฌํ ํ ํฝ.",
|
| 100 |
+
"Quadrant (4๋ถ๋ฉด)": "AI ๊ด์ฌ๋์ ๊ฒฝ์ ๋ฐ๋๋ฅผ ๊ธฐ์ค์ผ๋ก ํ ํฝ์ 4๊ฐ ์์ญ์ผ๋ก ๋ถ๋ฅํ ๊ฒ.",
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
for term, definition in glossary.items():
|
| 104 |
+
st.markdown(f"**{term}**")
|
| 105 |
+
st.markdown(f"> {definition}")
|
| 106 |
+
st.markdown("")
|
| 107 |
+
|
| 108 |
+
st.markdown("---")
|
| 109 |
+
|
| 110 |
+
# 4. FAQ
|
| 111 |
+
st.markdown("#### 4. ์์ฃผ ๋ฌป๋ ์ง๋ฌธ")
|
| 112 |
+
|
| 113 |
+
with st.expander("์ Top 10๋ง ์์ธ ๋ถ์ํ๋์?"):
|
| 114 |
+
st.markdown(
|
| 115 |
+
"๊ธฐํ ์ ์ ์์ 10๊ฐ ํ ํฝ์ด ์ค์ง์ ์ผ๋ก ์ฝํ
์ธ ์ ์ ์ฐ์ ์์๊ฐ ๊ฐ์ฅ ๋์ ์์ญ์
๋๋ค. "
|
| 116 |
+
"์ ์ฒด ๋ญํน ํ
์ด๋ธ์์๋ 50์๊น์ง ํ์ธํ ์ ์์ต๋๋ค."
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
with st.expander("์ธ์ฉ ์ถ์ฒ ๊ทธ๋ํ๊ฐ ์๋ ํ ํฝ์ ๋ฌด์์ธ๊ฐ์?"):
|
| 120 |
+
st.markdown(
|
| 121 |
+
"AI๊ฐ ํด๋น ํ ํฝ์์ ์์ง ํน์ ์น์ฌ์ดํธ๋ฅผ ์ธ์ฉํ์ง ์๊ณ ์๋ค๋ ์๋ฏธ์
๋๋ค. "
|
| 122 |
+
"์ด๋ ๊ฒฝ์์๊ฐ ๊ฑฐ์ ์๋ค๋ ๋ป์ด๋ฏ๋ก, **์ฝํ
์ธ ์ ์ ๊ธฐํ๊ฐ ๋์ฑ ํฐ** ์์ญ์
๋๋ค."
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
with st.expander("์ ์๊ฐ ๋งค์ฐ ๋ฎ์ ํ ํฝ์ ๋ฌด์ํด๋ ๋๋์?"):
|
| 126 |
+
st.markdown(
|
| 127 |
+
"์ ์๋ ์ ์ฒด ํ ํฝ ๋๋น ์๋์ ๋น์ค์
๋๋ค. "
|
| 128 |
+
"์๋ฅผ ๋ค์ด 481๊ฐ ํ ํฝ ์ค ํ๋์ Attention์ด 0.001์ด๋ฉด ์ ์ฒด์ 0.1%๋ฅผ ์ฐจ์งํ๋ ๊ฒ์
๋๋ค. "
|
| 129 |
+
"์ ๋์ ์ผ๋ก ๋ฎ์ ๋ณด์ฌ๋ ํด๋น ๋์น์์๋ ์ถฉ๋ถํ ์๋ฏธ ์์ ์ ์์ต๋๋ค."
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
with st.expander("ํ ํฝ ๋งต์์ ์ ๋ค์ด ๋ญ์ณ ์์ผ๋ฉด ๋ฌด์์ ์๋ฏธํ๋์?"):
|
| 133 |
+
st.markdown(
|
| 134 |
+
"์๋ก ๊ฐ๊น์ด ์๋ ์ (ํ ํฝ)๋ค์ ์๋ฏธ์ ์ผ๋ก ์ ์ฌํ ์ฃผ์ ์
๋๋ค. "
|
| 135 |
+
"๋ญ์ณ ์๋ ํ ํฝ ๊ทธ๋ฃน์ ํ๋์ ํฐ ์ฃผ์ ์์ญ์ ๋ํ๋ด๋ฉฐ, "
|
| 136 |
+
"์ด ์์ญ ์ ์ฒด์ ๋ํ ์ข
ํฉ์ ์ธ ๏ฟฝ๏ฟฝํ
์ธ ์ ๋ต์ ์๋ฆฝํ๋ฉด ํจ๊ณผ์ ์
๋๋ค."
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
with st.expander("๋ฐ์ดํฐ๋ ์ผ๋ง๋ ์์ฃผ ์
๋ฐ์ดํธ๋๋์?"):
|
| 140 |
+
st.markdown(
|
| 141 |
+
"์บ ํ์ธ๋ณ๋ก ํด๋ฌ์คํฐ๋ง์ ์คํํ ๋ ๋ฐ์ดํฐ๊ฐ ๊ฐฑ์ ๋ฉ๋๋ค. "
|
| 142 |
+
"ํ์ฌ๋ ์๋ ์คํ ๋ฐฉ์์ด๋ฉฐ, ์ถํ ์๋ ๊ฐฑ์ ์ด ์ถ๊ฐ๋ ์์ ์
๋๋ค."
|
| 143 |
+
)
|
features/research/keyword_suggest.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ํค์๋ ์ถ์ฒ (R-6).
|
| 2 |
+
|
| 3 |
+
ํด๋ฌ์คํฐ์ sample_fanouts์์ ๋น๋ ๋์ ํค์๋/n-gram์ ์ถ์ถํ์ฌ
|
| 4 |
+
์ฝํ
์ธ ๊ธฐํ๊ฐ ๋์ ํค์๋๋ฅผ ์ถ์ฒํฉ๋๋ค.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import re
|
| 8 |
+
from collections import Counter
|
| 9 |
+
|
| 10 |
+
import streamlit as st
|
| 11 |
+
import pandas as pd
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# Common stopwords (English + Korean particles)
|
| 15 |
+
_STOPWORDS = {
|
| 16 |
+
# English
|
| 17 |
+
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
|
| 18 |
+
"have", "has", "had", "do", "does", "did", "will", "would", "could",
|
| 19 |
+
"should", "may", "might", "shall", "can", "need", "dare", "ought",
|
| 20 |
+
"used", "to", "of", "in", "for", "on", "with", "at", "by", "from",
|
| 21 |
+
"as", "into", "through", "during", "before", "after", "above", "below",
|
| 22 |
+
"between", "out", "off", "over", "under", "again", "further", "then",
|
| 23 |
+
"once", "here", "there", "when", "where", "why", "how", "all", "each",
|
| 24 |
+
"every", "both", "few", "more", "most", "other", "some", "such", "no",
|
| 25 |
+
"nor", "not", "only", "own", "same", "so", "than", "too", "very",
|
| 26 |
+
"just", "because", "but", "and", "or", "if", "while", "about", "what",
|
| 27 |
+
"which", "who", "whom", "this", "that", "these", "those", "am", "it",
|
| 28 |
+
"its", "my", "your", "his", "her", "our", "their", "me", "him", "us",
|
| 29 |
+
"them", "i", "you", "he", "she", "we", "they",
|
| 30 |
+
"best", "top", "vs", "good", "new", "review", "reviews",
|
| 31 |
+
# Korean particles
|
| 32 |
+
"์", "์", "๋ฅผ", "์", "์ด", "๊ฐ", "์", "๋", "๋ก", "์ผ๋ก", "์", "๊ณผ",
|
| 33 |
+
"๋", "๋ง", "๊น์ง", "๋ถํฐ", "์์", "ํ", "๋", "๋๋", "ํ๋", "์๋",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _tokenize(text: str) -> list[str]:
|
| 38 |
+
"""Simple tokenization: lowercase, split on non-alphanumeric (preserving Korean)."""
|
| 39 |
+
text = text.lower().strip()
|
| 40 |
+
# Split on whitespace and punctuation, keeping Korean characters
|
| 41 |
+
tokens = re.findall(r"[a-z0-9\uac00-\ud7af]+", text)
|
| 42 |
+
return [t for t in tokens if t not in _STOPWORDS and len(t) >= 2]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _extract_ngrams(texts: list[str], n: int = 2) -> Counter:
|
| 46 |
+
"""Extract n-grams from a list of texts."""
|
| 47 |
+
ngram_counter = Counter()
|
| 48 |
+
for text in texts:
|
| 49 |
+
tokens = _tokenize(text)
|
| 50 |
+
for i in range(len(tokens) - n + 1):
|
| 51 |
+
ngram = " ".join(tokens[i:i + n])
|
| 52 |
+
ngram_counter[ngram] += 1
|
| 53 |
+
return ngram_counter
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def render_keyword_suggestions(clusters: list[dict], frame: str = "all"):
|
| 57 |
+
"""Render keyword suggestions extracted from cluster sample_fanouts."""
|
| 58 |
+
# Frame-specific description
|
| 59 |
+
if frame == "supply":
|
| 60 |
+
st.caption(
|
| 61 |
+
"Gemini ์ธ์ฉ ๋ฌธ๊ตฌ์์ ์ถ์ถํ ํค์๋์
๋๋ค. "
|
| 62 |
+
"AI๊ฐ ์ค์ ๋ก ์ธ์ฉํ๋ ํต์ฌ ํํ์ ํ์
ํ์ฌ ์ฝํ
์ธ ์ ๋ฐ์ํ์ธ์."
|
| 63 |
+
)
|
| 64 |
+
elif frame == "demand":
|
| 65 |
+
st.caption(
|
| 66 |
+
"ChatGPT sub-query์์ ์ถ์ถํ ํค์๋์
๋๋ค. "
|
| 67 |
+
"์๋น์๊ฐ AI์๊ฒ ๋ฌผ์ด๋ณด๋ ํต์ฌ ํํ์ ํ์
ํ์ฌ ์ฝํ
์ธ ๋ฅผ ์ต์ ํํ์ธ์."
|
| 68 |
+
)
|
| 69 |
+
else:
|
| 70 |
+
st.caption(
|
| 71 |
+
"AI ํ ํฝ ํด๋ฌ์คํฐ์์ ์ถ์ถํ ํต์ฌ ํค์๋์
๋๋ค. "
|
| 72 |
+
"๋น๋๊ฐ ๋์์๋ก AI๊ฐ ์์ฃผ ๋ค๋ฃจ๋ ํํ์
๋๋ค."
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# Collect all sample texts
|
| 76 |
+
all_samples = []
|
| 77 |
+
cluster_samples = {} # cluster_label -> samples
|
| 78 |
+
for c in clusters:
|
| 79 |
+
samples = c.get("sample_fanouts") or []
|
| 80 |
+
label = c.get("cluster_label") or "Unknown"
|
| 81 |
+
opp = float(c.get("opportunity_score", 0) or 0)
|
| 82 |
+
all_samples.extend(samples)
|
| 83 |
+
if samples:
|
| 84 |
+
cluster_samples[label] = {
|
| 85 |
+
"samples": samples,
|
| 86 |
+
"opportunity": opp,
|
| 87 |
+
"fanout_count": c.get("fanout_count", 0),
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
if not all_samples:
|
| 91 |
+
st.info("ํด๋ฌ์คํฐ์ ์ํ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.")
|
| 92 |
+
return
|
| 93 |
+
|
| 94 |
+
# --- Unigram Analysis ---
|
| 95 |
+
st.markdown("### ๋จ์ผ ํค์๋ (Unigram)")
|
| 96 |
+
|
| 97 |
+
unigram_counter = Counter()
|
| 98 |
+
for text in all_samples:
|
| 99 |
+
tokens = _tokenize(text)
|
| 100 |
+
unigram_counter.update(tokens)
|
| 101 |
+
|
| 102 |
+
top_unigrams = unigram_counter.most_common(30)
|
| 103 |
+
|
| 104 |
+
if top_unigrams:
|
| 105 |
+
uni_df = pd.DataFrame(top_unigrams, columns=["ํค์๋", "๋น๋"])
|
| 106 |
+
uni_df.index = range(1, len(uni_df) + 1)
|
| 107 |
+
uni_df.index.name = "์์"
|
| 108 |
+
|
| 109 |
+
col1, col2 = st.columns([2, 1])
|
| 110 |
+
with col1:
|
| 111 |
+
st.dataframe(uni_df, use_container_width=True)
|
| 112 |
+
with col2:
|
| 113 |
+
st.metric("๊ณ ์ ํค์๋", f"{len(unigram_counter):,}๊ฐ")
|
| 114 |
+
st.metric("์ด ํ ํฐ", f"{sum(unigram_counter.values()):,}๊ฐ")
|
| 115 |
+
st.metric("๋ถ์ ํ
์คํธ", f"{len(all_samples):,}๊ฐ")
|
| 116 |
+
|
| 117 |
+
st.markdown("---")
|
| 118 |
+
|
| 119 |
+
# --- Bigram Analysis ---
|
| 120 |
+
st.markdown("### ํค์๋ ์กฐํฉ (Bigram)")
|
| 121 |
+
|
| 122 |
+
bigram_counter = _extract_ngrams(all_samples, n=2)
|
| 123 |
+
top_bigrams = bigram_counter.most_common(20)
|
| 124 |
+
|
| 125 |
+
if top_bigrams:
|
| 126 |
+
bi_df = pd.DataFrame(top_bigrams, columns=["ํค์๋ ์กฐํฉ", "๋น๋"])
|
| 127 |
+
bi_df.index = range(1, len(bi_df) + 1)
|
| 128 |
+
bi_df.index.name = "์์"
|
| 129 |
+
st.dataframe(bi_df, use_container_width=True)
|
| 130 |
+
|
| 131 |
+
st.markdown("---")
|
| 132 |
+
|
| 133 |
+
# --- Opportunity-Weighted Keywords ---
|
| 134 |
+
st.markdown("### ๊ธฐํ ๊ฐ์ค ํค์๋")
|
| 135 |
+
st.caption(
|
| 136 |
+
"Opportunity Score๊ฐ ๋์ ํด๋ฌ์คํฐ์์ ๋ง์ด ๋ฑ์ฅํ๋ ํค์๋์
๋๋ค. "
|
| 137 |
+
"์ด ํค์๋๋ฅผ ์ฝํ
์ธ ์ ํฌํจํ๋ฉด AI ๋
ธ์ถ ๊ธฐํ๊ฐ ๋์์ง๋๋ค."
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
# Weight keywords by cluster opportunity score
|
| 141 |
+
weighted_counter = Counter()
|
| 142 |
+
for label, info in cluster_samples.items():
|
| 143 |
+
opp = info["opportunity"]
|
| 144 |
+
tokens_in_cluster = Counter()
|
| 145 |
+
for text in info["samples"]:
|
| 146 |
+
tokens_in_cluster.update(_tokenize(text))
|
| 147 |
+
# Weight by opportunity score (normalize by cluster token total to avoid volume bias)
|
| 148 |
+
total = sum(tokens_in_cluster.values()) or 1
|
| 149 |
+
for token, count in tokens_in_cluster.items():
|
| 150 |
+
weighted_counter[token] += (count / total) * opp
|
| 151 |
+
|
| 152 |
+
top_weighted = weighted_counter.most_common(20)
|
| 153 |
+
|
| 154 |
+
if top_weighted:
|
| 155 |
+
w_df = pd.DataFrame(top_weighted, columns=["ํค์๋", "๊ฐ์ค ์ ์"])
|
| 156 |
+
w_df["๊ฐ์ค ์ ์"] = w_df["๊ฐ์ค ์ ์"].apply(lambda x: f"{x:.4f}")
|
| 157 |
+
w_df.index = range(1, len(w_df) + 1)
|
| 158 |
+
w_df.index.name = "์์"
|
| 159 |
+
st.dataframe(w_df, use_container_width=True)
|
| 160 |
+
|
| 161 |
+
st.markdown("---")
|
| 162 |
+
|
| 163 |
+
# --- Per-Cluster Keyword Breakdown ---
|
| 164 |
+
st.markdown("### ํด๋ฌ์คํฐ๋ณ ํต์ฌ ํค์๋")
|
| 165 |
+
st.caption("๊ฐ ํ ํฝ ํด๋ฌ์คํฐ์ ๋ํ ํค์๋์
๋๋ค. Opportunity Score ์์ผ๋ก ์ ๋ ฌ.")
|
| 166 |
+
|
| 167 |
+
sorted_clusters = sorted(
|
| 168 |
+
cluster_samples.items(),
|
| 169 |
+
key=lambda x: x[1]["opportunity"],
|
| 170 |
+
reverse=True,
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
for label, info in sorted_clusters[:10]:
|
| 174 |
+
opp = info["opportunity"]
|
| 175 |
+
cluster_tokens = Counter()
|
| 176 |
+
for text in info["samples"]:
|
| 177 |
+
cluster_tokens.update(_tokenize(text))
|
| 178 |
+
|
| 179 |
+
top5 = [kw for kw, _ in cluster_tokens.most_common(5)]
|
| 180 |
+
keywords_str = ", ".join(top5)
|
| 181 |
+
|
| 182 |
+
with st.expander(f"{label} (Opp: {opp:.4f}) โ {keywords_str}"):
|
| 183 |
+
st.write(f"**Fanout/Citation ์:** {info['fanout_count']}")
|
| 184 |
+
st.write(f"**Opportunity Score:** {opp:.4f}")
|
| 185 |
+
st.markdown("**Top ํค์๋:**")
|
| 186 |
+
|
| 187 |
+
kw_df = pd.DataFrame(
|
| 188 |
+
cluster_tokens.most_common(10),
|
| 189 |
+
columns=["ํค์๋", "๋น๋"],
|
| 190 |
+
)
|
| 191 |
+
st.dataframe(kw_df, use_container_width=True, hide_index=True)
|
| 192 |
+
|
| 193 |
+
st.markdown("**์ํ ํ
์คํธ:**")
|
| 194 |
+
for s in info["samples"][:3]:
|
| 195 |
+
st.write(f"- {s}")
|
features/research/opportunities.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""๊ธฐํ ์์ญ ๋ญํน ํ
์ด๋ธ + ์์ธ Expander.
|
| 2 |
+
|
| 3 |
+
Clusters sorted by opportunity_score DESC.
|
| 4 |
+
Top-10 with expanders showing sample fanouts and top sources.
|
| 5 |
+
v9.0: ๋น์ฆ๋์ค ํด์, percentile, ์ธ์ฉ ์ ๋ฌด ๋ฉ์์ง ์ถ๊ฐ.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import statistics
|
| 9 |
+
|
| 10 |
+
import streamlit as st
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import plotly.graph_objects as go
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _get_percentile_rank(value: float, all_values: list[float]) -> int:
|
| 16 |
+
"""Return the percentile rank (0-100) of a value within a list."""
|
| 17 |
+
if not all_values:
|
| 18 |
+
return 0
|
| 19 |
+
count_below = sum(1 for v in all_values if v < value)
|
| 20 |
+
return int(count_below / len(all_values) * 100)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _interpret_density(density: float, median_density: float) -> str:
|
| 24 |
+
"""Interpret density relative to median."""
|
| 25 |
+
if density < median_density * 0.5:
|
| 26 |
+
return "๊ฒฝ์ ๋ฎ์"
|
| 27 |
+
elif density < median_density * 1.5:
|
| 28 |
+
return "๊ฒฝ์ ๋ณดํต"
|
| 29 |
+
return "๊ฒฝ์ ๋์"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _interpret_opportunity(opp: float, median_opp: float) -> str:
|
| 33 |
+
"""Interpret opportunity score."""
|
| 34 |
+
if opp >= median_opp * 1.5:
|
| 35 |
+
return "๊ธฐํ ํผ"
|
| 36 |
+
elif opp >= median_opp * 0.5:
|
| 37 |
+
return "๊ธฐํ ๋ณดํต"
|
| 38 |
+
return "๊ธฐํ ๋ฎ์"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def render_opportunities(clusters: list[dict], frame: str = "all"):
|
| 42 |
+
"""Render opportunity ranking table with detail expanders."""
|
| 43 |
+
scored = [
|
| 44 |
+
c for c in clusters
|
| 45 |
+
if c.get("opportunity_score") is not None
|
| 46 |
+
and c.get("attention_score") is not None
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
if not scored:
|
| 50 |
+
st.info("์ค์ฝ์ด๊ฐ ๊ณ์ฐ๋ ํด๋ฌ์คํฐ๊ฐ ์์ต๋๋ค.")
|
| 51 |
+
return
|
| 52 |
+
|
| 53 |
+
# Sort by opportunity_score DESC
|
| 54 |
+
scored.sort(key=lambda c: float(c.get("opportunity_score", 0) or 0), reverse=True)
|
| 55 |
+
|
| 56 |
+
# Pre-compute statistics for percentile/interpretation
|
| 57 |
+
all_attns = [float(c.get("attention_score", 0) or 0) for c in scored]
|
| 58 |
+
all_densities = [float(c.get("citation_density", 0) or 0) for c in scored]
|
| 59 |
+
all_opps = [float(c.get("opportunity_score", 0) or 0) for c in scored]
|
| 60 |
+
median_density = statistics.median(all_densities) if all_densities else 0
|
| 61 |
+
median_opp = statistics.median(all_opps) if all_opps else 0
|
| 62 |
+
|
| 63 |
+
# Top metrics
|
| 64 |
+
col1, col2, col3 = st.columns(3)
|
| 65 |
+
with col1:
|
| 66 |
+
st.metric("์ค์ฝ์ด๋ง ์๋ฃ", f"{len(scored)}๊ฐ ํด๋ฌ์คํฐ")
|
| 67 |
+
with col2:
|
| 68 |
+
avg_opp = sum(all_opps) / len(all_opps)
|
| 69 |
+
st.metric("ํ๊ท Opportunity", f"{avg_opp:.4f}")
|
| 70 |
+
with col3:
|
| 71 |
+
top = scored[0]
|
| 72 |
+
st.metric("Top ๊ธฐํ ํ ํฝ", (top.get("cluster_label") or "N/A")[:25])
|
| 73 |
+
|
| 74 |
+
st.markdown("---")
|
| 75 |
+
|
| 76 |
+
# Explanation before ranking table (frame-specific)
|
| 77 |
+
if frame == "demand":
|
| 78 |
+
st.info(
|
| 79 |
+
"ChatGPT๊ฐ ์์ฃผ ๊ฒ์ํ์ง๋ง ๊ฒฝ์์ด ๋ฎ์ Demand ํ ํฝ์
๋๋ค.\n\n"
|
| 80 |
+
"๊ธฐํ ์ ์๊ฐ ๋์์๋ก, ์ด ์ฃผ์ ์ ์ฝํ
์ธ ๋ฅผ ๋ง๋ค๋ฉด ChatGPT ๊ฒ์์ ๋
ธ์ถ๋ ๊ฐ๋ฅ์ฑ์ด ๋์ต๋๋ค."
|
| 81 |
+
)
|
| 82 |
+
elif frame == "supply":
|
| 83 |
+
st.info(
|
| 84 |
+
"Gemini๊ฐ ์์ฃผ ์ธ์ฉํ์ง๋ง ์ถ์ฒ ๊ฒฝ์์ด ๋ฎ์ Supply ํ ํฝ์
๋๋ค.\n\n"
|
| 85 |
+
"๊ธฐํ ์ ์๊ฐ ๋์์๋ก, ์ด ์ฃผ์ ์ ์ฝํ
์ธ ๋ฅผ ๋ง๋ค๋ฉด Gemini ๋ต๋ณ์ ์ธ์ฉ๋ ๊ฐ๋ฅ์ฑ์ด ๋์ต๋๋ค."
|
| 86 |
+
)
|
| 87 |
+
else:
|
| 88 |
+
st.info(
|
| 89 |
+
"๊ธฐํ ์ ์ Top 10 ํ ํฝ์ ์์ธ ๋ถ์ํฉ๋๋ค. "
|
| 90 |
+
"๊ธฐํ ์ ์๋ **'AI ๊ด์ฌ๋๊ฐ ๋์ง๋ง ๊ฒฝ์(์ธ์ฉ ์ถ์ฒ)์ด ์ ์ ํ ํฝ'**์ ์ฐพ์์ฃผ๋ ์งํ์
๋๋ค.\n\n"
|
| 91 |
+
"์ ์๊ฐ ๋์์๋ก ์ฝํ
์ธ ๋ฅผ ๋ง๋ค๋ฉด AI์ ์ธ์ฉ๋ ๊ฐ๋ฅ์ฑ์ด ๋์ต๋๋ค."
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# Ranking table
|
| 95 |
+
rows = []
|
| 96 |
+
for i, c in enumerate(scored[:50], 1):
|
| 97 |
+
rows.append({
|
| 98 |
+
"์์": i,
|
| 99 |
+
"ํ ํฝ": (c.get("cluster_label") or f"Cluster-{c['id'][:8]}")[:40],
|
| 100 |
+
"Attention": f"{float(c.get('attention_score', 0) or 0):.4f}",
|
| 101 |
+
"Density": f"{float(c.get('citation_density', 0) or 0):.4f}",
|
| 102 |
+
"Opportunity": f"{float(c.get('opportunity_score', 0) or 0):.4f}",
|
| 103 |
+
"Fanouts": c.get("fanout_count", 0),
|
| 104 |
+
})
|
| 105 |
+
|
| 106 |
+
df = pd.DataFrame(rows)
|
| 107 |
+
st.dataframe(df, use_container_width=True, hide_index=True)
|
| 108 |
+
|
| 109 |
+
# Detail expanders for top 10
|
| 110 |
+
st.markdown("#### Top 10 ์์ธ")
|
| 111 |
+
for i, c in enumerate(scored[:10], 1):
|
| 112 |
+
label = c.get("cluster_label") or f"Cluster-{c['id'][:8]}"
|
| 113 |
+
opp = float(c.get("opportunity_score", 0) or 0)
|
| 114 |
+
|
| 115 |
+
with st.expander(f"#{i} {label} (Opportunity: {opp:.4f})", key=f"research:opp_detail:{c['id']}"):
|
| 116 |
+
detail_col1, detail_col2 = st.columns(2)
|
| 117 |
+
|
| 118 |
+
with detail_col1:
|
| 119 |
+
attn = float(c.get("attention_score", 0) or 0)
|
| 120 |
+
density = float(c.get("citation_density", 0) or 0)
|
| 121 |
+
attn_pct = _get_percentile_rank(attn, all_attns)
|
| 122 |
+
density_label = _interpret_density(density, median_density)
|
| 123 |
+
opp_label = _interpret_opportunity(opp, median_opp)
|
| 124 |
+
|
| 125 |
+
st.markdown("**Score ์์ธ**")
|
| 126 |
+
st.write(f"- Attention: {attn:.4f} (์์ {100 - attn_pct}%)")
|
| 127 |
+
st.write(f"- Density: {density:.4f} ({density_label})")
|
| 128 |
+
st.write(f"- Opportunity: {opp:.4f} ({opp_label})")
|
| 129 |
+
st.write(f"- Fanout ์: {c.get('fanout_count', 0)}")
|
| 130 |
+
if c.get("unique_questions"):
|
| 131 |
+
st.write(f"- ๊ด๋ จ ์ง๋ฌธ ์: {c['unique_questions']}")
|
| 132 |
+
|
| 133 |
+
with detail_col2:
|
| 134 |
+
# Sample fanouts/citations with context
|
| 135 |
+
samples = c.get("sample_fanouts") or []
|
| 136 |
+
if samples:
|
| 137 |
+
if frame == "supply":
|
| 138 |
+
st.markdown("**๋ํ ์ธ์ฉ ๋ฌธ๊ตฌ** (Gemini๊ฐ ์ค์ ๋ก ์ธ์ฉํ ํ
์คํธ):")
|
| 139 |
+
else:
|
| 140 |
+
st.markdown("**๋ํ AI ์ถ๊ฐ ์ง๋ฌธ** (์ด ํ ํฝ์์ AI๊ฐ ์ค์ ๋ก ์์ฑํ ์ง๋ฌธ๋ค):")
|
| 141 |
+
for s in samples[:5]:
|
| 142 |
+
st.write(f"- {s}")
|
| 143 |
+
if frame == "supply":
|
| 144 |
+
st.caption("์ด๋ฐ ํํ์ ์ฝํ
์ธ ๋ฅผ ๋ง๋ค๋ฉด Gemini ๋ต๋ณ์ ์ธ์ฉ๋ ์ ์์ต๋๋ค.")
|
| 145 |
+
else:
|
| 146 |
+
st.caption("์ด๋ฐ ์ง๋ฌธ์ ๋ํ ์ฝํ
์ธ ๋ฅผ ๋ง๋ค๋ฉด AI ๋ต๋ณ์ ์ธ์ฉ๋ ์ ์์ต๋๋ค.")
|
| 147 |
+
else:
|
| 148 |
+
st.write("์ํ ์์")
|
| 149 |
+
|
| 150 |
+
# Top sources bar chart
|
| 151 |
+
top_sources = c.get("top_sources")
|
| 152 |
+
if top_sources and isinstance(top_sources, list) and len(top_sources) > 0:
|
| 153 |
+
st.caption("์ด ํ ํฝ์์ AI๊ฐ ์ธ์ฉํ ์ฃผ์ ์ถ์ฒ์
๋๋ค. ์ฌ๊ธฐ์ ์์ฌ ์ฝํ
์ธ ๊ฐ ์๋ค๋ฉด ์ง์ถ ๊ธฐํ์
๋๋ค.")
|
| 154 |
+
domains = [s.get("host_url", "unknown") for s in top_sources[:10]]
|
| 155 |
+
counts = [s.get("count", 0) for s in top_sources[:10]]
|
| 156 |
+
|
| 157 |
+
fig = go.Figure(go.Bar(
|
| 158 |
+
x=counts,
|
| 159 |
+
y=domains,
|
| 160 |
+
orientation="h",
|
| 161 |
+
marker_color="#059669",
|
| 162 |
+
))
|
| 163 |
+
fig.update_layout(
|
| 164 |
+
title="Top ์ธ์ฉ ์ถ์ฒ",
|
| 165 |
+
xaxis_title="์ธ์ฉ ์",
|
| 166 |
+
yaxis=dict(autorange="reversed"),
|
| 167 |
+
height=300,
|
| 168 |
+
margin=dict(l=0, r=0, t=30, b=0),
|
| 169 |
+
template="plotly_white",
|
| 170 |
+
)
|
| 171 |
+
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
|
| 172 |
+
else:
|
| 173 |
+
st.info("์ด ํ ํฝ์ ์์ง AI๊ฐ ํน์ ์ถ์ฒ๋ฅผ ์ธ์ฉํ์ง ์๊ณ ์์ด, ์ฝํ
์ธ ์ ์ ๊ธฐํ๊ฐ ๋์ฑ ํฝ๋๋ค.")
|
features/research/summary.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ํ ํฝ ์ธํ
๋ฆฌ์ ์ค Feature ์์ฝ ์นด๋."""
|
| 2 |
+
import streamlit as st
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def render_summary(clusters: list, frame: str = "all"):
|
| 6 |
+
"""ํ ํฝ ์ธํ
๋ฆฌ์ ์ค ์์ฝ ๋ฉํธ๋ฆญ.
|
| 7 |
+
|
| 8 |
+
Args:
|
| 9 |
+
clusters: List of cluster dicts.
|
| 10 |
+
frame: "demand" (ChatGPT), "supply" (Gemini), or "all".
|
| 11 |
+
"""
|
| 12 |
+
scored = [c for c in clusters if c.get("opportunity_score") is not None]
|
| 13 |
+
|
| 14 |
+
# Frame-specific labels
|
| 15 |
+
if frame == "demand":
|
| 16 |
+
count_label = "Demand ํ ํฝ"
|
| 17 |
+
volume_label = "์ด Fanout"
|
| 18 |
+
volume_help = "ChatGPT๊ฐ ์ฌ์ฉ์ ์ง๋ฌธ์ ๋ถํดํ ์ธ๋ถ ์ง๋ฌธ์ ์ด ์"
|
| 19 |
+
attn_help = "๊ฐ ํ ํฝ์ด ์ ์ฒด ChatGPT ์ง์์์ ์ฐจ์งํ๋ ๋น์ค์ ํ๊ท "
|
| 20 |
+
top_help = "ChatGPT์์ ๊ด์ฌ๋๋ ๋์ง๋ง ๊ฒฝ์์ด ๋ฎ์ ์ฝํ
์ธ ๊ธฐํ๊ฐ ๊ฐ์ฅ ํฐ ํ ํฝ"
|
| 21 |
+
elif frame == "supply":
|
| 22 |
+
count_label = "Supply ํ ํฝ"
|
| 23 |
+
volume_label = "์ด Citation"
|
| 24 |
+
volume_help = "Gemini๊ฐ ๋ต๋ณ์์ ์ธ์ฉํ ๋ฌธ๊ตฌ์ ์ด ์"
|
| 25 |
+
attn_help = "๊ฐ ํ ํฝ์ด ์ ์ฒด Gemini ์ธ์ฉ์์ ์ฐจ์งํ๋ ๋น์ค์ ํ๊ท "
|
| 26 |
+
top_help = "Gemini์์ ์ธ์ฉ ๋น๋๋ ๋์ง๋ง ๊ฒฝ์์ด ๋ฎ์ ์ฝํ
์ธ ๊ธฐํ๊ฐ ๊ฐ์ฅ ํฐ ํ ํฝ"
|
| 27 |
+
else:
|
| 28 |
+
count_label = "์ด ํด๋ฌ์คํฐ"
|
| 29 |
+
volume_label = "์ด Fanout"
|
| 30 |
+
volume_help = "AI๊ฐ ์ฌ์ฉ์ ์ง๋ฌธ์ ์กฐ์ฌํ๊ธฐ ์ํด ์์ฑํ ์ธ๋ถ ์ง๋ฌธ์ ์ด ์"
|
| 31 |
+
attn_help = "๊ฐ ํ ํฝ์ด ์ ์ฒด ์ง์์์ ์ฐจ์งํ๋ ๋น์ค์ ํ๊ท "
|
| 32 |
+
top_help = "AI ๊ด์ฌ๋๋ ๋์ง๋ง ๊ฒฝ์์ด ๋ฎ์ ์ฝํ
์ธ ๊ธฐํ๊ฐ ๊ฐ์ฅ ํฐ ํ ํฝ"
|
| 33 |
+
|
| 34 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 35 |
+
with col1:
|
| 36 |
+
st.metric(
|
| 37 |
+
count_label, len(clusters),
|
| 38 |
+
help="์ ์ฌํ AI ์ง๋ฌธ/์ธ์ฉ๋ค์ ๋ฌถ์ ํ ํฝ ๊ทธ๋ฃน ์",
|
| 39 |
+
)
|
| 40 |
+
with col2:
|
| 41 |
+
total_fanouts = sum(c.get("fanout_count", 0) for c in clusters)
|
| 42 |
+
st.metric(volume_label, f"{total_fanouts:,}", help=volume_help)
|
| 43 |
+
with col3:
|
| 44 |
+
avg_attn = sum(float(c.get("attention_score", 0) or 0) for c in scored) / len(scored) if scored else 0
|
| 45 |
+
st.metric("ํ๊ท Attention", f"{avg_attn:.4f}", help=attn_help)
|
| 46 |
+
with col4:
|
| 47 |
+
top_label = scored[0].get("cluster_label", "N/A") if scored else "N/A"
|
| 48 |
+
st.metric("Top Opportunity", top_label[:20], help=top_help)
|
features/research/topic_map.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""UMAP ํ ํฝ ๋งต ์๊ฐํ (Plotly scatter).
|
| 2 |
+
|
| 3 |
+
snapshot.coordinates: [{cluster_id, x, y, size, label}]
|
| 4 |
+
clusters: [{id, cluster_label, attention_score, citation_density, opportunity_score, fanout_count, ...}]
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import streamlit as st
|
| 8 |
+
import plotly.graph_objects as go
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def render_topic_map(clusters: list[dict], snapshot: dict | None, frame: str = "all"):
|
| 12 |
+
"""Render UMAP 2D scatter from snapshot coordinates + cluster metadata."""
|
| 13 |
+
if not snapshot or not snapshot.get("coordinates"):
|
| 14 |
+
st.info("UMAP ์ขํ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค. ํด๋ฌ์คํฐ๋ง ์คํ ํ ์์ฑ๋ฉ๋๋ค.")
|
| 15 |
+
return
|
| 16 |
+
|
| 17 |
+
# Frame-specific guide
|
| 18 |
+
if frame == "demand":
|
| 19 |
+
st.markdown("""
|
| 20 |
+
๊ฐ ์ ์ ํ๋์ **Demand ํ ํฝ** (ChatGPT sub-query ๊ทธ๋ฃน)์
๋๋ค.
|
| 21 |
+
- **์ ํฌ๊ธฐ**: ํด๋น ํ ํฝ์ fanout ์ (ํด์๋ก ์๋น์๊ฐ ์์ฃผ ๋ฌป๋ ํ ํฝ)
|
| 22 |
+
- **์ ์์**: ๊ธฐํ ์ ์ (๋นจ๊ฐ = ๊ธฐํ ํผ, ๋
ธ๋ = ๋ณดํต)
|
| 23 |
+
- **๊ฐ๊น์ด ์๋ ์ **: ์ ์ฌํ ๊ฒ์ ์๋์ ํ ํฝ
|
| 24 |
+
""")
|
| 25 |
+
elif frame == "supply":
|
| 26 |
+
st.markdown("""
|
| 27 |
+
๊ฐ ์ ์ ํ๋์ **Supply ํ ํฝ** (Gemini citation quote ๊ทธ๋ฃน)์
๋๋ค.
|
| 28 |
+
- **์ ํฌ๊ธฐ**: ํด๋น ํ ํฝ์ ์ธ์ฉ ์ (ํด์๋ก AI๊ฐ ์์ฃผ ์ธ์ฉํ๋ ํ ํฝ)
|
| 29 |
+
- **์ ์์**: ๊ธฐํ ์ ์ (๋นจ๊ฐ = ๊ธฐํ ํผ, ๋
ธ๋ = ๋ณดํต)
|
| 30 |
+
- **๊ฐ๊น์ด ์๋ ์ **: ์ ์ฌํ ์ธ์ฉ ์ฃผ์ ์ ํ ํฝ
|
| 31 |
+
""")
|
| 32 |
+
else:
|
| 33 |
+
st.markdown("""
|
| 34 |
+
๊ฐ ์ ์ ํ๋์ **ํ ํฝ**(AI ์ถ๊ฐ ์ง๋ฌธ ๊ทธ๋ฃน)์
๋๋ค.
|
| 35 |
+
- **์ ํฌ๊ธฐ**: ํด๋น ํ ํฝ์ AI ์ถ๊ฐ ์ง๋ฌธ ์ (ํด์๋ก AI๊ฐ ์์ฃผ ๋ฌป๋ ํ ํฝ)
|
| 36 |
+
- **์ ์์**: ๊ธฐํ ์ ์ (๋นจ๊ฐ = ๊ธฐํ ํผ, ๋
ธ๋ = ๋ณดํต)
|
| 37 |
+
- **๊ฐ๊น์ด ์๋ ์ **: ์ ์ฌํ ์ฃผ์ ์ ํ ํฝ
|
| 38 |
+
""")
|
| 39 |
+
|
| 40 |
+
count_label = "Citations" if frame == "supply" else "Fanouts"
|
| 41 |
+
coords = snapshot["coordinates"]
|
| 42 |
+
|
| 43 |
+
# Build cluster lookup by id
|
| 44 |
+
cluster_map = {c["id"]: c for c in clusters}
|
| 45 |
+
|
| 46 |
+
# Merge coordinate data with cluster metadata
|
| 47 |
+
xs, ys, sizes, colors, hover_texts = [], [], [], [], []
|
| 48 |
+
|
| 49 |
+
for pt in coords:
|
| 50 |
+
cid = pt.get("cluster_id")
|
| 51 |
+
meta = cluster_map.get(cid, {})
|
| 52 |
+
|
| 53 |
+
xs.append(pt["x"])
|
| 54 |
+
ys.append(pt["y"])
|
| 55 |
+
|
| 56 |
+
fanout_count = pt.get("size", meta.get("fanout_count", 10))
|
| 57 |
+
# Normalize size for display (min 5, max 40)
|
| 58 |
+
norm_size = max(5, min(40, fanout_count / 5))
|
| 59 |
+
sizes.append(norm_size)
|
| 60 |
+
|
| 61 |
+
opp = float(meta.get("opportunity_score", 0) or 0)
|
| 62 |
+
colors.append(opp)
|
| 63 |
+
|
| 64 |
+
label = meta.get("cluster_label") or f"Cluster {pt.get('label', '?')}"
|
| 65 |
+
attn = float(meta.get("attention_score", 0) or 0)
|
| 66 |
+
density = float(meta.get("citation_density", 0) or 0)
|
| 67 |
+
|
| 68 |
+
hover_texts.append(
|
| 69 |
+
f"<b>{label}</b><br>"
|
| 70 |
+
f"Attention: {attn:.4f}<br>"
|
| 71 |
+
f"Density: {density:.4f}<br>"
|
| 72 |
+
f"Opportunity: {opp:.4f}<br>"
|
| 73 |
+
f"{count_label}: {fanout_count}"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
fig = go.Figure()
|
| 77 |
+
fig.add_trace(go.Scatter(
|
| 78 |
+
x=xs,
|
| 79 |
+
y=ys,
|
| 80 |
+
mode="markers",
|
| 81 |
+
marker=dict(
|
| 82 |
+
size=sizes,
|
| 83 |
+
color=colors,
|
| 84 |
+
colorscale="YlOrRd",
|
| 85 |
+
colorbar=dict(title="Opportunity"),
|
| 86 |
+
opacity=0.7,
|
| 87 |
+
line=dict(width=0.5, color="#333"),
|
| 88 |
+
),
|
| 89 |
+
text=hover_texts,
|
| 90 |
+
hoverinfo="text",
|
| 91 |
+
))
|
| 92 |
+
|
| 93 |
+
fig.update_layout(
|
| 94 |
+
title="AI ํ ํฝ ๋งต (UMAP 2D Projection)",
|
| 95 |
+
xaxis=dict(title="UMAP-1", showgrid=False, zeroline=False),
|
| 96 |
+
yaxis=dict(title="UMAP-2", showgrid=False, zeroline=False),
|
| 97 |
+
height=600,
|
| 98 |
+
template="plotly_white",
|
| 99 |
+
hoverlabel=dict(bgcolor="white", font_size=12),
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
|
| 103 |
+
|
| 104 |
+
# Algorithm params info
|
| 105 |
+
params = snapshot.get("algorithm_params")
|
| 106 |
+
if params:
|
| 107 |
+
with st.expander("๋ถ์ ์ค์ (๊ธฐ์ ์์ธ)"):
|
| 108 |
+
st.json(params)
|
features/research/unified_scoring.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unified Scoring (ADR-014 Phase 4).
|
| 2 |
+
|
| 3 |
+
Cross-model unified score = weighted average of demand/supply percentiles.
|
| 4 |
+
Displayed in "์ ์ฒด" mode when cross-model pair exists.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import streamlit as st
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import plotly.graph_objects as go
|
| 10 |
+
|
| 11 |
+
from core.supabase_client import (
|
| 12 |
+
get_gap_scores, get_topic_clusters,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def render_unified_scoring(base_ctx: dict, pair: dict):
|
| 17 |
+
"""Render unified scoring view combining demand + supply signals.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
base_ctx: Dashboard base context.
|
| 21 |
+
pair: Cross-model pair dict from find_cross_model_pair().
|
| 22 |
+
"""
|
| 23 |
+
campaign_chatgpt = pair["campaign_chatgpt"]
|
| 24 |
+
campaign_gemini = pair["campaign_gemini"]
|
| 25 |
+
|
| 26 |
+
matches = get_gap_scores(campaign_chatgpt, campaign_gemini)
|
| 27 |
+
if not matches:
|
| 28 |
+
st.info("Unified Scoring์ ํ์ํ Cross-Model ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.")
|
| 29 |
+
return
|
| 30 |
+
|
| 31 |
+
# Fetch cluster counts for weight calculation
|
| 32 |
+
chatgpt_clusters = get_topic_clusters(campaign_chatgpt, source="chatgpt")
|
| 33 |
+
gemini_clusters = get_topic_clusters(campaign_gemini, source="gemini")
|
| 34 |
+
|
| 35 |
+
chatgpt_volume = sum(c.get("fanout_count", 0) for c in chatgpt_clusters)
|
| 36 |
+
gemini_volume = sum(c.get("fanout_count", 0) for c in gemini_clusters)
|
| 37 |
+
total_volume = chatgpt_volume + gemini_volume
|
| 38 |
+
|
| 39 |
+
# Weight by data volume (ADR-014 spec)
|
| 40 |
+
w_chatgpt = chatgpt_volume / total_volume if total_volume > 0 else 0.5
|
| 41 |
+
w_gemini = gemini_volume / total_volume if total_volume > 0 else 0.5
|
| 42 |
+
|
| 43 |
+
st.caption(
|
| 44 |
+
"Demand(ChatGPT)์ Supply(Gemini) ์ ํธ๋ฅผ ๋ฐ์ดํฐ ๋ณผ๋ฅจ ๋น๋ก๋ก ํตํฉํ ์ ์์
๋๋ค."
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# Weight info
|
| 48 |
+
c1, c2, c3 = st.columns(3)
|
| 49 |
+
with c1:
|
| 50 |
+
st.metric(
|
| 51 |
+
"Demand ๊ฐ์ค์น",
|
| 52 |
+
f"{w_chatgpt:.1%}",
|
| 53 |
+
help=f"ChatGPT fanout ๋ณผ๋ฅจ: {chatgpt_volume:,}",
|
| 54 |
+
)
|
| 55 |
+
with c2:
|
| 56 |
+
st.metric(
|
| 57 |
+
"Supply ๊ฐ์ค์น",
|
| 58 |
+
f"{w_gemini:.1%}",
|
| 59 |
+
help=f"Gemini citation ๋ณผ๋ฅจ: {gemini_volume:,}",
|
| 60 |
+
)
|
| 61 |
+
with c3:
|
| 62 |
+
st.metric("๋งค์นญ ํ ํฝ", f"{len(matches)}๊ฐ")
|
| 63 |
+
|
| 64 |
+
st.markdown("---")
|
| 65 |
+
|
| 66 |
+
# Compute unified scores
|
| 67 |
+
scored = []
|
| 68 |
+
for m in matches:
|
| 69 |
+
demand_pct = float(m.get("demand_percentile") or 0)
|
| 70 |
+
supply_pct = float(m.get("supply_percentile") or 0)
|
| 71 |
+
unified = w_chatgpt * demand_pct + w_gemini * supply_pct
|
| 72 |
+
scored.append({
|
| 73 |
+
**m,
|
| 74 |
+
"unified_score": unified,
|
| 75 |
+
})
|
| 76 |
+
|
| 77 |
+
# Sort by unified score DESC
|
| 78 |
+
scored.sort(key=lambda x: x["unified_score"], reverse=True)
|
| 79 |
+
|
| 80 |
+
# --- Unified Ranking Table ---
|
| 81 |
+
st.markdown("#### Unified Score ๋ญํน")
|
| 82 |
+
st.caption(
|
| 83 |
+
"๋ ๋ชจ๋ธ์ ์ ํธ๋ฅผ ํตํฉํ ์์์
๋๋ค. "
|
| 84 |
+
"Unified Score๊ฐ ๋์์๋ก Demand์ Supply ๋ชจ๋์์ ์ค์ํ ํ ํฝ์
๋๋ค."
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
rows = []
|
| 88 |
+
for i, s in enumerate(scored, 1):
|
| 89 |
+
demand_pct = float(s.get("demand_percentile") or 0)
|
| 90 |
+
supply_pct = float(s.get("supply_percentile") or 0)
|
| 91 |
+
rows.append({
|
| 92 |
+
"#": i,
|
| 93 |
+
"ํ ํฝ (ChatGPT)": (s.get("chatgpt_label") or "")[:30],
|
| 94 |
+
"ํ ํฝ (Gemini)": (s.get("gemini_label") or "")[:30],
|
| 95 |
+
"Unified": f"{s['unified_score']:.4f}",
|
| 96 |
+
"Demand": f"{demand_pct:.2%}",
|
| 97 |
+
"Supply": f"{supply_pct:.2%}",
|
| 98 |
+
"GapScore": f"{float(s.get('gap_score') or 0):.4f}",
|
| 99 |
+
"Quadrant": s.get("quadrant", "NICHE").replace("_", " ").title(),
|
| 100 |
+
})
|
| 101 |
+
|
| 102 |
+
df = pd.DataFrame(rows)
|
| 103 |
+
st.dataframe(df, use_container_width=True, hide_index=True)
|
| 104 |
+
|
| 105 |
+
st.markdown("---")
|
| 106 |
+
|
| 107 |
+
# --- Unified Score Distribution ---
|
| 108 |
+
st.markdown("#### Unified Score vs GapScore")
|
| 109 |
+
st.caption(
|
| 110 |
+
"X์ถ์ ํตํฉ ์ค์๋(๋์์๋ก ๋ ๋ชจ๋ธ ๋ชจ๋ ์ค์), "
|
| 111 |
+
"Y์ถ์ ๊ธฐํ ํฌ๊ธฐ(๋์์๋ก ์ฝํ
์ธ ์ ์ ROI๊ฐ ๋์)."
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
_render_unified_scatter(scored)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _render_unified_scatter(scored: list[dict]):
|
| 118 |
+
"""Scatter: Unified Score (x) vs GapScore (y)."""
|
| 119 |
+
from .cross_model import QUADRANT_COLORS, QUADRANT_LABELS
|
| 120 |
+
|
| 121 |
+
xs, ys, colors, hovers, sizes = [], [], [], [], []
|
| 122 |
+
|
| 123 |
+
for s in scored:
|
| 124 |
+
unified = s["unified_score"]
|
| 125 |
+
gap = float(s.get("gap_score") or 0)
|
| 126 |
+
quadrant = s.get("quadrant", "NICHE")
|
| 127 |
+
chatgpt_label = s.get("chatgpt_label", "")
|
| 128 |
+
|
| 129 |
+
xs.append(unified)
|
| 130 |
+
ys.append(gap)
|
| 131 |
+
colors.append(QUADRANT_COLORS.get(quadrant, "#9CA3AF"))
|
| 132 |
+
sizes.append(max(8, min(25, unified * 30)))
|
| 133 |
+
hovers.append(
|
| 134 |
+
f"<b>{chatgpt_label}</b><br>"
|
| 135 |
+
f"Unified: {unified:.4f}<br>"
|
| 136 |
+
f"GapScore: {gap:.4f}<br>"
|
| 137 |
+
f"Quadrant: {QUADRANT_LABELS.get(quadrant, quadrant)}"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
fig = go.Figure()
|
| 141 |
+
fig.add_trace(go.Scatter(
|
| 142 |
+
x=xs,
|
| 143 |
+
y=ys,
|
| 144 |
+
mode="markers",
|
| 145 |
+
marker=dict(
|
| 146 |
+
size=sizes,
|
| 147 |
+
color=colors,
|
| 148 |
+
opacity=0.7,
|
| 149 |
+
line=dict(width=0.5, color="#333"),
|
| 150 |
+
),
|
| 151 |
+
text=hovers,
|
| 152 |
+
hoverinfo="text",
|
| 153 |
+
showlegend=False,
|
| 154 |
+
))
|
| 155 |
+
|
| 156 |
+
fig.update_layout(
|
| 157 |
+
title="Unified Score vs GapScore",
|
| 158 |
+
xaxis_title="Unified Score (ํตํฉ ์ค์๋)",
|
| 159 |
+
yaxis_title="GapScore (์ฝํ
์ธ ๊ธฐํ)",
|
| 160 |
+
height=450,
|
| 161 |
+
template="plotly_white",
|
| 162 |
+
hoverlabel=dict(bgcolor="white", font_size=12),
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
st.plotly_chart(fig, use_container_width=True, key="cross_model:unified_scatter", config={"displayModeBar": False})
|
| 166 |
+
|
| 167 |
+
# Insight: Top-right quadrant = high importance + high opportunity
|
| 168 |
+
high_unified = [s for s in scored if s["unified_score"] > 0.5]
|
| 169 |
+
high_gap_and_unified = [
|
| 170 |
+
s for s in high_unified
|
| 171 |
+
if float(s.get("gap_score") or 0) > 0.05
|
| 172 |
+
]
|
| 173 |
+
if high_gap_and_unified:
|
| 174 |
+
st.info(
|
| 175 |
+
f"Unified Score > 0.5 ์ด๋ฉด์ GapScore๊ฐ ๋์ ํ ํฝ์ด "
|
| 176 |
+
f"**{len(high_gap_and_unified)}๊ฐ** ์์ต๋๋ค. "
|
| 177 |
+
f"์ด ํ ํฝ๋ค์ ๋ ๋ชจ๋ธ ๋ชจ๋์์ ์ค์ํ๋ฉด์ ์ฝํ
์ธ ๊ธฐํ๋ ํฐ ์ต์ฐ์ ์์ญ์
๋๋ค."
|
| 178 |
+
)
|