ginipick commited on
Commit
983f442
Β·
verified Β·
1 Parent(s): bc06335

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +1008 -0
app.py ADDED
@@ -0,0 +1,1008 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI 기반 μƒκΆŒ 뢄석 μ‹œμŠ€ν…œ - 슀트리밍 κ°•ν™” 버전 + Brave 웹검색
3
+ Dataset: https://huggingface.co/datasets/ginipick/market
4
+ """
5
+ import gradio as gr
6
+ import pandas as pd
7
+ import numpy as np
8
+ from typing import Dict, List, Tuple
9
+ import json
10
+ from datasets import load_dataset
11
+ import plotly.express as px
12
+ import plotly.graph_objects as go
13
+ from plotly.subplots import make_subplots
14
+ import folium
15
+ from folium.plugins import HeatMap, MarkerCluster
16
+ import requests
17
+ from collections import Counter
18
+ import re
19
+ import os
20
+ import time
21
+
22
+ # ============================================================================
23
+ # Brave 웹검색 ν΄λΌμ΄μ–ΈνŠΈ
24
+ # ============================================================================
25
+
26
+ class BraveSearchClient:
27
+ """Brave Search API ν΄λΌμ΄μ–ΈνŠΈ"""
28
+
29
+ def __init__(self, api_key: str = None):
30
+ self.api_key = api_key or os.getenv("BRAVE_API_KEY")
31
+ self.base_url = "https://api.search.brave.com/res/v1/web/search"
32
+
33
+ def search(self, query: str, count: int = 5) -> str:
34
+ """μ›Ή 검색 μˆ˜ν–‰"""
35
+ if not self.api_key:
36
+ return "⚠️ Brave Search API ν‚€κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€."
37
+
38
+ headers = {
39
+ "Accept": "application/json",
40
+ "X-Subscription-Token": self.api_key
41
+ }
42
+
43
+ params = {
44
+ "q": query,
45
+ "count": count,
46
+ "text_decorations": False,
47
+ "search_lang": "ko"
48
+ }
49
+
50
+ try:
51
+ response = requests.get(self.base_url, headers=headers, params=params, timeout=10)
52
+ if response.status_code == 200:
53
+ data = response.json()
54
+ results = []
55
+
56
+ if 'web' in data and 'results' in data['web']:
57
+ for item in data['web']['results'][:count]:
58
+ title = item.get('title', '')
59
+ description = item.get('description', '')
60
+ url = item.get('url', '')
61
+ results.append(f"πŸ“„ **{title}**\n{description}\nπŸ”— {url}")
62
+
63
+ return "\n\n".join(results) if results else "검색 κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€."
64
+ else:
65
+ return f"⚠️ 검색 μ‹€νŒ¨: {response.status_code}"
66
+ except Exception as e:
67
+ return f"⚠️ 검색 였λ₯˜: {str(e)}"
68
+
69
+
70
+ # ============================================================================
71
+ # 데이터 λ‘œλ” 클래슀
72
+ # ============================================================================
73
+
74
+ class MarketDataLoader:
75
+ """ν—ˆκΉ…νŽ˜μ΄μŠ€ μƒκΆŒ 데이터 λ‘œλ”"""
76
+
77
+ REGIONS = {
78
+ 'μ„œμšΈ': 'μ„œμšΈ_202506', 'κ²½κΈ°': 'κ²½κΈ°_202506', 'λΆ€μ‚°': 'λΆ€μ‚°_202506',
79
+ 'λŒ€κ΅¬': 'λŒ€κ΅¬_202506', '인천': '인천_202506', 'κ΄‘μ£Ό': 'κ΄‘μ£Ό_202506',
80
+ 'λŒ€μ „': 'λŒ€μ „_202506', 'μšΈμ‚°': 'μšΈμ‚°_202506', 'μ„Έμ’…': 'μ„Έμ’…_202506',
81
+ '경남': '경남_202506', '경뢁': '경뢁_202506', '전남': '전남_202506',
82
+ '전뢁': '전뢁_202506', '좩남': '좩남_202506', '좩뢁': '좩뢁_202506',
83
+ '강원': '강원_202506', '제주': '제주_202506'
84
+ }
85
+
86
+ # μ—…μ’… λΆ„λ₯˜ λ§€ν•‘
87
+ CATEGORY_MAPPING = {
88
+ 'G2': 'μ†Œλ§€μ—…',
89
+ 'I1': 'μˆ™λ°•μ—…',
90
+ 'I2': 'μŒμ‹μ μ—…',
91
+ 'L1': '뢀동산업',
92
+ 'M1': 'μ „λ¬Έ/κ³Όν•™/기술',
93
+ 'N1': '사업지원/μž„λŒ€',
94
+ 'P1': 'κ΅μœ‘μ„œλΉ„μŠ€',
95
+ 'Q1': '보건의료',
96
+ 'R1': '예술/슀포츠/μ—¬κ°€',
97
+ 'S2': '수리/κ°œμΈμ„œλΉ„μŠ€'
98
+ }
99
+
100
+ @staticmethod
101
+ def load_region_data(region: str, sample_size: int = 30000) -> pd.DataFrame:
102
+ """지역별 데이터 λ‘œλ“œ"""
103
+ try:
104
+ file_name = f"μ†Œμƒκ³΅μΈμ‹œμž₯μ§„ν₯곡단_상가(μƒκΆŒ)정보_{MarketDataLoader.REGIONS[region]}.csv"
105
+ dataset = load_dataset("ginipick/market", data_files=file_name, split="train")
106
+ df = dataset.to_pandas()
107
+
108
+ if len(df) > sample_size:
109
+ df = df.sample(n=sample_size, random_state=42)
110
+
111
+ return df
112
+ except Exception as e:
113
+ print(f"데이터 λ‘œλ“œ μ‹€νŒ¨: {str(e)}")
114
+ return pd.DataFrame()
115
+
116
+ @staticmethod
117
+ def load_multiple_regions(regions: List[str], sample_per_region: int = 30000) -> pd.DataFrame:
118
+ """μ—¬λŸ¬ μ§€μ—­ 데이터 λ‘œλ“œ"""
119
+ dfs = []
120
+ for region in regions:
121
+ df = MarketDataLoader.load_region_data(region, sample_per_region)
122
+ if not df.empty:
123
+ dfs.append(df)
124
+
125
+ if dfs:
126
+ return pd.concat(dfs, ignore_index=True)
127
+ return pd.DataFrame()
128
+
129
+
130
+ # ============================================================================
131
+ # μƒκΆŒ 뢄석 클래슀
132
+ # ============================================================================
133
+
134
+ class MarketAnalyzer:
135
+ """μƒκΆŒ 데이터 뢄석 μ—”μ§„"""
136
+
137
+ def __init__(self, df: pd.DataFrame):
138
+ self.df = df
139
+ self.prepare_data()
140
+
141
+ def prepare_data(self):
142
+ """데이터 μ „μ²˜λ¦¬"""
143
+ if '경도' in self.df.columns:
144
+ self.df['경도'] = pd.to_numeric(self.df['경도'], errors='coerce')
145
+ if 'μœ„λ„' in self.df.columns:
146
+ self.df['μœ„λ„'] = pd.to_numeric(self.df['μœ„λ„'], errors='coerce')
147
+ self.df = self.df.dropna(subset=['경도', 'μœ„λ„'])
148
+
149
+ # μΈ΅ 정보 μ •μ œ
150
+ if '측정보' in self.df.columns:
151
+ self.df['측정보_숫자'] = self.df['측정보'].apply(self._parse_floor)
152
+
153
+ def _parse_floor(self, floor_str):
154
+ """μΈ΅ 정보λ₯Ό 숫자둜 λ³€ν™˜"""
155
+ if pd.isna(floor_str):
156
+ return None
157
+ floor_str = str(floor_str)
158
+ if 'μ§€ν•˜' in floor_str or 'B' in floor_str:
159
+ match = re.search(r'\d+', floor_str)
160
+ return -int(match.group()) if match else -1
161
+ elif '1μΈ΅' in floor_str or floor_str == '1':
162
+ return 1
163
+ else:
164
+ match = re.search(r'\d+', floor_str)
165
+ return int(match.group()) if match else None
166
+
167
+ def get_comprehensive_insights(self) -> List[Dict]:
168
+ """포괄적인 μΈμ‚¬μ΄νŠΈ 생성"""
169
+ insights = []
170
+
171
+ # 1. 업쒅별 점포 수 (μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜)
172
+ insights.append(self._create_top_categories_chart())
173
+
174
+ # 2. λŒ€λΆ„λ₯˜λ³„ 뢄포 (파이 차트)
175
+ insights.append(self._create_major_category_pie())
176
+
177
+ # 3. 측별 뢄포 상세 뢄석
178
+ insights.append(self._create_floor_analysis())
179
+
180
+ # 4. 지역별 μ—…μ’… λ‹€μ–‘μ„± μ§€μˆ˜
181
+ insights.append(self._create_diversity_index())
182
+
183
+ # 5. ν”„λžœμ°¨μ΄μ¦ˆ vs κ°œμΈμ‚¬μ—…μž 뢄석
184
+ insights.append(self._create_franchise_analysis())
185
+
186
+ # 6. 업쒅별 μΈ΅ μ„ ν˜Έλ„
187
+ insights.append(self._create_floor_preference())
188
+
189
+ # 7. μ‹œκ΅°κ΅¬λ³„ μƒκΆŒ 밀집도 TOP 20
190
+ insights.append(self._create_district_density())
191
+
192
+ # 8. μ—…μ’… 상관관계 (같은 지역에 자주 λ‚˜νƒ€λ‚˜λŠ” μ—…μ’…)
193
+ insights.append(self._create_category_correlation())
194
+
195
+ # 9. μ†ŒλΆ„λ₯˜ νŠΈλ Œλ“œ (μƒμœ„ 20개)
196
+ insights.append(self._create_subcategory_trends())
197
+
198
+ # 10. 지역별 νŠΉν™” μ—…μ’…
199
+ insights.append(self._create_regional_specialization())
200
+
201
+ return insights
202
+
203
+ def _create_top_categories_chart(self) -> Dict:
204
+ """업쒅별 점포 수 차트"""
205
+ if 'μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜λͺ…' not in self.df.columns:
206
+ return None
207
+
208
+ top_categories = self.df['μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜λͺ…'].value_counts().head(15)
209
+ fig = px.bar(
210
+ x=top_categories.values,
211
+ y=top_categories.index,
212
+ orientation='h',
213
+ labels={'x': '점포 수', 'y': 'μ—…μ’…'},
214
+ title='πŸ† μƒμœ„ μ—…μ’… TOP 15',
215
+ color=top_categories.values,
216
+ color_continuous_scale='blues'
217
+ )
218
+ fig.update_layout(showlegend=False, height=500)
219
+ return {'type': 'plot', 'data': fig, 'title': '업쒅별 점포 수 뢄석'}
220
+
221
+ def _create_major_category_pie(self) -> Dict:
222
+ """λŒ€λΆ„λ₯˜λ³„ 뢄포"""
223
+ if 'μƒκΆŒμ—…μ’…λŒ€λΆ„λ₯˜μ½”λ“œ' not in self.df.columns:
224
+ return None
225
+
226
+ major_counts = self.df['μƒκΆŒμ—…μ’…λŒ€λΆ„λ₯˜μ½”λ“œ'].value_counts()
227
+ labels = [MarketDataLoader.CATEGORY_MAPPING.get(code, code) for code in major_counts.index]
228
+
229
+ fig = px.pie(
230
+ values=major_counts.values,
231
+ names=labels,
232
+ title='πŸ“Š μ—…μ’… λŒ€λΆ„λ₯˜ 뢄포',
233
+ hole=0.4,
234
+ color_discrete_sequence=px.colors.qualitative.Set3
235
+ )
236
+ fig.update_traces(textposition='inside', textinfo='percent+label')
237
+ return {'type': 'plot', 'data': fig, 'title': 'λŒ€λΆ„λ₯˜λ³„ μƒκΆŒ ꡬ성'}
238
+
239
+ def _create_floor_analysis(self) -> Dict:
240
+ """측별 뢄포 상세 뢄석"""
241
+ if '측정보_숫자' not in self.df.columns:
242
+ return None
243
+
244
+ floor_data = self.df['측정보_숫자'].dropna()
245
+ floor_counts = floor_data.value_counts().sort_index()
246
+
247
+ # μ§€ν•˜, 1μΈ΅, 2μΈ΅ μ΄μƒμœΌλ‘œ κ·Έλ£Ήν™”
248
+ underground = floor_counts[floor_counts.index < 0].sum()
249
+ first_floor = floor_counts.get(1, 0)
250
+ upper_floors = floor_counts[floor_counts.index > 1].sum()
251
+
252
+ fig = go.Figure(data=[
253
+ go.Bar(
254
+ x=['μ§€ν•˜', '1μΈ΅', '2μΈ΅ 이상'],
255
+ y=[underground, first_floor, upper_floors],
256
+ text=[f'{underground:,}<br>({underground/len(floor_data)*100:.1f}%)',
257
+ f'{first_floor:,}<br>({first_floor/len(floor_data)*100:.1f}%)',
258
+ f'{upper_floors:,}<br>({upper_floors/len(floor_data)*100:.1f}%)'],
259
+ textposition='auto',
260
+ marker_color=['#e74c3c', '#3498db', '#95a5a6']
261
+ )
262
+ ])
263
+ fig.update_layout(
264
+ title='🏒 측별 점포 뢄포 (μ§€ν•˜ vs 1μΈ΅ vs 상측)',
265
+ xaxis_title='μΈ΅ ꡬ뢄',
266
+ yaxis_title='점포 수',
267
+ height=400
268
+ )
269
+ return {'type': 'plot', 'data': fig, 'title': '측별 μž…μ§€ 뢄석'}
270
+
271
+ def _create_diversity_index(self) -> Dict:
272
+ """지역별 μ—…μ’… λ‹€μ–‘μ„± μ§€μˆ˜"""
273
+ if 'μ‹œκ΅°κ΅¬λͺ…' not in self.df.columns or 'μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜λͺ…' not in self.df.columns:
274
+ return None
275
+
276
+ # 각 μ‹œκ΅°κ΅¬λ³„ μ—…μ’… λ‹€μ–‘μ„± 계산 (μ—…μ’… 수 / 전체 점포 수)
277
+ diversity_data = []
278
+ for district in self.df['μ‹œκ΅°κ΅¬λͺ…'].unique()[:20]: # μƒμœ„ 20개
279
+ district_df = self.df[self.df['μ‹œκ΅°κ΅¬λͺ…'] == district]
280
+ num_categories = district_df['μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜λͺ…'].nunique()
281
+ total_stores = len(district_df)
282
+ diversity_score = (num_categories / total_stores) * 100
283
+ diversity_data.append({
284
+ 'μ§€μ—­': district,
285
+ 'λ‹€μ–‘μ„±μ§€μˆ˜': diversity_score,
286
+ 'μ—…μ’…μˆ˜': num_categories,
287
+ '점포수': total_stores
288
+ })
289
+
290
+ diversity_df = pd.DataFrame(diversity_data).sort_values('λ‹€μ–‘μ„±μ§€μˆ˜', ascending=False)
291
+
292
+ fig = px.bar(
293
+ diversity_df,
294
+ x='λ‹€μ–‘μ„±μ§€μˆ˜',
295
+ y='μ§€μ—­',
296
+ orientation='h',
297
+ title='🎨 지역별 μ—…μ’… λ‹€μ–‘μ„± μ§€μˆ˜ (μ—…μ’… 수 / 점포 수 Γ— 100)',
298
+ labels={'λ‹€μ–‘μ„±μ§€μˆ˜': 'λ‹€μ–‘μ„± μ§€μˆ˜', 'μ§€μ—­': 'μ‹œκ΅°κ΅¬'},
299
+ color='λ‹€μ–‘μ„±μ§€μˆ˜',
300
+ color_continuous_scale='viridis'
301
+ )
302
+ fig.update_layout(height=500)
303
+ return {'type': 'plot', 'data': fig, 'title': 'μƒκΆŒ λ‹€μ–‘μ„± 뢄석'}
304
+
305
+ def _create_franchise_analysis(self) -> Dict:
306
+ """ν”„λžœμ°¨μ΄μ¦ˆ vs κ°œμΈμ‚¬μ—…μž 뢄석"""
307
+ if 'λΈŒλžœλ“œλͺ…' not in self.df.columns:
308
+ return None
309
+
310
+ # λΈŒλžœλ“œλͺ…이 있으면 ν”„λžœμ°¨μ΄μ¦ˆλ‘œ κ°„μ£Ό
311
+ franchise_count = self.df['λΈŒλžœλ“œλͺ…'].notna().sum()
312
+ individual_count = self.df['λΈŒλžœλ“œλͺ…'].isna().sum()
313
+
314
+ fig = go.Figure(data=[
315
+ go.Pie(
316
+ labels=['κ°œμΈμ‚¬μ—…μž', 'ν”„λžœμ°¨μ΄μ¦ˆ'],
317
+ values=[individual_count, franchise_count],
318
+ hole=0.4,
319
+ marker_colors=['#3498db', '#e74c3c'],
320
+ textinfo='label+percent+value',
321
+ texttemplate='%{label}<br>%{value:,}개<br>(%{percent})'
322
+ )
323
+ ])
324
+
325
+ fig.update_layout(
326
+ title='πŸͺ κ°œμΈμ‚¬μ—…μž vs ν”„λžœμ°¨μ΄μ¦ˆ λΉ„μœ¨',
327
+ height=400
328
+ )
329
+ return {'type': 'plot', 'data': fig, 'title': 'μ‚¬μ—…μž μœ ν˜• 뢄석'}
330
+
331
+ def _create_floor_preference(self) -> Dict:
332
+ """업쒅별 μΈ΅ μ„ ν˜Έλ„"""
333
+ if '측정보_숫자' not in self.df.columns or 'μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜λͺ…' not in self.df.columns:
334
+ return None
335
+
336
+ # μƒμœ„ 10개 μ—…μ’… 선택
337
+ top_categories = self.df['μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜λͺ…'].value_counts().head(10).index
338
+ floor_pref_data = []
339
+
340
+ for category in top_categories:
341
+ cat_df = self.df[self.df['μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜λͺ…'] == category]
342
+ floor_dist = cat_df['측정보_숫자'].dropna()
343
+
344
+ if len(floor_dist) > 0:
345
+ underground = (floor_dist < 0).sum()
346
+ first_floor = (floor_dist == 1).sum()
347
+ upper_floors = (floor_dist > 1).sum()
348
+
349
+ floor_pref_data.append({
350
+ 'μ—…μ’…': category,
351
+ 'μ§€ν•˜': underground,
352
+ '1μΈ΅': first_floor,
353
+ '2μΈ΅ 이상': upper_floors
354
+ })
355
+
356
+ pref_df = pd.DataFrame(floor_pref_data)
357
+
358
+ fig = go.Figure()
359
+ fig.add_trace(go.Bar(name='μ§€ν•˜', x=pref_df['μ—…μ’…'], y=pref_df['μ§€ν•˜'], marker_color='#e74c3c'))
360
+ fig.add_trace(go.Bar(name='1μΈ΅', x=pref_df['μ—…μ’…'], y=pref_df['1μΈ΅'], marker_color='#3498db'))
361
+ fig.add_trace(go.Bar(name='2μΈ΅ 이상', x=pref_df['μ—…μ’…'], y=pref_df['2μΈ΅ 이상'], marker_color='#95a5a6'))
362
+
363
+ fig.update_layout(
364
+ title='🏒 업쒅별 μΈ΅ μ„ ν˜Έλ„ (μƒμœ„ 10개 μ—…μ’…)',
365
+ xaxis_title='μ—…μ’…',
366
+ yaxis_title='점포 수',
367
+ barmode='stack',
368
+ height=500,
369
+ xaxis_tickangle=-45
370
+ )
371
+ return {'type': 'plot', 'data': fig, 'title': '측별 μ„ ν˜Έλ„ 뢄석'}
372
+
373
+ def _create_district_density(self) -> Dict:
374
+ """μ‹œκ΅°κ΅¬λ³„ μƒκΆŒ 밀집도"""
375
+ if 'μ‹œκ΅°κ΅¬λͺ…' not in self.df.columns:
376
+ return None
377
+
378
+ district_counts = self.df['μ‹œκ΅°κ΅¬λͺ…'].value_counts().head(20)
379
+
380
+ fig = px.bar(
381
+ x=district_counts.values,
382
+ y=district_counts.index,
383
+ orientation='h',
384
+ title='πŸ“ μ‹œκ΅°κ΅¬λ³„ 점포 밀집도 TOP 20',
385
+ labels={'x': '점포 수', 'y': 'μ‹œκ΅°κ΅¬'},
386
+ color=district_counts.values,
387
+ color_continuous_scale='reds'
388
+ )
389
+ fig.update_layout(showlegend=False, height=600)
390
+ return {'type': 'plot', 'data': fig, 'title': 'μ§€μ—­ 밀집도 뢄석'}
391
+
392
+ def _create_category_correlation(self) -> Dict:
393
+ """μ—…μ’… 상관관계 (같은 지역에 자주 λ‚˜νƒ€λ‚˜λŠ” μ—…μ’…)"""
394
+ if 'μ‹œκ΅°κ΅¬λͺ…' not in self.df.columns or 'μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜λͺ…' not in self.df.columns:
395
+ return None
396
+
397
+ # μƒμœ„ 10개 μ—…μ’…λ§Œ 선택
398
+ top_categories = self.df['μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜λͺ…'].value_counts().head(10).index.tolist()
399
+
400
+ # 각 μ‹œκ΅°κ΅¬λ³„λ‘œ μ—…μ’… 쑴재 μ—¬λΆ€ 맀트릭슀 생성
401
+ districts = self.df['μ‹œκ΅°κ΅¬λͺ…'].unique()
402
+ correlation_matrix = np.zeros((len(top_categories), len(top_categories)))
403
+
404
+ for i, cat1 in enumerate(top_categories):
405
+ for j, cat2 in enumerate(top_categories):
406
+ if i != j:
407
+ # 두 업쒅이 같은 μ‹œκ΅°κ΅¬μ— μ‘΄μž¬ν•˜λŠ” 횟수
408
+ coexist_count = 0
409
+ for district in districts:
410
+ district_df = self.df[self.df['μ‹œκ΅°κ΅¬λͺ…'] == district]
411
+ has_cat1 = cat1 in district_df['μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜λͺ…'].values
412
+ has_cat2 = cat2 in district_df['μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜λͺ…'].values
413
+ if has_cat1 and has_cat2:
414
+ coexist_count += 1
415
+ correlation_matrix[i][j] = coexist_count
416
+
417
+ fig = go.Figure(data=go.Heatmap(
418
+ z=correlation_matrix,
419
+ x=top_categories,
420
+ y=top_categories,
421
+ colorscale='Blues',
422
+ text=np.round(correlation_matrix, 1),
423
+ texttemplate='%{text}',
424
+ textfont={"size": 10}
425
+ ))
426
+
427
+ fig.update_layout(
428
+ title='πŸ”— μ—…μ’… 상관관계 맀트릭슀 (같은 μ§€μ—­ λ™μ‹œ μΆœν˜„μœ¨)',
429
+ xaxis_title='μ—…μ’…',
430
+ yaxis_title='μ—…μ’…',
431
+ height=600,
432
+ xaxis_tickangle=-45
433
+ )
434
+ return {'type': 'plot', 'data': fig, 'title': 'μ—…μ’… 곡쑴 뢄석'}
435
+
436
+ def _create_subcategory_trends(self) -> Dict:
437
+ """μ†ŒλΆ„λ₯˜ νŠΈλ Œλ“œ"""
438
+ if 'μƒκΆŒμ—…μ’…μ†ŒλΆ„λ₯˜λͺ…' not in self.df.columns:
439
+ return None
440
+
441
+ subcat_counts = self.df['μƒκΆŒμ—…μ’…μ†ŒλΆ„λ₯˜λͺ…'].value_counts().head(20)
442
+
443
+ fig = px.treemap(
444
+ names=subcat_counts.index,
445
+ parents=[''] * len(subcat_counts),
446
+ values=subcat_counts.values,
447
+ title='πŸ” μ†ŒλΆ„λ₯˜ μ—…μ’… νŠΈλ Œλ“œ TOP 20',
448
+ color=subcat_counts.values,
449
+ color_continuous_scale='greens'
450
+ )
451
+ fig.update_layout(height=600)
452
+ return {'type': 'plot', 'data': fig, 'title': 'μ„ΈλΆ€ μ—…μ’… 뢄석'}
453
+
454
+ def _create_regional_specialization(self) -> Dict:
455
+ """지역별 νŠΉν™” μ—…μ’…"""
456
+ if 'μ‹œλ„λͺ…' not in self.df.columns or 'μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜λͺ…' not in self.df.columns:
457
+ return None
458
+
459
+ # 각 μ‹œλ„λ³„ μƒμœ„ 3개 μ—…μ’…
460
+ specialization_data = []
461
+ for region in self.df['μ‹œλ„λͺ…'].unique():
462
+ region_df = self.df[self.df['μ‹œλ„λͺ…'] == region]
463
+ top_categories = region_df['μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜λͺ…'].value_counts().head(3)
464
+ for category, count in top_categories.items():
465
+ specialization_data.append({
466
+ 'μ§€μ—­': region,
467
+ 'νŠΉν™”μ—…μ’…': category,
468
+ '점포수': count
469
+ })
470
+
471
+ spec_df = pd.DataFrame(specialization_data)
472
+
473
+ fig = px.sunburst(
474
+ spec_df,
475
+ path=['μ§€μ—­', 'νŠΉν™”μ—…μ’…'],
476
+ values='점포수',
477
+ title='🎯 지역별 νŠΉν™” μ—…μ’… (각 μ§€μ—­ TOP 3)',
478
+ color='점포수',
479
+ color_continuous_scale='oranges'
480
+ )
481
+ fig.update_layout(height=700)
482
+ return {'type': 'plot', 'data': fig, 'title': 'μ§€μ—­ νŠΉν™” 뢄석'}
483
+
484
+ def create_density_map(self, sample_size: int = 1000) -> str:
485
+ """점포 밀집도 지도 생성"""
486
+ df_sample = self.df.sample(n=min(sample_size, len(self.df)), random_state=42)
487
+
488
+ center_lat = df_sample['μœ„λ„'].mean()
489
+ center_lon = df_sample['경도'].mean()
490
+
491
+ m = folium.Map(location=[center_lat, center_lon], zoom_start=11, tiles='OpenStreetMap')
492
+
493
+ # 히트맡
494
+ heat_data = [[row['μœ„λ„'], row['경도']] for _, row in df_sample.iterrows()]
495
+ HeatMap(heat_data, radius=15, blur=25, max_zoom=13).add_to(m)
496
+
497
+ return m._repr_html_()
498
+
499
+ def analyze_for_llm(self) -> Dict:
500
+ """LLM μ»¨ν…μŠ€νŠΈμš© 뢄석 데이터"""
501
+ context = {
502
+ '총_점포_수': len(self.df),
503
+ 'μ§€μ—­_수': self.df['μ‹œλ„λͺ…'].nunique() if 'μ‹œλ„λͺ…' in self.df.columns else 0,
504
+ 'μ—…μ’…_수': self.df['μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜λͺ…'].nunique() if 'μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜λͺ…' in self.df.columns else 0,
505
+ }
506
+
507
+ if 'μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜λͺ…' in self.df.columns:
508
+ context['μƒμœ„_μ—…μ’…_5'] = self.df['μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜λͺ…'].value_counts().head(5).to_dict()
509
+
510
+ if '측정보_숫자' in self.df.columns:
511
+ first_floor_ratio = (self.df['측정보_숫자'] == 1).sum() / len(self.df) * 100
512
+ context['1μΈ΅_λΉ„μœ¨'] = f"{first_floor_ratio:.1f}%"
513
+
514
+ return context
515
+
516
+
517
+ # ============================================================================
518
+ # LLM 쿼리 ν”„λ‘œμ„Έμ„œ (슀트리밍 지원 + 웹검색)
519
+ # ============================================================================
520
+
521
+ class LLMQueryProcessor:
522
+ """Fireworks AI 기반 μžμ—°μ–΄ 처리 (슀트리밍 지원 + 웹검색)"""
523
+
524
+ def __init__(self, api_key: str = None):
525
+ # ν™˜κ²½λ³€μˆ˜μ—μ„œ API ν‚€ κ°€μ Έμ˜€κΈ°
526
+ self.api_key = api_key or os.getenv("FIREWORKS_API_KEY")
527
+ self.base_url = "https://api.fireworks.ai/inference/v1/chat/completions"
528
+
529
+ if not self.api_key:
530
+ raise ValueError("❌ FIREWORKS_API_KEY ν™˜κ²½λ³€μˆ˜λ₯Ό μ„€μ •ν•˜κ±°λ‚˜ API ν‚€λ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”!")
531
+
532
+ def process_query_stream(self, query: str, data_context: Dict, chat_history: List = None, web_search_results: str = None):
533
+ """μžμ—°μ–΄ 쿼리 처리 (슀트리밍 λͺ¨λ“œ) - 웹검색 κ²°κ³Ό 포함"""
534
+
535
+ # 웹검색 κ²°κ³Όκ°€ 있으면 μ»¨ν…μŠ€νŠΈμ— μΆ”κ°€
536
+ web_context = ""
537
+ if web_search_results and "⚠️" not in web_search_results:
538
+ web_context = f"""
539
+
540
+ 🌐 **μ΅œμ‹  μ›Ή 검색 정보**
541
+ {web_search_results}
542
+
543
+ μœ„ μ›Ή 검색 κ²°κ³Όλ₯Ό μ°Έκ³ ν•˜μ—¬ μ΅œμ‹  정보와 νŠΈλ Œλ“œλ₯Ό λ°˜μ˜ν•΄μ£Όμ„Έμš”.
544
+ """
545
+
546
+ system_prompt = f"""당신은 ν•œκ΅­ μƒκΆŒ 데이터 뢄석 μ „λ¬Έκ°€μž…λ‹ˆλ‹€.
547
+
548
+ πŸ“Š **ν˜„μž¬ 뢄석 데이터**
549
+ {json.dumps(data_context, ensure_ascii=False, indent=2)}
550
+ {web_context}
551
+
552
+ ꡬ체적인 μˆ«μžμ™€ λΉ„μœ¨λ‘œ μ •λŸ‰μ  뢄석을 μ œκ³΅ν•˜μ„Έμš”.
553
+ μ°½μ—…, 투자, 경쟁 뢄석 κ΄€μ μ—μ„œ μ‹€μš©μ  μΈμ‚¬μ΄νŠΈλ₯Ό μ œκ³΅ν•˜μ„Έμš”.
554
+ μ›Ή 검색 κ²°κ³Όκ°€ 제곡된 경우 μ΅œμ‹  νŠΈλ Œλ“œμ™€ ν•¨κ»˜ λΆ„μ„ν•˜μ„Έμš”."""
555
+
556
+ messages = [{"role": "system", "content": system_prompt}]
557
+ if chat_history:
558
+ messages.extend(chat_history[-6:])
559
+ messages.append({"role": "user", "content": query})
560
+
561
+ payload = {
562
+ "model": "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507",
563
+ "max_tokens": 4800,
564
+ "temperature": 0.7,
565
+ "messages": messages,
566
+ "stream": True # πŸ”₯ 슀트리밍 ν™œμ„±ν™”!
567
+ }
568
+
569
+ headers = {
570
+ "Authorization": f"Bearer {self.api_key}",
571
+ "Content-Type": "application/json"
572
+ }
573
+
574
+ try:
575
+ response = requests.post(
576
+ self.base_url,
577
+ headers=headers,
578
+ json=payload,
579
+ timeout=60,
580
+ stream=True # 슀트리밍 λͺ¨λ“œ
581
+ )
582
+
583
+ if response.status_code == 200:
584
+ # 슀트리밍 응닡 처리
585
+ for line in response.iter_lines():
586
+ if line:
587
+ line_text = line.decode('utf-8')
588
+ if line_text.startswith('data: '):
589
+ data_str = line_text[6:] # 'data: ' 제거
590
+ if data_str.strip() == '[DONE]':
591
+ break
592
+ try:
593
+ data = json.loads(data_str)
594
+ if 'choices' in data and len(data['choices']) > 0:
595
+ delta = data['choices'][0].get('delta', {})
596
+ content = delta.get('content', '')
597
+ if content:
598
+ yield content
599
+ except json.JSONDecodeError:
600
+ continue
601
+ else:
602
+ yield f"⚠️ API 였λ₯˜: {response.status_code}"
603
+
604
+ except requests.exceptions.Timeout:
605
+ yield "⚠️ API 응닡 μ‹œκ°„ 초과. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."
606
+ except requests.exceptions.ConnectionError:
607
+ yield "⚠️ λ„€νŠΈμ›Œν¬ μ—°κ²° 였λ₯˜. 인터넷 연결을 ν™•μΈν•΄μ£Όμ„Έμš”."
608
+ except Exception as e:
609
+ yield f"❌ 였λ₯˜: {str(e)}"
610
+
611
+
612
+ # ============================================================================
613
+ # μ „μ—­ μƒνƒœ
614
+ # ============================================================================
615
+
616
+ class AppState:
617
+ def __init__(self):
618
+ self.analyzer = None
619
+ self.llm_processor = None
620
+ self.brave_client = None
621
+ self.chat_history = []
622
+
623
+ app_state = AppState()
624
+
625
+
626
+ # ============================================================================
627
+ # Gradio μΈν„°νŽ˜μ΄μŠ€ ν•¨μˆ˜
628
+ # ============================================================================
629
+
630
+ def load_data(regions):
631
+ """데이터 λ‘œλ“œ"""
632
+ if not regions:
633
+ return "❌ μ΅œμ†Œ 1개 지역을 μ„ νƒν•΄μ£Όμ„Έμš”!", None, None, None
634
+
635
+ try:
636
+ df = MarketDataLoader.load_multiple_regions(regions, sample_per_region=30000)
637
+ if df.empty:
638
+ return "❌ 데이터 λ‘œλ“œ μ‹€νŒ¨!", None, None, None
639
+
640
+ app_state.analyzer = MarketAnalyzer(df)
641
+
642
+ # κΈ°λ³Έ 톡계
643
+ stats = f"""
644
+ βœ… **데이터 λ‘œλ“œ μ™„λ£Œ!**
645
+
646
+ πŸ“Š **톡계**
647
+ - 총 점포: {len(df):,}개
648
+ - 뢄석 μ§€μ—­: {', '.join(regions)}
649
+ - μ—…μ’… 수: {df['μƒκΆŒμ—…μ’…μ€‘λΆ„λ₯˜λͺ…'].nunique()}개
650
+ - λŒ€λΆ„λ₯˜: {df['μƒκΆŒμ—…μ’…λŒ€λΆ„λ₯˜λͺ…'].nunique()}개
651
+ """
652
+
653
+ return stats, gr.update(visible=True), gr.update(visible=True), gr.update(visible=True)
654
+ except Exception as e:
655
+ return f"❌ 였λ₯˜: {str(e)}", None, None, None
656
+
657
+
658
+ def generate_insights():
659
+ """μΈμ‚¬μ΄νŠΈ 생성"""
660
+ if app_state.analyzer is None:
661
+ return [None] * 11
662
+
663
+ insights = app_state.analyzer.get_comprehensive_insights()
664
+ map_html = app_state.analyzer.create_density_map(sample_size=2000)
665
+
666
+ result = [map_html]
667
+ for insight in insights:
668
+ if insight and insight['type'] == 'plot':
669
+ result.append(insight['data'])
670
+ else:
671
+ result.append(None)
672
+
673
+ # λΆ€μ‘±ν•œ μ°¨νŠΈλŠ” None으둜 μ±„μš°κΈ°
674
+ while len(result) < 11:
675
+ result.append(None)
676
+
677
+ return result[:11]
678
+
679
+
680
+ def chat_respond(message, history):
681
+ """챗봇 응닡 (슀트리밍 λͺ¨λ“œ + 웹검색) - Generator ν•¨μˆ˜"""
682
+ if app_state.analyzer is None:
683
+ yield history + [[message, "❌ λ¨Όμ € 데이터λ₯Ό λ‘œλ“œν•΄μ£Όμ„Έμš”!"]]
684
+ return
685
+
686
+ data_context = app_state.analyzer.analyze_for_llm()
687
+
688
+ try:
689
+ # LLM ν”„λ‘œμ„Έμ„œ μ΄ˆκΈ°ν™”
690
+ if app_state.llm_processor is None:
691
+ app_state.llm_processor = LLMQueryProcessor()
692
+
693
+ # Brave Search ν΄λΌμ΄μ–ΈνŠΈ μ΄ˆκΈ°ν™”
694
+ if app_state.brave_client is None:
695
+ try:
696
+ app_state.brave_client = BraveSearchClient()
697
+ except:
698
+ app_state.brave_client = None
699
+
700
+ # πŸ” μžλ™ 웹검색 μˆ˜ν–‰
701
+ web_results = None
702
+ if app_state.brave_client and app_state.brave_client.api_key:
703
+ # 검색 쿼리 생성 (μ‚¬μš©μž μ§ˆλ¬Έμ—μ„œ 핡심 ν‚€μ›Œλ“œ μΆ”μΆœ)
704
+ search_query = f"ν•œκ΅­ μƒκΆŒ μ°½μ—… νŠΈλ Œλ“œ {message}"
705
+ web_results = app_state.brave_client.search(search_query, count=3)
706
+
707
+ # λŒ€ν™” νžˆμŠ€ν† λ¦¬ ꡬ성
708
+ chat_hist = []
709
+ for user_msg, bot_msg in history:
710
+ chat_hist.append({"role": "user", "content": user_msg})
711
+ chat_hist.append({"role": "assistant", "content": bot_msg})
712
+
713
+ # μƒˆ λ©”μ‹œμ§€ μΆ”κ°€
714
+ history = history + [[message, ""]]
715
+
716
+ # 웹검색 μƒνƒœ ν‘œμ‹œ
717
+ if web_results and "⚠️" not in web_results:
718
+ history[-1][1] = "πŸ” μ›Ή 검색 쀑...\n\n"
719
+ yield history
720
+
721
+ # 슀트리밍 응닡 (웹검색 κ²°κ³Ό 포함)
722
+ full_response = ""
723
+ for chunk in app_state.llm_processor.process_query_stream(message, data_context, chat_hist, web_results):
724
+ full_response += chunk
725
+ history[-1][1] = full_response
726
+ yield history
727
+
728
+ except ValueError as e:
729
+ # API ν‚€κ°€ μ—†λŠ” 경우 κΈ°λ³Έ 톡계 제곡
730
+ response = f"""πŸ“Š **κΈ°λ³Έ 데이터 뢄석 κ²°κ³Ό**
731
+
732
+ **전체 ν˜„ν™©**
733
+ - 총 점포 수: {data_context['총_점포_수']:,}개
734
+ - μ—…μ’… μ’…λ₯˜: {data_context['μ—…μ’…_수']}개
735
+ - 1μΈ΅ λΉ„μœ¨: {data_context.get('1μΈ΅_λΉ„μœ¨', 'N/A')}
736
+
737
+ ⚠️ **AI 뢄석 μ‚¬μš© 방법**
738
+ ν™˜κ²½λ³€μˆ˜λ₯Ό μ„€μ •ν•˜μ„Έμš”:
739
+ ```bash
740
+ export FIREWORKS_API_KEY="your_api_key_here"
741
+ export BRAVE_API_KEY="your_brave_api_key_here" # μ›Ήκ²€μƒ‰μš© (선택)
742
+ ```
743
+
744
+ λ˜λŠ” Hugging Face Spaceμ—μ„œλŠ” Settings > Variables μ—μ„œ μ„€μ •ν•˜μ„Έμš”."""
745
+
746
+ history = history + [[message, response]]
747
+ yield history
748
+
749
+
750
+ # ============================================================================
751
+ # Gradio UI
752
+ # ============================================================================
753
+
754
+ with gr.Blocks(title="AI μƒκΆŒ 뢄석 μ‹œμŠ€ν…œ Pro", theme=gr.themes.Soft()) as demo:
755
+ gr.Markdown("""
756
+ # πŸͺ AI μƒκΆŒ 뢄석 μ‹œμŠ€ν…œ Pro (슀트리밍 + 웹검색 πŸ”)
757
+ *μ „οΏ½οΏ½ 상가(μƒκΆŒ) 데이터 기반 μ‹€μ‹œκ°„ 뢄석 | Powered by Fireworks AI + Brave Search*
758
+
759
+ **✨ 10κ°€μ§€ 심측 뢄석 제곡**: μ—…μ’… νŠΈλ Œλ“œ, 경쟁 강도, μž…μ§€ 뢄석, ν”„λžœμ°¨μ΄μ¦ˆ λΉ„μœ¨, μ§€μ—­ νŠΉν™”, 측별 μ„ ν˜Έλ„ λ“±
760
+ """)
761
+
762
+ # μ›Ή λ°°μ§€ μΆ”κ°€
763
+ gr.HTML("""
764
+ <style>
765
+ .badges-container {
766
+ display: flex;
767
+ justify-content: center;
768
+ align-items: center;
769
+ gap: 15px;
770
+ flex-wrap: wrap;
771
+ margin: 20px 0;
772
+ }
773
+
774
+ .badge {
775
+ display: inline-flex;
776
+ align-items: center;
777
+ gap: 8px;
778
+ padding: 10px 20px;
779
+ border-radius: 25px;
780
+ text-decoration: none;
781
+ font-weight: 600;
782
+ font-size: 0.95em;
783
+ transition: all 0.3s ease;
784
+ box-shadow: 0 4px 15px rgba(0,0,0,0.2);
785
+ position: relative;
786
+ overflow: hidden;
787
+ }
788
+
789
+ .badge::before {
790
+ content: '';
791
+ position: absolute;
792
+ top: 0;
793
+ left: -100%;
794
+ width: 100%;
795
+ height: 100%;
796
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
797
+ transition: left 0.5s;
798
+ }
799
+
800
+ .badge:hover::before {
801
+ left: 100%;
802
+ }
803
+
804
+ .badge:hover {
805
+ transform: translateY(-3px);
806
+ box-shadow: 0 6px 25px rgba(0,0,0,0.3);
807
+ }
808
+
809
+ .badge-kakao {
810
+ background: linear-gradient(135deg, #FEE500 0%, #FFEB3B 100%);
811
+ color: #3C1E1E;
812
+ }
813
+
814
+ .badge-kakao:hover {
815
+ background: linear-gradient(135deg, #FFD700 0%, #FFC107 100%);
816
+ }
817
+
818
+ .badge-ginigen {
819
+ background: linear-gradient(135deg, #00D9FF 0%, #0099FF 100%);
820
+ color: white;
821
+ }
822
+
823
+ .badge-ginigen:hover {
824
+ background: linear-gradient(135deg, #00C4E6 0%, #0080E6 100%);
825
+ }
826
+
827
+ .badge-icon {
828
+ font-size: 1.2em;
829
+ }
830
+ </style>
831
+
832
+ <div class="badges-container">
833
+ <a href="https://open.kakao.com/o/peIe8KWh" target="_blank" class="badge badge-kakao">
834
+ <span class="badge-icon">πŸ’¬</span>
835
+ <span>μ˜€ν”ˆμ±„νŒ… λ°”λ‘œκ°€κΈ°</span>
836
+ </a>
837
+ <a href="https://ginigen.ai" target="_blank" class="badge badge-ginigen">
838
+ <span class="badge-icon">🍌</span>
839
+ <span>λ‚˜λ…Έ λ°”λ‚˜λ‚˜ μ• λ“œμ˜¨ 무료 μ„œλΉ„μŠ€</span>
840
+ </a>
841
+ </div>
842
+ """)
843
+
844
+
845
+ with gr.Row():
846
+ with gr.Column(scale=1):
847
+ gr.Markdown("### βš™οΈ μ„€μ •")
848
+
849
+ # ν™˜κ²½λ³€μˆ˜ μƒνƒœ ν‘œμ‹œ
850
+ api_status = "βœ… API ν‚€ 섀정됨" if os.getenv("FIREWORKS_API_KEY") else "⚠️ API ν‚€ λ―Έμ„€μ • (κΈ°λ³Έ ν†΅κ³„λ§Œ 제곡)"
851
+ brave_status = "βœ… 웹검색 ν™œμ„±ν™”" if os.getenv("BRAVE_API_KEY") else "⚠️ 웹검색 λΉ„ν™œμ„±ν™”"
852
+ gr.Markdown(f"**πŸ”‘ Fireworks AI**: {api_status}")
853
+ gr.Markdown(f"**πŸ” Brave Search**: {brave_status}")
854
+
855
+ region_select = gr.CheckboxGroup(
856
+ choices=list(MarketDataLoader.REGIONS.keys()),
857
+ value=['μ„œμšΈ'],
858
+ label="πŸ“ 뢄석 μ§€μ—­ 선택 (μ΅œλŒ€ 5개 ꢌμž₯)"
859
+ )
860
+
861
+ load_btn = gr.Button("πŸ“Š 데이터 λ‘œλ“œ", variant="primary", size="lg")
862
+
863
+ status_box = gr.Markdown("πŸ‘ˆ 지역을 μ„ νƒν•˜κ³  데이터λ₯Ό λ‘œλ“œν•˜μ„Έμš”!")
864
+
865
+ with gr.Column(scale=3):
866
+ with gr.Tabs() as tabs:
867
+ with gr.Tab("πŸ“Š μΈμ‚¬μ΄νŠΈ λŒ€μ‹œλ³΄λ“œ", id=0) as tab1:
868
+ insights_content = gr.Column(visible=False)
869
+
870
+ with insights_content:
871
+ gr.Markdown("### πŸ—ΊοΈ 점포 밀집도 히트맡")
872
+ map_output = gr.HTML()
873
+
874
+ gr.Markdown("---")
875
+ gr.Markdown("### πŸ“ˆ 10κ°€μ§€ 심측 μƒκΆŒ μΈμ‚¬μ΄νŠΈ")
876
+
877
+ with gr.Row():
878
+ chart1 = gr.Plot(label="업쒅별 점포 수")
879
+ chart2 = gr.Plot(label="λŒ€λΆ„λ₯˜ 뢄포")
880
+
881
+ with gr.Row():
882
+ chart3 = gr.Plot(label="측별 뢄포")
883
+ chart4 = gr.Plot(label="μ—…μ’… λ‹€μ–‘μ„±")
884
+
885
+ with gr.Row():
886
+ chart5 = gr.Plot(label="ν”„λžœμ°¨μ΄μ¦ˆ 뢄석")
887
+ chart6 = gr.Plot(label="μΈ΅ μ„ ν˜Έλ„")
888
+
889
+ with gr.Row():
890
+ chart7 = gr.Plot(label="μ§€μ—­ 밀집도")
891
+ chart8 = gr.Plot(label="μ—…μ’… 상관관계")
892
+
893
+ with gr.Row():
894
+ chart9 = gr.Plot(label="μ†ŒλΆ„λ₯˜ νŠΈλ Œλ“œ")
895
+ chart10 = gr.Plot(label="μ§€μ—­ νŠΉν™”")
896
+
897
+ with gr.Tab("πŸ€– AI 뢄석 챗봇 (μ‹€μ‹œκ°„ 슀트리밍 + 웹검색 βš‘πŸ”)", id=1) as tab2:
898
+ chat_content = gr.Column(visible=False)
899
+
900
+ with chat_content:
901
+ gr.Markdown("""
902
+ ### πŸ’‘ μƒ˜ν”Œ 질문
903
+ κ°•λ‚¨μ—μ„œ 카페 μ°½μ—…? | μΉ˜ν‚¨μ§‘ 포화 μ§€μ—­? | 1측이 μœ λ¦¬ν•œ μ—…μ’…? | ν”„λžœμ°¨μ΄μ¦ˆ 점유율?
904
+
905
+ ⚑ **슀트리밍 λͺ¨λ“œ**: AI 응닡이 μ‹€μ‹œκ°„μœΌλ‘œ ν‘œμ‹œλ©λ‹ˆλ‹€!
906
+ πŸ” **웹검색 μžλ™**: μ΅œμ‹  μƒκΆŒ νŠΈλ Œλ“œλ₯Ό μžλ™μœΌλ‘œ κ²€μƒ‰ν•˜μ—¬ λ°˜μ˜ν•©λ‹ˆλ‹€!
907
+ """)
908
+
909
+ chatbot = gr.Chatbot(height=400, label="AI μƒκΆŒ 뢄석 μ–΄μ‹œμŠ€ν„΄νŠΈ")
910
+
911
+ with gr.Row():
912
+ msg_input = gr.Textbox(
913
+ placeholder="무엇이든 λ¬Όμ–΄λ³΄μ„Έμš”! (예: κ°•λ‚¨μ—μ„œ 카페 μ°½μ—…ν•˜λ €λ©΄?)",
914
+ show_label=False,
915
+ scale=4
916
+ )
917
+ submit_btn = gr.Button("전솑", variant="primary", scale=1)
918
+
919
+ # μƒ˜ν”Œ λ²„νŠΌλ“€
920
+ with gr.Row():
921
+ sample_btn1 = gr.Button("κ°•λ‚¨μ—μ„œ 카페 μ°½μ—…?", size="sm")
922
+ sample_btn2 = gr.Button("μΉ˜ν‚¨μ§‘ 포화 μ§€μ—­?", size="sm")
923
+ sample_btn3 = gr.Button("1측이 μœ λ¦¬ν•œ μ—…μ’…?", size="sm")
924
+ sample_btn4 = gr.Button("ν”„λžœμ°¨μ΄μ¦ˆ 점유율?", size="sm")
925
+
926
+ # 이벀트 ν•Έλ“€λŸ¬
927
+ load_btn.click(
928
+ fn=load_data,
929
+ inputs=[region_select],
930
+ outputs=[status_box, insights_content, chat_content, tab1]
931
+ ).then(
932
+ fn=generate_insights,
933
+ outputs=[map_output, chart1, chart2, chart3, chart4, chart5, chart6, chart7, chart8, chart9, chart10]
934
+ )
935
+
936
+ # 챗봇 이벀트 (슀트리밍 λͺ¨λ“œ)
937
+ submit_btn.click(
938
+ fn=chat_respond,
939
+ inputs=[msg_input, chatbot],
940
+ outputs=[chatbot]
941
+ ).then(
942
+ fn=lambda: "",
943
+ outputs=[msg_input]
944
+ )
945
+
946
+ msg_input.submit(
947
+ fn=chat_respond,
948
+ inputs=[msg_input, chatbot],
949
+ outputs=[chatbot]
950
+ ).then(
951
+ fn=lambda: "",
952
+ outputs=[msg_input]
953
+ )
954
+
955
+ # μƒ˜ν”Œ λ²„νŠΌ 이벀트
956
+ def create_sample_click(text):
957
+ def handler(history):
958
+ for result in chat_respond(text, history or []):
959
+ yield result
960
+ return handler
961
+
962
+ sample_btn1.click(fn=create_sample_click("κ°•λ‚¨μ—μ„œ 카페 μ°½μ—…?"), inputs=[chatbot], outputs=[chatbot])
963
+ sample_btn2.click(fn=create_sample_click("μΉ˜ν‚¨μ§‘ 포화 μ§€μ—­?"), inputs=[chatbot], outputs=[chatbot])
964
+ sample_btn3.click(fn=create_sample_click("1측이 μœ λ¦¬ν•œ μ—…μ’…?"), inputs=[chatbot], outputs=[chatbot])
965
+ sample_btn4.click(fn=create_sample_click("ν”„λžœμ°¨μ΄μ¦ˆ 점유율?"), inputs=[chatbot], outputs=[chatbot])
966
+
967
+ gr.Markdown("""
968
+ ---
969
+ ### πŸ“– μ‚¬μš© κ°€μ΄λ“œ
970
+ 1. μ§€μ—­ 선택 β†’ 2. 데이터 λ‘œλ“œ β†’ 3. 10κ°€μ§€ μΈμ‚¬μ΄νŠΈ 확인 λ˜λŠ” AIμ—κ²Œ 질문
971
+
972
+ ### πŸ”‘ AI 챗봇 + 웹검색 ν™œμ„±ν™” 방법
973
+ ν™˜κ²½λ³€μˆ˜ μ„€μ •:
974
+ ```bash
975
+ export FIREWORKS_API_KEY="your_api_key_here"
976
+ export BRAVE_API_KEY="your_brave_api_key_here" # 웹검색 κΈ°λŠ₯용 (선택)
977
+ ```
978
+
979
+ Hugging Face Spaceμ—μ„œλŠ”:
980
+ 1. Settings 메뉴 클릭
981
+ 2. Variables νƒ­ 선택
982
+ 3. New variable μΆ”κ°€:
983
+ - `FIREWORKS_API_KEY` (ν•„μˆ˜ - AI λΆ„μ„μš©)
984
+ - `BRAVE_API_KEY` (선택 - 웹검색 ν™œμ„±ν™”μš©)
985
+
986
+ 🌐 **웹검색 κΈ°λŠ₯**: BRAVE_API_KEY μ„€μ • μ‹œ μžλ™μœΌλ‘œ μ΅œμ‹  μƒκΆŒ νŠΈλ Œλ“œλ₯Ό κ²€μƒ‰ν•˜μ—¬ 닡변에 λ°˜μ˜ν•©λ‹ˆλ‹€!
987
+
988
+ ### πŸ“Š μ œκ³΅λ˜λŠ” 10κ°€μ§€ 뢄석
989
+ 1. **업쒅별 점포 수**: κ°€μž₯ λ§Žμ€ μ—…μ’… TOP 15
990
+ 2. **λŒ€λΆ„λ₯˜ 뢄포**: μ†Œλ§€/μŒμ‹/μ„œλΉ„μŠ€ λ“± λŒ€λΆ„λ₯˜ λΉ„μœ¨
991
+ 3. **측별 뢄포**: μ§€ν•˜/1μΈ΅/상측 μž…μ§€ 뢄석
992
+ 4. **μ—…μ’… λ‹€μ–‘μ„±**: 지역별 μ—…μ’… λ‹€μ–‘μ„± μ§€μˆ˜
993
+ 5. **ν”„λžœμ°¨μ΄μ¦ˆ 뢄석**: 개인 vs ν”„λžœμ°¨μ΄μ¦ˆ λΉ„μœ¨
994
+ 6. **μΈ΅ μ„ ν˜Έλ„**: 업쒅별 μ„ ν˜Έ 측수
995
+ 7. **μ§€μ—­ 밀집도**: 점포 수 μƒμœ„ μ§€μ—­
996
+ 8. **μ—…μ’… 상관관계**: 같이 λ‚˜νƒ€λ‚˜λŠ” μ—…μ’… νŒ¨ν„΄
997
+ 9. **μ†ŒλΆ„λ₯˜ νŠΈλ Œλ“œ**: μ„ΈλΆ€ μ—…μ’… 뢄포
998
+ 10. **μ§€μ—­ νŠΉν™”**: 각 μ§€μ—­μ˜ νŠΉν™” μ—…μ’…
999
+
1000
+ πŸ’‘ **Tip**: API ν‚€ 없이도 10κ°€μ§€ μ‹œκ°ν™” 뢄석과 κΈ°λ³Έ 톡계λ₯Ό 확인할 수 μžˆμŠ΅λ‹ˆλ‹€!
1001
+
1002
+ ⚑ **NEW!** 챗봇이 이제 οΏ½οΏ½μ‹œκ°„ 슀트리밍으둜 μ‘λ‹΅ν•©λ‹ˆλ‹€!
1003
+ πŸ” **NEW!** Brave Search μ›Ήκ²€μƒ‰μœΌλ‘œ μ΅œμ‹  μƒκΆŒ νŠΈλ Œλ“œλ₯Ό μžλ™ λ°˜μ˜ν•©λ‹ˆλ‹€!
1004
+ """)
1005
+
1006
+ # μ‹€ν–‰
1007
+ if __name__ == "__main__":
1008
+ demo.launch(server_name="0.0.0.0", server_port=7860, share=False)