GitHub Action commited on
Commit
ef78361
ยท
0 Parent(s):

Sync from GitHub

Browse files
This view is limited to 50 files because it contains too many changes. ย  See raw diff
Files changed (50) hide show
  1. .streamlit/config.toml +14 -0
  2. .streamlit/secrets.toml.example +7 -0
  3. Dockerfile +9 -0
  4. README.md +8 -0
  5. app.py +77 -0
  6. components/__init__.py +31 -0
  7. components/cards.py +188 -0
  8. components/expanders.py +244 -0
  9. components/metrics.py +175 -0
  10. core/__init__.py +45 -0
  11. core/api_client.py +150 -0
  12. core/api_client_hierarchy.py +102 -0
  13. core/api_client_reports.py +196 -0
  14. core/api_client_sentiment.py +279 -0
  15. core/athena_client.py +331 -0
  16. core/charts.py +361 -0
  17. core/data_fetchers.py +71 -0
  18. core/export_utils.py +216 -0
  19. core/job_realtime.py +264 -0
  20. core/styles.py +93 -0
  21. core/supabase_action_items.py +206 -0
  22. core/supabase_client.py +76 -0
  23. core/supabase_research.py +153 -0
  24. core/supabase_sentiment.py +373 -0
  25. core/utils.py +143 -0
  26. features/__init__.py +4 -0
  27. features/action_items/__init__.py +24 -0
  28. features/action_items/analysis.py +131 -0
  29. features/action_items/overview.py +339 -0
  30. features/action_items/trend_charts.py +329 -0
  31. features/action_items/triggers.py +158 -0
  32. features/action_items/utils.py +44 -0
  33. features/hierarchy/__init__.py +41 -0
  34. features/hierarchy/monitor.py +208 -0
  35. features/hierarchy/request.py +221 -0
  36. features/reports/__init__.py +25 -0
  37. features/reports/full_report.py +325 -0
  38. features/reports/overview.py +220 -0
  39. features/reports/summary.py +31 -0
  40. features/reports/utils.py +469 -0
  41. features/research/__init__.py +189 -0
  42. features/research/content_actions.py +217 -0
  43. features/research/cross_model.py +652 -0
  44. features/research/distribution.py +186 -0
  45. features/research/guide.py +143 -0
  46. features/research/keyword_suggest.py +195 -0
  47. features/research/opportunities.py +173 -0
  48. features/research/summary.py +48 -0
  49. features/research/topic_map.py +108 -0
  50. 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} &middot; {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
+ )