Update app.py
Browse files
app.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
"""
|
| 2 |
-
AI 기반 상권 분석 시스템 -
|
| 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
|
|
@@ -18,6 +18,8 @@ from collections import Counter
|
|
| 18 |
import re
|
| 19 |
import os
|
| 20 |
import time
|
|
|
|
|
|
|
| 21 |
|
| 22 |
# ============================================================================
|
| 23 |
# 데이터 로더 클래스
|
|
@@ -80,7 +82,560 @@ class MarketDataLoader:
|
|
| 80 |
|
| 81 |
|
| 82 |
# ============================================================================
|
| 83 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
# ============================================================================
|
| 85 |
|
| 86 |
class MarketAnalyzer:
|
|
@@ -89,6 +644,12 @@ class MarketAnalyzer:
|
|
| 89 |
def __init__(self, df: pd.DataFrame):
|
| 90 |
self.df = df
|
| 91 |
self.prepare_data()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
def prepare_data(self):
|
| 94 |
"""데이터 전처리"""
|
|
@@ -116,39 +677,122 @@ class MarketAnalyzer:
|
|
| 116 |
match = re.search(r'\d+', floor_str)
|
| 117 |
return int(match.group()) if match else None
|
| 118 |
|
| 119 |
-
def
|
| 120 |
-
"""
|
| 121 |
-
insights = []
|
| 122 |
|
| 123 |
-
|
| 124 |
-
|
| 125 |
|
| 126 |
-
#
|
| 127 |
-
|
| 128 |
|
| 129 |
-
#
|
| 130 |
-
|
|
|
|
| 131 |
|
| 132 |
-
#
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
#
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
-
#
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
-
#
|
| 151 |
-
insights.append(self.
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
return insights
|
| 154 |
|
|
@@ -196,7 +840,6 @@ class MarketAnalyzer:
|
|
| 196 |
floor_data = self.df['층정보_숫자'].dropna()
|
| 197 |
floor_counts = floor_data.value_counts().sort_index()
|
| 198 |
|
| 199 |
-
# 지하, 1층, 2층 이상으로 그룹화
|
| 200 |
underground = floor_counts[floor_counts.index < 0].sum()
|
| 201 |
first_floor = floor_counts.get(1, 0)
|
| 202 |
upper_floors = floor_counts[floor_counts.index > 1].sum()
|
|
@@ -213,7 +856,7 @@ class MarketAnalyzer:
|
|
| 213 |
)
|
| 214 |
])
|
| 215 |
fig.update_layout(
|
| 216 |
-
title='🏢 층별 점포 분포
|
| 217 |
xaxis_title='층 구분',
|
| 218 |
yaxis_title='점포 수',
|
| 219 |
height=400
|
|
@@ -225,363 +868,114 @@ class MarketAnalyzer:
|
|
| 225 |
if '시군구명' not in self.df.columns or '상권업종중분류명' not in self.df.columns:
|
| 226 |
return None
|
| 227 |
|
| 228 |
-
# 각 시군구별 업종 다양성 계산 (업종 수 / 전체 점포 수)
|
| 229 |
diversity_data = []
|
| 230 |
-
for district in self.df['시군구명'].unique()[:20]:
|
| 231 |
district_df = self.df[self.df['시군구명'] == district]
|
| 232 |
num_categories = district_df['상권업종중분류명'].nunique()
|
| 233 |
total_stores = len(district_df)
|
| 234 |
diversity_score = (num_categories / total_stores) * 100
|
| 235 |
diversity_data.append({
|
| 236 |
'시군구': district,
|
| 237 |
-
'다양성 지수': diversity_score
|
| 238 |
-
'업종 수': num_categories,
|
| 239 |
-
'총 점포': total_stores
|
| 240 |
})
|
| 241 |
|
| 242 |
-
|
| 243 |
|
| 244 |
fig = px.bar(
|
| 245 |
-
|
| 246 |
x='다양성 지수',
|
| 247 |
y='시군구',
|
| 248 |
orientation='h',
|
| 249 |
-
title='🌈 지역별 업종 다양성 지수
|
| 250 |
-
labels={'다양성 지수': '다양성 지수 (%)', '시군구': '지역'},
|
| 251 |
color='다양성 지수',
|
| 252 |
-
color_continuous_scale='viridis'
|
| 253 |
-
hover_data=['업종 수', '총 점포']
|
| 254 |
)
|
| 255 |
-
fig.update_layout(height=500)
|
| 256 |
return {'type': 'plot', 'data': fig, 'title': '업종 다양성 분석'}
|
| 257 |
-
|
| 258 |
-
def _create_franchise_analysis(self) -> Dict:
|
| 259 |
-
"""프랜차이즈 vs 개인사업자 분석"""
|
| 260 |
-
if '상호명' not in self.df.columns:
|
| 261 |
-
return None
|
| 262 |
-
|
| 263 |
-
# 주요 프랜차이즈 키워드
|
| 264 |
-
franchise_keywords = [
|
| 265 |
-
'CU', 'GS25', '세븐일레븐', '이마트24', '미니스톱',
|
| 266 |
-
'스타벅스', '투썸플레이스', '이디야', '탐앤탐스', '커피빈',
|
| 267 |
-
'맥도날드', '버거킹', '롯데리아', 'KFC', '맘스터치',
|
| 268 |
-
'BBQ', '교촌', '굽네', 'bhc', '네네치킨'
|
| 269 |
-
]
|
| 270 |
-
|
| 271 |
-
franchise_counts = {}
|
| 272 |
-
for keyword in franchise_keywords:
|
| 273 |
-
count = self.df['상호명'].str.contains(keyword, case=False, na=False).sum()
|
| 274 |
-
if count > 0:
|
| 275 |
-
franchise_counts[keyword] = count
|
| 276 |
-
|
| 277 |
-
total_franchise = sum(franchise_counts.values())
|
| 278 |
-
total_stores = len(self.df)
|
| 279 |
-
individual_stores = total_stores - total_franchise
|
| 280 |
-
|
| 281 |
-
# 파이 차트
|
| 282 |
-
fig = make_subplots(
|
| 283 |
-
rows=1, cols=2,
|
| 284 |
-
specs=[[{'type': 'pie'}, {'type': 'bar'}]],
|
| 285 |
-
subplot_titles=('전체 비율', '프랜차이즈별 점포 수')
|
| 286 |
-
)
|
| 287 |
-
|
| 288 |
-
# 전체 비율
|
| 289 |
-
fig.add_trace(
|
| 290 |
-
go.Pie(
|
| 291 |
-
labels=['개인사업자', '프랜차이즈'],
|
| 292 |
-
values=[individual_stores, total_franchise],
|
| 293 |
-
hole=0.3,
|
| 294 |
-
marker_colors=['#3498db', '#e74c3c']
|
| 295 |
-
),
|
| 296 |
-
row=1, col=1
|
| 297 |
-
)
|
| 298 |
-
|
| 299 |
-
# 프랜차이즈별
|
| 300 |
-
top_franchises = dict(sorted(franchise_counts.items(), key=lambda x: x[1], reverse=True)[:10])
|
| 301 |
-
fig.add_trace(
|
| 302 |
-
go.Bar(
|
| 303 |
-
x=list(top_franchises.keys()),
|
| 304 |
-
y=list(top_franchises.values()),
|
| 305 |
-
marker_color='#e74c3c'
|
| 306 |
-
),
|
| 307 |
-
row=1, col=2
|
| 308 |
-
)
|
| 309 |
-
|
| 310 |
-
fig.update_layout(
|
| 311 |
-
title_text='🏪 프랜차이즈 vs 개인사업자 분석',
|
| 312 |
-
showlegend=True,
|
| 313 |
-
height=400
|
| 314 |
-
)
|
| 315 |
-
return {'type': 'plot', 'data': fig, 'title': '프랜차이즈 점유율'}
|
| 316 |
-
|
| 317 |
-
def _create_floor_preference(self) -> Dict:
|
| 318 |
-
"""업종별 층 선호도"""
|
| 319 |
-
if '상권업종중분류명' not in self.df.columns or '층정보_숫자' not in self.df.columns:
|
| 320 |
-
return None
|
| 321 |
-
|
| 322 |
-
# 상위 10개 업종의 층별 분포
|
| 323 |
-
top_categories = self.df['상권업종중분류명'].value_counts().head(10).index
|
| 324 |
-
|
| 325 |
-
floor_pref_data = []
|
| 326 |
-
for category in top_categories:
|
| 327 |
-
cat_df = self.df[self.df['상권업종중분류명'] == category]
|
| 328 |
-
underground = (cat_df['층정보_숫자'] < 0).sum()
|
| 329 |
-
first_floor = (cat_df['층정보_숫자'] == 1).sum()
|
| 330 |
-
upper_floors = (cat_df['층정보_숫자'] > 1).sum()
|
| 331 |
-
|
| 332 |
-
total = underground + first_floor + upper_floors
|
| 333 |
-
if total > 0:
|
| 334 |
-
floor_pref_data.append({
|
| 335 |
-
'업종': category,
|
| 336 |
-
'지하': (underground / total) * 100,
|
| 337 |
-
'1층': (first_floor / total) * 100,
|
| 338 |
-
'2층 이상': (upper_floors / total) * 100
|
| 339 |
-
})
|
| 340 |
-
|
| 341 |
-
pref_df = pd.DataFrame(floor_pref_data)
|
| 342 |
-
|
| 343 |
-
fig = go.Figure(data=[
|
| 344 |
-
go.Bar(name='지하', x=pref_df['업종'], y=pref_df['��하'], marker_color='#e74c3c'),
|
| 345 |
-
go.Bar(name='1층', x=pref_df['업종'], y=pref_df['1층'], marker_color='#3498db'),
|
| 346 |
-
go.Bar(name='2층 이상', x=pref_df['업종'], y=pref_df['2층 이상'], marker_color='#95a5a6')
|
| 347 |
-
])
|
| 348 |
-
|
| 349 |
-
fig.update_layout(
|
| 350 |
-
title='🎯 업종별 층 선호도 (상위 10개)',
|
| 351 |
-
xaxis_title='업종',
|
| 352 |
-
yaxis_title='비율 (%)',
|
| 353 |
-
barmode='stack',
|
| 354 |
-
height=500,
|
| 355 |
-
xaxis_tickangle=-45
|
| 356 |
-
)
|
| 357 |
-
return {'type': 'plot', 'data': fig, 'title': '층별 입지 선호도'}
|
| 358 |
-
|
| 359 |
-
def _create_district_density(self) -> Dict:
|
| 360 |
-
"""시군구별 상권 밀집도"""
|
| 361 |
-
if '시군구명' not in self.df.columns:
|
| 362 |
-
return None
|
| 363 |
-
|
| 364 |
-
district_counts = self.df['시군구명'].value_counts().head(20)
|
| 365 |
-
|
| 366 |
-
fig = px.bar(
|
| 367 |
-
x=district_counts.values,
|
| 368 |
-
y=district_counts.index,
|
| 369 |
-
orientation='h',
|
| 370 |
-
title='🌆 시군구별 상권 밀집도 TOP 20',
|
| 371 |
-
labels={'x': '점포 수', 'y': '시군구'},
|
| 372 |
-
color=district_counts.values,
|
| 373 |
-
color_continuous_scale='reds'
|
| 374 |
-
)
|
| 375 |
-
fig.update_layout(showlegend=False, height=600)
|
| 376 |
-
return {'type': 'plot', 'data': fig, 'title': '지역별 밀집도'}
|
| 377 |
-
|
| 378 |
-
def _create_category_correlation(self) -> Dict:
|
| 379 |
-
"""업종 상관관계 (같은 동에 자주 나타나는 업종)"""
|
| 380 |
-
if '행정동명' not in self.df.columns or '상권업종중분류명' not in self.df.columns:
|
| 381 |
-
return None
|
| 382 |
-
|
| 383 |
-
# 상위 10개 업종만 분석
|
| 384 |
-
top_categories = self.df['상권업종중분류명'].value_counts().head(10).index.tolist()
|
| 385 |
-
|
| 386 |
-
# 각 동별로 업종 카운트
|
| 387 |
-
correlation_matrix = []
|
| 388 |
-
for cat1 in top_categories:
|
| 389 |
-
row = []
|
| 390 |
-
for cat2 in top_categories:
|
| 391 |
-
# 두 업종이 같은 동에 있는 경우의 수
|
| 392 |
-
cat1_dongs = set(self.df[self.df['상권업종중분류명'] == cat1]['행정동명'].unique())
|
| 393 |
-
cat2_dongs = set(self.df[self.df['상권업종중분류명'] == cat2]['행정동명'].unique())
|
| 394 |
-
intersection = len(cat1_dongs & cat2_dongs)
|
| 395 |
-
union = len(cat1_dongs | cat2_dongs)
|
| 396 |
-
similarity = (intersection / union * 100) if union > 0 else 0
|
| 397 |
-
row.append(similarity)
|
| 398 |
-
correlation_matrix.append(row)
|
| 399 |
-
|
| 400 |
-
fig = go.Figure(data=go.Heatmap(
|
| 401 |
-
z=correlation_matrix,
|
| 402 |
-
x=top_categories,
|
| 403 |
-
y=top_categories,
|
| 404 |
-
colorscale='Blues',
|
| 405 |
-
text=np.round(correlation_matrix, 1),
|
| 406 |
-
texttemplate='%{text}',
|
| 407 |
-
textfont={"size": 10}
|
| 408 |
-
))
|
| 409 |
-
|
| 410 |
-
fig.update_layout(
|
| 411 |
-
title='🔗 업종 상관관계 매트릭스 (같은 지역 동시 출현율)',
|
| 412 |
-
xaxis_title='업종',
|
| 413 |
-
yaxis_title='업종',
|
| 414 |
-
height=600,
|
| 415 |
-
xaxis_tickangle=-45
|
| 416 |
-
)
|
| 417 |
-
return {'type': 'plot', 'data': fig, 'title': '업종 공존 분석'}
|
| 418 |
-
|
| 419 |
-
def _create_subcategory_trends(self) -> Dict:
|
| 420 |
-
"""소분류 트렌드"""
|
| 421 |
-
if '상권업종소분류명' not in self.df.columns:
|
| 422 |
-
return None
|
| 423 |
-
|
| 424 |
-
subcat_counts = self.df['상권업종소분류명'].value_counts().head(20)
|
| 425 |
-
|
| 426 |
-
fig = px.treemap(
|
| 427 |
-
names=subcat_counts.index,
|
| 428 |
-
parents=[''] * len(subcat_counts),
|
| 429 |
-
values=subcat_counts.values,
|
| 430 |
-
title='🔍 소분류 업종 트렌드 TOP 20',
|
| 431 |
-
color=subcat_counts.values,
|
| 432 |
-
color_continuous_scale='greens'
|
| 433 |
-
)
|
| 434 |
-
fig.update_layout(height=600)
|
| 435 |
-
return {'type': 'plot', 'data': fig, 'title': '세부 업종 분석'}
|
| 436 |
-
|
| 437 |
-
def _create_regional_specialization(self) -> Dict:
|
| 438 |
-
"""지역별 특화 업종"""
|
| 439 |
-
if '시도명' not in self.df.columns or '상권업종중분류명' not in self.df.columns:
|
| 440 |
-
return None
|
| 441 |
-
|
| 442 |
-
# 각 시도별 상위 3개 업종
|
| 443 |
-
specialization_data = []
|
| 444 |
-
for region in self.df['시도명'].unique():
|
| 445 |
-
region_df = self.df[self.df['시도명'] == region]
|
| 446 |
-
top_categories = region_df['상권업종중분류명'].value_counts().head(3)
|
| 447 |
-
for category, count in top_categories.items():
|
| 448 |
-
specialization_data.append({
|
| 449 |
-
'지역': region,
|
| 450 |
-
'특화업종': category,
|
| 451 |
-
'점포수': count
|
| 452 |
-
})
|
| 453 |
-
|
| 454 |
-
spec_df = pd.DataFrame(specialization_data)
|
| 455 |
-
|
| 456 |
-
fig = px.sunburst(
|
| 457 |
-
spec_df,
|
| 458 |
-
path=['지역', '특화업종'],
|
| 459 |
-
values='점포수',
|
| 460 |
-
title='🎯 지역별 특화 업종 (각 지역 TOP 3)',
|
| 461 |
-
color='점포수',
|
| 462 |
-
color_continuous_scale='oranges'
|
| 463 |
-
)
|
| 464 |
-
fig.update_layout(height=700)
|
| 465 |
-
return {'type': 'plot', 'data': fig, 'title': '지역 특화 분석'}
|
| 466 |
-
|
| 467 |
-
def create_density_map(self, sample_size: int = 1000) -> str:
|
| 468 |
-
"""점포 밀집도 지도 생성"""
|
| 469 |
-
df_sample = self.df.sample(n=min(sample_size, len(self.df)), random_state=42)
|
| 470 |
-
|
| 471 |
-
center_lat = df_sample['위도'].mean()
|
| 472 |
-
center_lon = df_sample['경도'].mean()
|
| 473 |
-
|
| 474 |
-
m = folium.Map(location=[center_lat, center_lon], zoom_start=11, tiles='OpenStreetMap')
|
| 475 |
-
|
| 476 |
-
# 히트맵
|
| 477 |
-
heat_data = [[row['위도'], row['경도']] for _, row in df_sample.iterrows()]
|
| 478 |
-
HeatMap(heat_data, radius=15, blur=25, max_zoom=13).add_to(m)
|
| 479 |
-
|
| 480 |
-
return m._repr_html_()
|
| 481 |
-
|
| 482 |
-
def analyze_for_llm(self) -> Dict:
|
| 483 |
-
"""LLM 컨텍스트용 분석 데이터"""
|
| 484 |
-
context = {
|
| 485 |
-
'총_점포_수': len(self.df),
|
| 486 |
-
'지역_수': self.df['시도명'].nunique() if '시도명' in self.df.columns else 0,
|
| 487 |
-
'업종_수': self.df['상권업종중분류명'].nunique() if '상권업종중분류명' in self.df.columns else 0,
|
| 488 |
-
}
|
| 489 |
-
|
| 490 |
-
if '상권업종중분류명' in self.df.columns:
|
| 491 |
-
context['상위_업종_5'] = self.df['상권업종중분류명'].value_counts().head(5).to_dict()
|
| 492 |
-
|
| 493 |
-
if '층정보_숫자' in self.df.columns:
|
| 494 |
-
first_floor_ratio = (self.df['층정보_숫자'] == 1).sum() / len(self.df) * 100
|
| 495 |
-
context['1층_비율'] = f"{first_floor_ratio:.1f}%"
|
| 496 |
-
|
| 497 |
-
return context
|
| 498 |
|
| 499 |
|
| 500 |
# ============================================================================
|
| 501 |
-
# LLM
|
| 502 |
# ============================================================================
|
| 503 |
|
| 504 |
class LLMQueryProcessor:
|
| 505 |
-
"""Fireworks AI
|
| 506 |
|
| 507 |
-
def __init__(self
|
| 508 |
-
|
| 509 |
-
self.api_key = api_key or os.getenv("FIREWORKS_API_KEY")
|
| 510 |
-
self.base_url = "https://api.fireworks.ai/inference/v1/chat/completions"
|
| 511 |
-
|
| 512 |
if not self.api_key:
|
| 513 |
-
raise ValueError("
|
|
|
|
|
|
|
|
|
|
| 514 |
|
| 515 |
-
def
|
| 516 |
-
"""
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
{json.dumps(data_context, ensure_ascii=False, indent=2)}
|
| 521 |
|
| 522 |
-
|
| 523 |
-
|
|
|
|
|
|
|
|
|
|
| 524 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
messages = [{"role": "system", "content": system_prompt}]
|
| 526 |
-
|
| 527 |
-
messages.extend(chat_history[-6:])
|
| 528 |
messages.append({"role": "user", "content": query})
|
| 529 |
|
| 530 |
-
payload = {
|
| 531 |
-
"model": "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507",
|
| 532 |
-
"max_tokens": 2000,
|
| 533 |
-
"temperature": 0.7,
|
| 534 |
-
"messages": messages
|
| 535 |
-
}
|
| 536 |
-
|
| 537 |
headers = {
|
| 538 |
"Authorization": f"Bearer {self.api_key}",
|
| 539 |
"Content-Type": "application/json"
|
| 540 |
}
|
| 541 |
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
timeout=60
|
| 551 |
-
)
|
| 552 |
-
|
| 553 |
-
if response.status_code == 200:
|
| 554 |
-
return response.json()['choices'][0]['message']['content']
|
| 555 |
-
elif response.status_code == 429:
|
| 556 |
-
# Rate limit - 재시도
|
| 557 |
-
wait_time = (attempt + 1) * 2
|
| 558 |
-
time.sleep(wait_time)
|
| 559 |
-
continue
|
| 560 |
-
else:
|
| 561 |
-
return f"⚠️ API 오류: {response.status_code} - {response.text[:200]}"
|
| 562 |
-
|
| 563 |
-
except requests.exceptions.Timeout:
|
| 564 |
-
if attempt < max_retries - 1:
|
| 565 |
-
time.sleep(2)
|
| 566 |
-
continue
|
| 567 |
-
else:
|
| 568 |
-
return "⚠️ API 응답 시간 초과. 잠시 후 다시 시도해주세요."
|
| 569 |
-
|
| 570 |
-
except requests.exceptions.ConnectionError:
|
| 571 |
-
if attempt < max_retries - 1:
|
| 572 |
-
time.sleep(2)
|
| 573 |
-
continue
|
| 574 |
-
else:
|
| 575 |
-
return "⚠️ 네트워크 연결 오류. 인터넷 연결을 확인해주세요."
|
| 576 |
-
|
| 577 |
-
except Exception as e:
|
| 578 |
-
if attempt < max_retries - 1:
|
| 579 |
-
time.sleep(1)
|
| 580 |
-
continue
|
| 581 |
-
else:
|
| 582 |
-
return f"❌ 오류: {str(e)}"
|
| 583 |
|
| 584 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 585 |
|
| 586 |
|
| 587 |
# ============================================================================
|
|
@@ -589,31 +983,36 @@ class LLMQueryProcessor:
|
|
| 589 |
# ============================================================================
|
| 590 |
|
| 591 |
class AppState:
|
|
|
|
| 592 |
def __init__(self):
|
|
|
|
| 593 |
self.analyzer = None
|
| 594 |
self.llm_processor = None
|
| 595 |
-
self.chat_history = []
|
| 596 |
|
| 597 |
app_state = AppState()
|
| 598 |
|
| 599 |
|
| 600 |
# ============================================================================
|
| 601 |
-
# Gradio
|
| 602 |
# ============================================================================
|
| 603 |
|
| 604 |
def load_data(regions):
|
| 605 |
"""데이터 로드"""
|
| 606 |
-
if not regions:
|
| 607 |
-
return "❌ 최소 1개 지역을 선택해주세요!", None, None, None
|
| 608 |
-
|
| 609 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
df = MarketDataLoader.load_multiple_regions(regions, sample_per_region=30000)
|
|
|
|
| 611 |
if df.empty:
|
| 612 |
return "❌ 데이터 로드 실패!", None, None, None
|
| 613 |
|
|
|
|
| 614 |
app_state.analyzer = MarketAnalyzer(df)
|
| 615 |
|
| 616 |
-
# 기본 통계
|
| 617 |
stats = f"""
|
| 618 |
✅ **데이터 로드 완료!**
|
| 619 |
|
|
@@ -622,6 +1021,11 @@ def load_data(regions):
|
|
| 622 |
- 분석 지역: {', '.join(regions)}
|
| 623 |
- 업종 수: {df['상권업종중분류명'].nunique()}개
|
| 624 |
- 대분류: {df['상권업종대분류명'].nunique()}개
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
"""
|
| 626 |
|
| 627 |
return stats, gr.update(visible=True), gr.update(visible=True), gr.update(visible=True)
|
|
@@ -630,48 +1034,338 @@ def load_data(regions):
|
|
| 630 |
|
| 631 |
|
| 632 |
def generate_insights():
|
| 633 |
-
"""인사이트 생성"""
|
| 634 |
if app_state.analyzer is None:
|
| 635 |
-
return [None] *
|
| 636 |
|
| 637 |
insights = app_state.analyzer.get_comprehensive_insights()
|
| 638 |
-
map_html = app_state.analyzer.
|
| 639 |
|
| 640 |
result = [map_html]
|
| 641 |
-
for insight in insights:
|
| 642 |
if insight and insight['type'] == 'plot':
|
| 643 |
result.append(insight['data'])
|
| 644 |
else:
|
| 645 |
result.append(None)
|
| 646 |
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 650 |
|
| 651 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 652 |
|
|
|
|
| 653 |
|
| 654 |
-
|
| 655 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
if app_state.analyzer is None:
|
| 657 |
-
|
|
|
|
| 658 |
|
| 659 |
data_context = app_state.analyzer.analyze_for_llm()
|
| 660 |
|
| 661 |
-
# LLM 프로세서 초기화 (환경변수에서 API 키 자동 로드)
|
| 662 |
try:
|
| 663 |
if app_state.llm_processor is None:
|
| 664 |
app_state.llm_processor = LLMQueryProcessor()
|
| 665 |
|
|
|
|
| 666 |
chat_hist = []
|
| 667 |
for user_msg, bot_msg in history:
|
| 668 |
chat_hist.append({"role": "user", "content": user_msg})
|
| 669 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
|
| 671 |
-
|
|
|
|
|
|
|
|
|
|
| 672 |
|
| 673 |
except ValueError as e:
|
| 674 |
-
# API 키가 없는 경우 기본 통계 제공
|
| 675 |
response = f"""📊 **기본 데이터 분석 결과**
|
| 676 |
|
| 677 |
**전체 현황**
|
|
@@ -679,60 +1373,52 @@ def chat_respond(message, history):
|
|
| 679 |
- 업종 종류: {data_context['업종_수']}개
|
| 680 |
- 1층 비율: {data_context.get('1층_비율', 'N/A')}
|
| 681 |
|
| 682 |
-
⚠️ **AI 분석
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
또는 Hugging Face Space에서는 Settings > Variables 에서 설정하세요."""
|
| 689 |
-
|
| 690 |
-
history.append([message, response])
|
| 691 |
-
return history
|
| 692 |
|
| 693 |
|
| 694 |
# ============================================================================
|
| 695 |
# Gradio UI
|
| 696 |
# ============================================================================
|
| 697 |
|
| 698 |
-
with gr.Blocks(title="AI 상권 분석 시스템 Pro", theme=gr.themes.Soft()) as demo:
|
| 699 |
gr.Markdown("""
|
| 700 |
-
# 🏪 AI 상권 분석 시스템 Pro
|
| 701 |
-
*전국 상가(상권) 데이터 기반
|
| 702 |
|
| 703 |
-
**✨
|
| 704 |
""")
|
| 705 |
|
| 706 |
with gr.Row():
|
| 707 |
with gr.Column(scale=1):
|
| 708 |
gr.Markdown("### ⚙️ 설정")
|
| 709 |
|
| 710 |
-
|
| 711 |
-
api_status = "✅ API 키 설정됨" if os.getenv("FIREWORKS_API_KEY") else "⚠️ API 키 미설정 (기본 통계만 제공)"
|
| 712 |
gr.Markdown(f"**🔑 API 상태**: {api_status}")
|
| 713 |
|
| 714 |
region_select = gr.CheckboxGroup(
|
| 715 |
choices=list(MarketDataLoader.REGIONS.keys()),
|
| 716 |
value=['서울'],
|
| 717 |
-
label="📍 분석 지역 선택 (최대 5개
|
| 718 |
)
|
| 719 |
|
| 720 |
load_btn = gr.Button("📊 데이터 로드", variant="primary", size="lg")
|
| 721 |
-
|
| 722 |
status_box = gr.Markdown("👈 지역을 선택하고 데이터를 로드하세요!")
|
| 723 |
|
| 724 |
with gr.Column(scale=3):
|
| 725 |
with gr.Tabs() as tabs:
|
| 726 |
-
|
|
|
|
| 727 |
insights_content = gr.Column(visible=False)
|
| 728 |
|
| 729 |
with insights_content:
|
| 730 |
-
gr.Markdown("### 🗺️
|
| 731 |
map_output = gr.HTML()
|
| 732 |
|
| 733 |
-
gr.Markdown("
|
| 734 |
-
gr.Markdown("### 📈 10가지 심층 상권 인사이트")
|
| 735 |
-
|
| 736 |
with gr.Row():
|
| 737 |
chart1 = gr.Plot(label="업종별 점포 수")
|
| 738 |
chart2 = gr.Plot(label="대분류 분포")
|
|
@@ -740,58 +1426,142 @@ with gr.Blocks(title="AI 상권 분석 시스템 Pro", theme=gr.themes.Soft()) a
|
|
| 740 |
with gr.Row():
|
| 741 |
chart3 = gr.Plot(label="층별 분포")
|
| 742 |
chart4 = gr.Plot(label="업종 다양성")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 743 |
|
| 744 |
with gr.Row():
|
| 745 |
-
|
| 746 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 747 |
|
| 748 |
with gr.Row():
|
| 749 |
-
|
| 750 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 751 |
|
| 752 |
with gr.Row():
|
| 753 |
-
|
| 754 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 755 |
|
| 756 |
-
|
|
|
|
| 757 |
chat_content = gr.Column(visible=False)
|
| 758 |
|
| 759 |
with chat_content:
|
| 760 |
gr.Markdown("""
|
| 761 |
### 💡 샘플 질문
|
| 762 |
-
강남에서 카페 창업? | 치킨집 포화 지역? | 1층이 유리한 업종? | 프랜차이즈
|
| 763 |
""")
|
| 764 |
|
| 765 |
-
chatbot = gr.Chatbot(height=
|
| 766 |
|
| 767 |
with gr.Row():
|
| 768 |
msg_input = gr.Textbox(
|
| 769 |
-
placeholder="무엇이든 물어보세요!
|
| 770 |
show_label=False,
|
| 771 |
scale=4
|
| 772 |
)
|
| 773 |
submit_btn = gr.Button("전송", variant="primary", scale=1)
|
| 774 |
|
| 775 |
-
# 샘플 버튼들
|
| 776 |
with gr.Row():
|
| 777 |
sample_btn1 = gr.Button("강남에서 카페 창업?", size="sm")
|
| 778 |
sample_btn2 = gr.Button("치킨집 포화 지역?", size="sm")
|
| 779 |
sample_btn3 = gr.Button("1층이 유리한 업종?", size="sm")
|
| 780 |
-
sample_btn4 = gr.Button("프랜차이즈
|
| 781 |
|
| 782 |
# 이벤트 핸들러
|
| 783 |
load_btn.click(
|
| 784 |
fn=load_data,
|
| 785 |
inputs=[region_select],
|
| 786 |
-
outputs=[status_box, insights_content,
|
| 787 |
).then(
|
| 788 |
fn=generate_insights,
|
| 789 |
-
outputs=[map_output, chart1, chart2, chart3, chart4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
)
|
| 791 |
|
| 792 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 793 |
submit_btn.click(
|
| 794 |
-
fn=
|
| 795 |
inputs=[msg_input, chatbot],
|
| 796 |
outputs=[chatbot]
|
| 797 |
).then(
|
|
@@ -800,7 +1570,7 @@ with gr.Blocks(title="AI 상권 분석 시스템 Pro", theme=gr.themes.Soft()) a
|
|
| 800 |
)
|
| 801 |
|
| 802 |
msg_input.submit(
|
| 803 |
-
fn=
|
| 804 |
inputs=[msg_input, chatbot],
|
| 805 |
outputs=[chatbot]
|
| 806 |
).then(
|
|
@@ -809,48 +1579,39 @@ with gr.Blocks(title="AI 상권 분석 시스템 Pro", theme=gr.themes.Soft()) a
|
|
| 809 |
)
|
| 810 |
|
| 811 |
# 샘플 버튼 이벤트
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
(sample_btn4, "프랜차이즈 점유율?")
|
| 817 |
-
]:
|
| 818 |
-
btn.click(
|
| 819 |
-
fn=lambda t=text, h=chatbot: chat_respond(t, h.value or []),
|
| 820 |
-
outputs=[chatbot]
|
| 821 |
-
)
|
| 822 |
|
| 823 |
gr.Markdown("""
|
| 824 |
---
|
| 825 |
-
###
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
💡 **Tip**: API 키 없이도 10가지 시각화 분석과 기본 통계를 확인할 수 있습니다!
|
| 852 |
""")
|
| 853 |
|
| 854 |
-
# 실행
|
| 855 |
if __name__ == "__main__":
|
| 856 |
demo.launch(server_name="0.0.0.0", server_port=7860, share=False)
|
|
|
|
| 1 |
"""
|
| 2 |
+
AI 기반 상권 분석 시스템 - 프리미엄 버전
|
| 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, Optional
|
| 9 |
import json
|
| 10 |
from datasets import load_dataset
|
| 11 |
import plotly.express as px
|
|
|
|
| 18 |
import re
|
| 19 |
import os
|
| 20 |
import time
|
| 21 |
+
from dataclasses import dataclass
|
| 22 |
+
from sklearn.preprocessing import MinMaxScaler
|
| 23 |
|
| 24 |
# ============================================================================
|
| 25 |
# 데이터 로더 클래스
|
|
|
|
| 82 |
|
| 83 |
|
| 84 |
# ============================================================================
|
| 85 |
+
# 창업 성공 스코어링 시스템
|
| 86 |
+
# ============================================================================
|
| 87 |
+
|
| 88 |
+
@dataclass
|
| 89 |
+
class StartupScore:
|
| 90 |
+
"""창업 성공 점수"""
|
| 91 |
+
total_score: float # 0-100
|
| 92 |
+
competition_score: float # 경쟁강도 점수
|
| 93 |
+
saturation_score: float # 포화도 점수
|
| 94 |
+
location_score: float # 입지 점수
|
| 95 |
+
floor_score: float # 층수 적합도 점수
|
| 96 |
+
diversity_score: float # 업종 다양성 점수
|
| 97 |
+
recommendation: str # 추천 메시지
|
| 98 |
+
details: Dict # 상세 정보
|
| 99 |
+
|
| 100 |
+
class StartupScorer:
|
| 101 |
+
"""창업 성공 확률 계산 엔진"""
|
| 102 |
+
|
| 103 |
+
def __init__(self, df: pd.DataFrame):
|
| 104 |
+
self.df = df
|
| 105 |
+
|
| 106 |
+
def calculate_score(self, category: str, district: str, floor: int,
|
| 107 |
+
budget: Optional[float] = None) -> StartupScore:
|
| 108 |
+
"""창업 성공 점수 계산"""
|
| 109 |
+
|
| 110 |
+
# 해당 지역 필터링
|
| 111 |
+
district_df = self.df[self.df['시군구명'].str.contains(district, na=False)]
|
| 112 |
+
|
| 113 |
+
if district_df.empty:
|
| 114 |
+
return self._create_default_score("해당 지역 데이터가 없습니다.")
|
| 115 |
+
|
| 116 |
+
# 1. 경쟁강도 점수 (0-20점)
|
| 117 |
+
competition_score = self._calculate_competition_score(district_df, category)
|
| 118 |
+
|
| 119 |
+
# 2. 포화도 점수 (0-20점)
|
| 120 |
+
saturation_score = self._calculate_saturation_score(district_df, category)
|
| 121 |
+
|
| 122 |
+
# 3. 입지 점수 (0-25점)
|
| 123 |
+
location_score = self._calculate_location_score(district_df, category)
|
| 124 |
+
|
| 125 |
+
# 4. 층수 적합도 점수 (0-20점)
|
| 126 |
+
floor_score = self._calculate_floor_score(district_df, category, floor)
|
| 127 |
+
|
| 128 |
+
# 5. 업종 다양성 점수 (0-15점)
|
| 129 |
+
diversity_score = self._calculate_diversity_score(district_df)
|
| 130 |
+
|
| 131 |
+
# 총점 계산
|
| 132 |
+
total_score = (competition_score + saturation_score + location_score +
|
| 133 |
+
floor_score + diversity_score)
|
| 134 |
+
|
| 135 |
+
# 추천 메시지 생성
|
| 136 |
+
recommendation = self._generate_recommendation(total_score, {
|
| 137 |
+
'competition': competition_score,
|
| 138 |
+
'saturation': saturation_score,
|
| 139 |
+
'location': location_score,
|
| 140 |
+
'floor': floor_score,
|
| 141 |
+
'diversity': diversity_score
|
| 142 |
+
})
|
| 143 |
+
|
| 144 |
+
# 상세 정보
|
| 145 |
+
details = {
|
| 146 |
+
'동종업종수': len(district_df[district_df['상권업종중분류명'] == category]),
|
| 147 |
+
'전체점포수': len(district_df),
|
| 148 |
+
'업종비율': f"{len(district_df[district_df['상권업종중분류명'] == category]) / len(district_df) * 100:.1f}%",
|
| 149 |
+
'지역': district
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
return StartupScore(
|
| 153 |
+
total_score=total_score,
|
| 154 |
+
competition_score=competition_score,
|
| 155 |
+
saturation_score=saturation_score,
|
| 156 |
+
location_score=location_score,
|
| 157 |
+
floor_score=floor_score,
|
| 158 |
+
diversity_score=diversity_score,
|
| 159 |
+
recommendation=recommendation,
|
| 160 |
+
details=details
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
def _calculate_competition_score(self, district_df: pd.DataFrame, category: str) -> float:
|
| 164 |
+
"""경쟁강도 점수 (낮을수록 좋음)"""
|
| 165 |
+
total_stores = len(district_df)
|
| 166 |
+
same_category = len(district_df[district_df['상권업종중분류명'] == category])
|
| 167 |
+
|
| 168 |
+
if total_stores == 0:
|
| 169 |
+
return 10.0
|
| 170 |
+
|
| 171 |
+
ratio = same_category / total_stores
|
| 172 |
+
|
| 173 |
+
# 비율이 낮을수록 높은 점수 (경쟁이 적음)
|
| 174 |
+
if ratio < 0.05:
|
| 175 |
+
return 20.0
|
| 176 |
+
elif ratio < 0.10:
|
| 177 |
+
return 15.0
|
| 178 |
+
elif ratio < 0.15:
|
| 179 |
+
return 10.0
|
| 180 |
+
elif ratio < 0.20:
|
| 181 |
+
return 5.0
|
| 182 |
+
else:
|
| 183 |
+
return 0.0
|
| 184 |
+
|
| 185 |
+
def _calculate_saturation_score(self, district_df: pd.DataFrame, category: str) -> float:
|
| 186 |
+
"""포화도 점수"""
|
| 187 |
+
same_category = len(district_df[district_df['상권업종중분류명'] == category])
|
| 188 |
+
|
| 189 |
+
# 동종 업종 수에 따른 점수
|
| 190 |
+
if same_category < 10:
|
| 191 |
+
return 20.0 # 블루오션
|
| 192 |
+
elif same_category < 30:
|
| 193 |
+
return 15.0
|
| 194 |
+
elif same_category < 50:
|
| 195 |
+
return 10.0
|
| 196 |
+
elif same_category < 100:
|
| 197 |
+
return 5.0
|
| 198 |
+
else:
|
| 199 |
+
return 0.0 # 레드오션
|
| 200 |
+
|
| 201 |
+
def _calculate_location_score(self, district_df: pd.DataFrame, category: str) -> float:
|
| 202 |
+
"""입지 점수"""
|
| 203 |
+
# 점포 밀집도가 적당히 높은 곳이 좋음 (유동인구가 많다는 의미)
|
| 204 |
+
density = len(district_df)
|
| 205 |
+
|
| 206 |
+
if 500 < density < 2000:
|
| 207 |
+
return 25.0 # 최적
|
| 208 |
+
elif 200 < density <= 500 or 2000 <= density < 3000:
|
| 209 |
+
return 20.0
|
| 210 |
+
elif 100 < density <= 200 or 3000 <= density < 5000:
|
| 211 |
+
return 15.0
|
| 212 |
+
elif density <= 100:
|
| 213 |
+
return 10.0 # 너무 한적함
|
| 214 |
+
else:
|
| 215 |
+
return 5.0 # 너무 혼잡함
|
| 216 |
+
|
| 217 |
+
def _calculate_floor_score(self, district_df: pd.DataFrame, category: str, floor: int) -> float:
|
| 218 |
+
"""층수 적합도 점수"""
|
| 219 |
+
# 해당 업종의 층별 분포 분석
|
| 220 |
+
category_df = district_df[district_df['상권업종중분류명'] == category]
|
| 221 |
+
|
| 222 |
+
if category_df.empty or '층정보' not in category_df.columns:
|
| 223 |
+
return 10.0
|
| 224 |
+
|
| 225 |
+
# 층 정보 파싱
|
| 226 |
+
category_df = category_df.copy()
|
| 227 |
+
category_df['층정보_숫자'] = category_df['층정보'].apply(self._parse_floor)
|
| 228 |
+
|
| 229 |
+
# 해당 업종에서 가장 많이 사용하는 층
|
| 230 |
+
floor_counts = category_df['층정보_숫자'].value_counts()
|
| 231 |
+
|
| 232 |
+
if floor_counts.empty:
|
| 233 |
+
return 10.0
|
| 234 |
+
|
| 235 |
+
most_common_floor = floor_counts.index[0]
|
| 236 |
+
|
| 237 |
+
# 선택한 층과 선호 층 비교
|
| 238 |
+
if floor == most_common_floor:
|
| 239 |
+
return 20.0
|
| 240 |
+
elif abs(floor - most_common_floor) <= 1:
|
| 241 |
+
return 15.0
|
| 242 |
+
else:
|
| 243 |
+
return 10.0
|
| 244 |
+
|
| 245 |
+
def _calculate_diversity_score(self, district_df: pd.DataFrame) -> float:
|
| 246 |
+
"""업종 다양성 점수"""
|
| 247 |
+
if '상권업종중분류명' not in district_df.columns:
|
| 248 |
+
return 7.5
|
| 249 |
+
|
| 250 |
+
num_categories = district_df['상권업종중분류명'].nunique()
|
| 251 |
+
|
| 252 |
+
# 다양성이 높을수록 좋음 (다양한 소비자층)
|
| 253 |
+
if num_categories > 50:
|
| 254 |
+
return 15.0
|
| 255 |
+
elif num_categories > 30:
|
| 256 |
+
return 12.0
|
| 257 |
+
elif num_categories > 20:
|
| 258 |
+
return 9.0
|
| 259 |
+
else:
|
| 260 |
+
return 6.0
|
| 261 |
+
|
| 262 |
+
def _parse_floor(self, floor_str):
|
| 263 |
+
"""층 정보를 숫자로 변환"""
|
| 264 |
+
if pd.isna(floor_str):
|
| 265 |
+
return 1
|
| 266 |
+
floor_str = str(floor_str)
|
| 267 |
+
if '지하' in floor_str or 'B' in floor_str:
|
| 268 |
+
match = re.search(r'\d+', floor_str)
|
| 269 |
+
return -int(match.group()) if match else -1
|
| 270 |
+
elif '1층' in floor_str or floor_str == '1':
|
| 271 |
+
return 1
|
| 272 |
+
else:
|
| 273 |
+
match = re.search(r'\d+', floor_str)
|
| 274 |
+
return int(match.group()) if match else 1
|
| 275 |
+
|
| 276 |
+
def _generate_recommendation(self, total_score: float, scores: Dict) -> str:
|
| 277 |
+
"""추천 메시지 생성"""
|
| 278 |
+
if total_score >= 80:
|
| 279 |
+
level = "🟢 매우 우수"
|
| 280 |
+
msg = "창업 성공 가능성이 매우 높습니다! 적극 추천합니다."
|
| 281 |
+
elif total_score >= 65:
|
| 282 |
+
level = "🔵 우수"
|
| 283 |
+
msg = "창업하기 좋은 조건입니다. 세부 계획을 잘 세우세요."
|
| 284 |
+
elif total_score >= 50:
|
| 285 |
+
level = "🟡 보통"
|
| 286 |
+
msg = "신중한 검토가 필요합니다. 차별화 전략을 준비하세요."
|
| 287 |
+
elif total_score >= 35:
|
| 288 |
+
level = "🟠 주의"
|
| 289 |
+
msg = "경쟁이 치열합니다. 재고를 권장합니다."
|
| 290 |
+
else:
|
| 291 |
+
level = "🔴 위험"
|
| 292 |
+
msg = "창업을 권장하지 않습니다. 다른 지역을 고려하세요."
|
| 293 |
+
|
| 294 |
+
# 가장 낮은 점수 항목 찾기
|
| 295 |
+
min_category = min(scores, key=scores.get)
|
| 296 |
+
weak_point = {
|
| 297 |
+
'competition': '경쟁이 매우 치열합니다',
|
| 298 |
+
'saturation': '시장이 포화 상태입니다',
|
| 299 |
+
'location': '입지가 최적이 아닙니다',
|
| 300 |
+
'floor': '층수 선택을 재고하세요',
|
| 301 |
+
'diversity': '업종 다양성이 부족합니다'
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
return f"{level}\n{msg}\n\n⚠️ 약점: {weak_point[min_category]}"
|
| 305 |
+
|
| 306 |
+
def _create_default_score(self, message: str) -> StartupScore:
|
| 307 |
+
"""기본 점수 생성"""
|
| 308 |
+
return StartupScore(
|
| 309 |
+
total_score=0.0,
|
| 310 |
+
competition_score=0.0,
|
| 311 |
+
saturation_score=0.0,
|
| 312 |
+
location_score=0.0,
|
| 313 |
+
floor_score=0.0,
|
| 314 |
+
diversity_score=0.0,
|
| 315 |
+
recommendation=message,
|
| 316 |
+
details={}
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
# ============================================================================
|
| 321 |
+
# 반경 분석기
|
| 322 |
+
# ============================================================================
|
| 323 |
+
|
| 324 |
+
class RadiusAnalyzer:
|
| 325 |
+
"""특정 위치 반경 분석"""
|
| 326 |
+
|
| 327 |
+
def __init__(self, df: pd.DataFrame):
|
| 328 |
+
self.df = df
|
| 329 |
+
|
| 330 |
+
def analyze_radius(self, lat: float, lon: float, radius_km: float,
|
| 331 |
+
category: Optional[str] = None) -> Dict:
|
| 332 |
+
"""반경 내 경쟁자 분석"""
|
| 333 |
+
|
| 334 |
+
# 반경 내 점포 필터링
|
| 335 |
+
nearby_stores = self._filter_by_radius(lat, lon, radius_km)
|
| 336 |
+
|
| 337 |
+
if nearby_stores.empty:
|
| 338 |
+
return {
|
| 339 |
+
'total_stores': 0,
|
| 340 |
+
'category_stores': 0,
|
| 341 |
+
'message': '반경 내 점포가 없습니다.'
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
# 동종 업종 필터링
|
| 345 |
+
if category:
|
| 346 |
+
category_stores = nearby_stores[nearby_stores['상권업종중분류명'] == category]
|
| 347 |
+
category_count = len(category_stores)
|
| 348 |
+
else:
|
| 349 |
+
category_count = 0
|
| 350 |
+
|
| 351 |
+
# 업종별 분포
|
| 352 |
+
category_distribution = nearby_stores['상권업종중분류명'].value_counts().head(10).to_dict()
|
| 353 |
+
|
| 354 |
+
# 경쟁 강도 계산
|
| 355 |
+
competition_level = self._calculate_competition_level(len(nearby_stores), category_count)
|
| 356 |
+
|
| 357 |
+
# 결과 반환
|
| 358 |
+
return {
|
| 359 |
+
'total_stores': len(nearby_stores),
|
| 360 |
+
'category_stores': category_count,
|
| 361 |
+
'radius_km': radius_km,
|
| 362 |
+
'competition_level': competition_level,
|
| 363 |
+
'category_distribution': category_distribution,
|
| 364 |
+
'nearby_coords': nearby_stores[['위도', '경도', '상권업종중분류명']].to_dict('records')
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
def _filter_by_radius(self, lat: float, lon: float, radius_km: float) -> pd.DataFrame:
|
| 368 |
+
"""반경 내 점포 필터링 (Haversine 공식)"""
|
| 369 |
+
|
| 370 |
+
# 위도/경도를 라디안으로 변환
|
| 371 |
+
lat_rad = np.radians(lat)
|
| 372 |
+
lon_rad = np.radians(lon)
|
| 373 |
+
|
| 374 |
+
df_lat_rad = np.radians(self.df['위도'])
|
| 375 |
+
df_lon_rad = np.radians(self.df['경도'])
|
| 376 |
+
|
| 377 |
+
# Haversine 공식
|
| 378 |
+
dlat = df_lat_rad - lat_rad
|
| 379 |
+
dlon = df_lon_rad - lon_rad
|
| 380 |
+
|
| 381 |
+
a = np.sin(dlat/2)**2 + np.cos(lat_rad) * np.cos(df_lat_rad) * np.sin(dlon/2)**2
|
| 382 |
+
c = 2 * np.arcsin(np.sqrt(a))
|
| 383 |
+
|
| 384 |
+
# 지구 반경 (km)
|
| 385 |
+
distance_km = 6371 * c
|
| 386 |
+
|
| 387 |
+
# 반경 내 필터링
|
| 388 |
+
return self.df[distance_km <= radius_km].copy()
|
| 389 |
+
|
| 390 |
+
def _calculate_competition_level(self, total: int, category: int) -> str:
|
| 391 |
+
"""경쟁 강도 계산"""
|
| 392 |
+
if category == 0:
|
| 393 |
+
return "데이터 없음"
|
| 394 |
+
|
| 395 |
+
ratio = category / total if total > 0 else 0
|
| 396 |
+
|
| 397 |
+
if ratio > 0.3:
|
| 398 |
+
return "🔴 매우 높음"
|
| 399 |
+
elif ratio > 0.2:
|
| 400 |
+
return "🟠 높음"
|
| 401 |
+
elif ratio > 0.1:
|
| 402 |
+
return "🟡 보통"
|
| 403 |
+
elif ratio > 0.05:
|
| 404 |
+
return "🔵 낮음"
|
| 405 |
+
else:
|
| 406 |
+
return "🟢 매우 낮음"
|
| 407 |
+
|
| 408 |
+
def create_radius_map(self, lat: float, lon: float, radius_km: float,
|
| 409 |
+
category: Optional[str] = None) -> str:
|
| 410 |
+
"""반경 분석 지도 생성"""
|
| 411 |
+
|
| 412 |
+
# 지도 생성
|
| 413 |
+
m = folium.Map(location=[lat, lon], zoom_start=14)
|
| 414 |
+
|
| 415 |
+
# 중심점 마커
|
| 416 |
+
folium.Marker(
|
| 417 |
+
[lat, lon],
|
| 418 |
+
popup="선택한 위치",
|
| 419 |
+
icon=folium.Icon(color='red', icon='star'),
|
| 420 |
+
tooltip="창업 희망 위치"
|
| 421 |
+
).add_to(m)
|
| 422 |
+
|
| 423 |
+
# 반경 원
|
| 424 |
+
folium.Circle(
|
| 425 |
+
location=[lat, lon],
|
| 426 |
+
radius=radius_km * 1000, # km to m
|
| 427 |
+
color='blue',
|
| 428 |
+
fill=True,
|
| 429 |
+
fillOpacity=0.1,
|
| 430 |
+
popup=f"반경 {radius_km}km"
|
| 431 |
+
).add_to(m)
|
| 432 |
+
|
| 433 |
+
# 반경 내 점포 표시
|
| 434 |
+
nearby_stores = self._filter_by_radius(lat, lon, radius_km)
|
| 435 |
+
|
| 436 |
+
if category:
|
| 437 |
+
# 동종 업종만 표시
|
| 438 |
+
category_stores = nearby_stores[nearby_stores['상권업종중분류명'] == category]
|
| 439 |
+
|
| 440 |
+
for _, row in category_stores.iterrows():
|
| 441 |
+
folium.CircleMarker(
|
| 442 |
+
location=[row['위도'], row['경도']],
|
| 443 |
+
radius=3,
|
| 444 |
+
color='red',
|
| 445 |
+
fill=True,
|
| 446 |
+
fillColor='red',
|
| 447 |
+
fillOpacity=0.6,
|
| 448 |
+
popup=f"{row['상권업종중분류명']}<br>{row.get('상호명', 'N/A')}"
|
| 449 |
+
).add_to(m)
|
| 450 |
+
|
| 451 |
+
return m._repr_html_()
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
# ============================================================================
|
| 455 |
+
# 최적 입지 추천 엔진
|
| 456 |
+
# ============================================================================
|
| 457 |
+
|
| 458 |
+
class LocationRecommender:
|
| 459 |
+
"""최적 입지 추천"""
|
| 460 |
+
|
| 461 |
+
def __init__(self, df: pd.DataFrame):
|
| 462 |
+
self.df = df
|
| 463 |
+
self.scorer = StartupScorer(df)
|
| 464 |
+
|
| 465 |
+
def recommend_locations(self, category: str, budget: Optional[float] = None,
|
| 466 |
+
preferred_regions: Optional[List[str]] = None,
|
| 467 |
+
top_n: int = 5) -> List[Dict]:
|
| 468 |
+
"""최적 입지 TOP N 추천"""
|
| 469 |
+
|
| 470 |
+
# 지역 필터링
|
| 471 |
+
if preferred_regions:
|
| 472 |
+
filtered_df = self.df[self.df['시도명'].isin(preferred_regions)]
|
| 473 |
+
else:
|
| 474 |
+
filtered_df = self.df
|
| 475 |
+
|
| 476 |
+
if filtered_df.empty:
|
| 477 |
+
return []
|
| 478 |
+
|
| 479 |
+
# 시군구별로 점수 계산
|
| 480 |
+
districts = filtered_df['시군구명'].unique()
|
| 481 |
+
recommendations = []
|
| 482 |
+
|
| 483 |
+
for district in districts[:50]: # 상위 50개 지역만 분석 (성능 고려)
|
| 484 |
+
score = self.scorer.calculate_score(category, district, floor=1)
|
| 485 |
+
|
| 486 |
+
district_df = filtered_df[filtered_df['시군구명'] == district]
|
| 487 |
+
|
| 488 |
+
recommendations.append({
|
| 489 |
+
'district': district,
|
| 490 |
+
'score': score.total_score,
|
| 491 |
+
'competition_score': score.competition_score,
|
| 492 |
+
'saturation_score': score.saturation_score,
|
| 493 |
+
'total_stores': len(district_df),
|
| 494 |
+
'category_stores': len(district_df[district_df['상권업종중분류명'] == category]),
|
| 495 |
+
'recommendation': score.recommendation.split('\n')[0], # 첫 줄만
|
| 496 |
+
'avg_lat': district_df['위도'].mean(),
|
| 497 |
+
'avg_lon': district_df['경도'].mean()
|
| 498 |
+
})
|
| 499 |
+
|
| 500 |
+
# 점수 순으로 정렬
|
| 501 |
+
recommendations.sort(key=lambda x: x['score'], reverse=True)
|
| 502 |
+
|
| 503 |
+
return recommendations[:top_n]
|
| 504 |
+
|
| 505 |
+
def create_recommendation_map(self, recommendations: List[Dict]) -> str:
|
| 506 |
+
"""추천 지역 지도 생성"""
|
| 507 |
+
|
| 508 |
+
if not recommendations:
|
| 509 |
+
return "<p>추천 결과가 없습니다.</p>"
|
| 510 |
+
|
| 511 |
+
# 중심점 계산
|
| 512 |
+
avg_lat = np.mean([r['avg_lat'] for r in recommendations])
|
| 513 |
+
avg_lon = np.mean([r['avg_lon'] for r in recommendations])
|
| 514 |
+
|
| 515 |
+
# 지도 생성
|
| 516 |
+
m = folium.Map(location=[avg_lat, avg_lon], zoom_start=11)
|
| 517 |
+
|
| 518 |
+
# 추천 지역 마커
|
| 519 |
+
colors = ['green', 'blue', 'orange', 'purple', 'red']
|
| 520 |
+
|
| 521 |
+
for i, rec in enumerate(recommendations):
|
| 522 |
+
color = colors[i] if i < len(colors) else 'gray'
|
| 523 |
+
|
| 524 |
+
folium.Marker(
|
| 525 |
+
location=[rec['avg_lat'], rec['avg_lon']],
|
| 526 |
+
popup=f"""
|
| 527 |
+
<b>{i+1}위: {rec['district']}</b><br>
|
| 528 |
+
점수: {rec['score']:.1f}점<br>
|
| 529 |
+
동종업종: {rec['category_stores']}개<br>
|
| 530 |
+
{rec['recommendation']}
|
| 531 |
+
""",
|
| 532 |
+
icon=folium.Icon(color=color, icon='info-sign'),
|
| 533 |
+
tooltip=f"#{i+1} {rec['district']} ({rec['score']:.1f}점)"
|
| 534 |
+
).add_to(m)
|
| 535 |
+
|
| 536 |
+
return m._repr_html_()
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
# ============================================================================
|
| 540 |
+
# 프랜차이즈 시뮬레이터
|
| 541 |
+
# ============================================================================
|
| 542 |
+
|
| 543 |
+
class FranchiseSimulator:
|
| 544 |
+
"""프랜차이즈 vs 개인사업자 비교"""
|
| 545 |
+
|
| 546 |
+
def __init__(self, df: pd.DataFrame):
|
| 547 |
+
self.df = df
|
| 548 |
+
|
| 549 |
+
def analyze_franchise_ratio(self, category: str, district: Optional[str] = None) -> Dict:
|
| 550 |
+
"""프랜차이즈 비율 분석"""
|
| 551 |
+
|
| 552 |
+
# 지역 필터링
|
| 553 |
+
if district:
|
| 554 |
+
filtered_df = self.df[self.df['시군구명'].str.contains(district, na=False)]
|
| 555 |
+
else:
|
| 556 |
+
filtered_df = self.df
|
| 557 |
+
|
| 558 |
+
# 해당 업종 필터링
|
| 559 |
+
category_df = filtered_df[filtered_df['상권업종중분류명'] == category]
|
| 560 |
+
|
| 561 |
+
if category_df.empty:
|
| 562 |
+
return {
|
| 563 |
+
'total': 0,
|
| 564 |
+
'franchise': 0,
|
| 565 |
+
'individual': 0,
|
| 566 |
+
'franchise_ratio': 0.0,
|
| 567 |
+
'message': '데이터가 없습니다.'
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
# 프랜차이즈 판별 (브랜드명이 있는 경우)
|
| 571 |
+
# 상호명에 특정 패턴이 있으면 프랜차이즈로 간주
|
| 572 |
+
if '상호명' in category_df.columns:
|
| 573 |
+
# 숫자가 들어간 상호명(ex: 스타벅스 강남점) 또는 반복되는 이름
|
| 574 |
+
name_counts = category_df['상호명'].value_counts()
|
| 575 |
+
franchise_names = name_counts[name_counts > 1].index.tolist()
|
| 576 |
+
|
| 577 |
+
franchise_count = len(category_df[category_df['상호명'].isin(franchise_names)])
|
| 578 |
+
individual_count = len(category_df) - franchise_count
|
| 579 |
+
else:
|
| 580 |
+
# 상호명 정보가 없으면 추정
|
| 581 |
+
franchise_count = int(len(category_df) * 0.3) # 30% 추정
|
| 582 |
+
individual_count = len(category_df) - franchise_count
|
| 583 |
+
|
| 584 |
+
franchise_ratio = franchise_count / len(category_df) * 100
|
| 585 |
+
|
| 586 |
+
# 추천 메시지
|
| 587 |
+
if franchise_ratio > 70:
|
| 588 |
+
message = "🔴 프랜차이즈 비율이 매우 높습니다. 개인 사업자에게 불리할 수 있습니다."
|
| 589 |
+
elif franchise_ratio > 50:
|
| 590 |
+
message = "🟠 프랜차이즈 비율이 높습니다. 차별화 전략이 필요합니다."
|
| 591 |
+
elif franchise_ratio > 30:
|
| 592 |
+
message = "🟡 프랜차이즈와 개인이 균형을 이루고 있습니다."
|
| 593 |
+
else:
|
| 594 |
+
message = "🟢 개인 사업자 비율이 높습니다. 진입하기 좋은 시장입니다."
|
| 595 |
+
|
| 596 |
+
return {
|
| 597 |
+
'total': len(category_df),
|
| 598 |
+
'franchise': franchise_count,
|
| 599 |
+
'individual': individual_count,
|
| 600 |
+
'franchise_ratio': franchise_ratio,
|
| 601 |
+
'message': message,
|
| 602 |
+
'district': district or '전체'
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
def create_comparison_chart(self, analysis: Dict) -> go.Figure:
|
| 606 |
+
"""프랜차이즈 비교 차트"""
|
| 607 |
+
|
| 608 |
+
fig = go.Figure(data=[
|
| 609 |
+
go.Bar(
|
| 610 |
+
name='프랜차이즈',
|
| 611 |
+
x=['점포 수'],
|
| 612 |
+
y=[analysis['franchise']],
|
| 613 |
+
text=[f"{analysis['franchise']}개<br>({analysis['franchise_ratio']:.1f}%)"],
|
| 614 |
+
textposition='auto',
|
| 615 |
+
marker_color='#e74c3c'
|
| 616 |
+
),
|
| 617 |
+
go.Bar(
|
| 618 |
+
name='개인사업자',
|
| 619 |
+
x=['점포 수'],
|
| 620 |
+
y=[analysis['individual']],
|
| 621 |
+
text=[f"{analysis['individual']}개<br>({100-analysis['franchise_ratio']:.1f}%)"],
|
| 622 |
+
textposition='auto',
|
| 623 |
+
marker_color='#3498db'
|
| 624 |
+
)
|
| 625 |
+
])
|
| 626 |
+
|
| 627 |
+
fig.update_layout(
|
| 628 |
+
title=f"프랜차이즈 vs 개인사업자 비율 - {analysis['district']}",
|
| 629 |
+
barmode='group',
|
| 630 |
+
height=400,
|
| 631 |
+
showlegend=True
|
| 632 |
+
)
|
| 633 |
+
|
| 634 |
+
return fig
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
# ============================================================================
|
| 638 |
+
# 상권 분석 클래스 (기존)
|
| 639 |
# ============================================================================
|
| 640 |
|
| 641 |
class MarketAnalyzer:
|
|
|
|
| 644 |
def __init__(self, df: pd.DataFrame):
|
| 645 |
self.df = df
|
| 646 |
self.prepare_data()
|
| 647 |
+
|
| 648 |
+
# 새로운 분석기 초기화
|
| 649 |
+
self.startup_scorer = StartupScorer(df)
|
| 650 |
+
self.radius_analyzer = RadiusAnalyzer(df)
|
| 651 |
+
self.location_recommender = LocationRecommender(df)
|
| 652 |
+
self.franchise_simulator = FranchiseSimulator(df)
|
| 653 |
|
| 654 |
def prepare_data(self):
|
| 655 |
"""데이터 전처리"""
|
|
|
|
| 677 |
match = re.search(r'\d+', floor_str)
|
| 678 |
return int(match.group()) if match else None
|
| 679 |
|
| 680 |
+
def create_interactive_map(self, sample_size: int = 3000) -> str:
|
| 681 |
+
"""인터랙티브 클릭 가능한 밀집도 지도"""
|
|
|
|
| 682 |
|
| 683 |
+
if self.df.empty or len(self.df) == 0:
|
| 684 |
+
return "<p>데이터가 없습니다.</p>"
|
| 685 |
|
| 686 |
+
# 샘플링
|
| 687 |
+
df_sample = self.df.sample(n=min(sample_size, len(self.df)), random_state=42)
|
| 688 |
|
| 689 |
+
# 중심점 계산
|
| 690 |
+
center_lat = df_sample['위도'].mean()
|
| 691 |
+
center_lon = df_sample['경도'].mean()
|
| 692 |
|
| 693 |
+
# 지도 생성
|
| 694 |
+
m = folium.Map(
|
| 695 |
+
location=[center_lat, center_lon],
|
| 696 |
+
zoom_start=12,
|
| 697 |
+
tiles='OpenStreetMap'
|
| 698 |
+
)
|
| 699 |
|
| 700 |
+
# 히트맵 데이터
|
| 701 |
+
heat_data = [[row['위도'], row['경도']] for _, row in df_sample.iterrows()]
|
| 702 |
+
HeatMap(heat_data, radius=10, blur=15, max_zoom=13).add_to(m)
|
| 703 |
+
|
| 704 |
+
# 클릭 가능한 마커 클러스터
|
| 705 |
+
marker_cluster = MarkerCluster(name="점포 클러스터").add_to(m)
|
| 706 |
+
|
| 707 |
+
# 업종별 색상
|
| 708 |
+
category_colors = {}
|
| 709 |
+
color_palette = ['blue', 'red', 'green', 'purple', 'orange', 'darkred',
|
| 710 |
+
'lightred', 'beige', 'darkblue', 'darkgreen']
|
| 711 |
+
|
| 712 |
+
for i, category in enumerate(df_sample['상권업종중분류명'].unique()[:10]):
|
| 713 |
+
category_colors[category] = color_palette[i % len(color_palette)]
|
| 714 |
+
|
| 715 |
+
# 마커 추가 (샘플링하여 성능 개선)
|
| 716 |
+
for idx, row in df_sample.head(500).iterrows():
|
| 717 |
+
category = row.get('상권업종중분류명', 'N/A')
|
| 718 |
+
color = category_colors.get(category, 'gray')
|
| 719 |
+
|
| 720 |
+
popup_html = f"""
|
| 721 |
+
<div style="font-family: Arial; width: 200px;">
|
| 722 |
+
<h4 style="margin: 0 0 10px 0;">{row.get('상호명', 'N/A')}</h4>
|
| 723 |
+
<p style="margin: 5px 0;"><b>업종:</b> {category}</p>
|
| 724 |
+
<p style="margin: 5px 0;"><b>주소:</b> {row.get('도로명주소', 'N/A')}</p>
|
| 725 |
+
<p style="margin: 5px 0;"><b>층:</b> {row.get('층정보', 'N/A')}</p>
|
| 726 |
+
<hr style="margin: 10px 0;">
|
| 727 |
+
<p style="margin: 5px 0; font-size: 12px;">
|
| 728 |
+
📍 위도: {row['위도']:.6f}<br>
|
| 729 |
+
📍 경도: {row['경도']:.6f}
|
| 730 |
+
</p>
|
| 731 |
+
</div>
|
| 732 |
+
"""
|
| 733 |
+
|
| 734 |
+
folium.Marker(
|
| 735 |
+
location=[row['위도'], row['경도']],
|
| 736 |
+
popup=folium.Popup(popup_html, max_width=250),
|
| 737 |
+
tooltip=f"{category}",
|
| 738 |
+
icon=folium.Icon(color=color, icon='info-sign')
|
| 739 |
+
).add_to(marker_cluster)
|
| 740 |
+
|
| 741 |
+
# 레이어 컨트롤
|
| 742 |
+
folium.LayerControl().add_to(m)
|
| 743 |
+
|
| 744 |
+
# 클릭 이벤트를 위한 JavaScript 추가
|
| 745 |
+
click_js = """
|
| 746 |
+
<script>
|
| 747 |
+
function onMapClick(e) {
|
| 748 |
+
var lat = e.latlng.lat.toFixed(6);
|
| 749 |
+
var lng = e.latlng.lng.toFixed(6);
|
| 750 |
+
|
| 751 |
+
// Gradio의 텍스트박스에 좌표 입력
|
| 752 |
+
var latInput = document.querySelector('input[placeholder*="위도"]');
|
| 753 |
+
var lngInput = document.querySelector('input[placeholder*="경도"]');
|
| 754 |
+
|
| 755 |
+
if (latInput) latInput.value = lat;
|
| 756 |
+
if (lngInput) lngInput.value = lng;
|
| 757 |
+
|
| 758 |
+
// 이벤트 트리거
|
| 759 |
+
if (latInput) latInput.dispatchEvent(new Event('input', { bubbles: true }));
|
| 760 |
+
if (lngInput) lngInput.dispatchEvent(new Event('input', { bubbles: true }));
|
| 761 |
+
|
| 762 |
+
alert('선택된 위치\\n위도: ' + lat + '\\n경도: ' + lng);
|
| 763 |
+
}
|
| 764 |
+
</script>
|
| 765 |
+
"""
|
| 766 |
|
| 767 |
+
return m._repr_html_() + click_js
|
| 768 |
+
|
| 769 |
+
def analyze_for_llm(self) -> Dict:
|
| 770 |
+
"""LLM을 위한 데이터 컨텍스트 생성"""
|
| 771 |
|
| 772 |
+
context = {
|
| 773 |
+
'총_점포_수': len(self.df),
|
| 774 |
+
'업종_수': self.df['상권업종중분류명'].nunique() if '상권업종중분류명' in self.df.columns else 0,
|
| 775 |
+
'상위_업종': self.df['상권업종중분류명'].value_counts().head(10).to_dict() if '상권업종중분류명' in self.df.columns else {},
|
| 776 |
+
'지역_분포': self.df['시군구명'].value_counts().head(10).to_dict() if '시군구명' in self.df.columns else {},
|
| 777 |
+
}
|
| 778 |
|
| 779 |
+
# 층 정보
|
| 780 |
+
if '층정보_숫자' in self.df.columns:
|
| 781 |
+
floor_counts = self.df['층정보_숫자'].value_counts()
|
| 782 |
+
first_floor = floor_counts.get(1, 0)
|
| 783 |
+
context['1층_비율'] = f"{first_floor / len(self.df) * 100:.1f}%"
|
| 784 |
|
| 785 |
+
return context
|
| 786 |
+
|
| 787 |
+
def get_comprehensive_insights(self) -> List[Dict]:
|
| 788 |
+
"""포괄적인 인사이트 생성 (기존 10가지 분석)"""
|
| 789 |
+
insights = []
|
| 790 |
|
| 791 |
+
# 기존 분석들 (간략화)
|
| 792 |
+
insights.append(self._create_top_categories_chart())
|
| 793 |
+
insights.append(self._create_major_category_pie())
|
| 794 |
+
insights.append(self._create_floor_analysis())
|
| 795 |
+
insights.append(self._create_diversity_index())
|
| 796 |
|
| 797 |
return insights
|
| 798 |
|
|
|
|
| 840 |
floor_data = self.df['층정보_숫자'].dropna()
|
| 841 |
floor_counts = floor_data.value_counts().sort_index()
|
| 842 |
|
|
|
|
| 843 |
underground = floor_counts[floor_counts.index < 0].sum()
|
| 844 |
first_floor = floor_counts.get(1, 0)
|
| 845 |
upper_floors = floor_counts[floor_counts.index > 1].sum()
|
|
|
|
| 856 |
)
|
| 857 |
])
|
| 858 |
fig.update_layout(
|
| 859 |
+
title='🏢 층별 점포 분포',
|
| 860 |
xaxis_title='층 구분',
|
| 861 |
yaxis_title='점포 수',
|
| 862 |
height=400
|
|
|
|
| 868 |
if '시군구명' not in self.df.columns or '상권업종중분류명' not in self.df.columns:
|
| 869 |
return None
|
| 870 |
|
|
|
|
| 871 |
diversity_data = []
|
| 872 |
+
for district in self.df['시군구명'].unique()[:20]:
|
| 873 |
district_df = self.df[self.df['시군구명'] == district]
|
| 874 |
num_categories = district_df['상권업종중분류명'].nunique()
|
| 875 |
total_stores = len(district_df)
|
| 876 |
diversity_score = (num_categories / total_stores) * 100
|
| 877 |
diversity_data.append({
|
| 878 |
'시군구': district,
|
| 879 |
+
'다양성 지수': diversity_score
|
|
|
|
|
|
|
| 880 |
})
|
| 881 |
|
| 882 |
+
df_diversity = pd.DataFrame(diversity_data).sort_values('다양성 지수', ascending=False)
|
| 883 |
|
| 884 |
fig = px.bar(
|
| 885 |
+
df_diversity,
|
| 886 |
x='다양성 지수',
|
| 887 |
y='시군구',
|
| 888 |
orientation='h',
|
| 889 |
+
title='🌈 지역별 업종 다양성 지수 TOP 20',
|
|
|
|
| 890 |
color='다양성 지수',
|
| 891 |
+
color_continuous_scale='viridis'
|
|
|
|
| 892 |
)
|
| 893 |
+
fig.update_layout(height=500, showlegend=False)
|
| 894 |
return {'type': 'plot', 'data': fig, 'title': '업종 다양성 분석'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 895 |
|
| 896 |
|
| 897 |
# ============================================================================
|
| 898 |
+
# LLM 프로세서 (스트리밍 지원)
|
| 899 |
# ============================================================================
|
| 900 |
|
| 901 |
class LLMQueryProcessor:
|
| 902 |
+
"""Fireworks AI를 활용한 상권 분석 챗봇 (스트리밍)"""
|
| 903 |
|
| 904 |
+
def __init__(self):
|
| 905 |
+
self.api_key = os.getenv("FIREWORKS_API_KEY")
|
|
|
|
|
|
|
|
|
|
| 906 |
if not self.api_key:
|
| 907 |
+
raise ValueError("FIREWORKS_API_KEY 환경변수가 설정되지 않았습니다.")
|
| 908 |
+
|
| 909 |
+
self.base_url = "https://api.fireworks.ai/inference/v1/chat/completions"
|
| 910 |
+
self.model = "accounts/fireworks/models/qwen3-235b-fp8"
|
| 911 |
|
| 912 |
+
def process_query_stream(self, query: str, data_context: Dict, chat_history: List[Dict]):
|
| 913 |
+
"""스트리밍 방식으로 응답 생성"""
|
| 914 |
+
|
| 915 |
+
system_prompt = f"""당신은 대한민국 상권 분석 전문가입니다.
|
| 916 |
+
소상공인들에게 실용적이고 구체적인 창업 조언을 제공합니다.
|
|
|
|
| 917 |
|
| 918 |
+
현재 분석 중인 데이터:
|
| 919 |
+
- 총 점포 수: {data_context.get('총_점포_수', 0):,}개
|
| 920 |
+
- 분석 업종: {data_context.get('업종_수', 0)}개
|
| 921 |
+
- 주요 업종: {', '.join(list(data_context.get('상위_업종', {}).keys())[:5])}
|
| 922 |
+
- 주요 지역: {', '.join(list(data_context.get('지역_분포', {}).keys())[:5])}
|
| 923 |
|
| 924 |
+
답변 시 주의사항:
|
| 925 |
+
1. 구체적인 숫자와 데이터를 활용하세요
|
| 926 |
+
2. 창업 리스크와 기회를 균형있게 제시하세요
|
| 927 |
+
3. 실행 가능한 조언을 제공하세요
|
| 928 |
+
4. 이모지를 활용해 가독성을 높이세요
|
| 929 |
+
5. 한국 소상공인의 현실을 반영하세요"""
|
| 930 |
+
|
| 931 |
messages = [{"role": "system", "content": system_prompt}]
|
| 932 |
+
messages.extend(chat_history)
|
|
|
|
| 933 |
messages.append({"role": "user", "content": query})
|
| 934 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 935 |
headers = {
|
| 936 |
"Authorization": f"Bearer {self.api_key}",
|
| 937 |
"Content-Type": "application/json"
|
| 938 |
}
|
| 939 |
|
| 940 |
+
payload = {
|
| 941 |
+
"model": self.model,
|
| 942 |
+
"messages": messages,
|
| 943 |
+
"max_tokens": 4096, # 토큰 수 증가
|
| 944 |
+
"temperature": 0.7,
|
| 945 |
+
"top_p": 0.9,
|
| 946 |
+
"stream": True # 스트리밍 활성화
|
| 947 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 948 |
|
| 949 |
+
try:
|
| 950 |
+
response = requests.post(
|
| 951 |
+
self.base_url,
|
| 952 |
+
headers=headers,
|
| 953 |
+
json=payload,
|
| 954 |
+
stream=True,
|
| 955 |
+
timeout=60
|
| 956 |
+
)
|
| 957 |
+
response.raise_for_status()
|
| 958 |
+
|
| 959 |
+
# 스트리밍 응답 처리
|
| 960 |
+
for line in response.iter_lines():
|
| 961 |
+
if line:
|
| 962 |
+
line = line.decode('utf-8')
|
| 963 |
+
if line.startswith('data: '):
|
| 964 |
+
data = line[6:] # 'data: ' 제거
|
| 965 |
+
if data == '[DONE]':
|
| 966 |
+
break
|
| 967 |
+
try:
|
| 968 |
+
json_data = json.loads(data)
|
| 969 |
+
if 'choices' in json_data and len(json_data['choices']) > 0:
|
| 970 |
+
delta = json_data['choices'][0].get('delta', {})
|
| 971 |
+
content = delta.get('content', '')
|
| 972 |
+
if content:
|
| 973 |
+
yield content
|
| 974 |
+
except json.JSONDecodeError:
|
| 975 |
+
continue
|
| 976 |
+
|
| 977 |
+
except requests.exceptions.RequestException as e:
|
| 978 |
+
yield f"\n\n❌ API 오류: {str(e)}"
|
| 979 |
|
| 980 |
|
| 981 |
# ============================================================================
|
|
|
|
| 983 |
# ============================================================================
|
| 984 |
|
| 985 |
class AppState:
|
| 986 |
+
"""앱 전역 상태"""
|
| 987 |
def __init__(self):
|
| 988 |
+
self.df = None
|
| 989 |
self.analyzer = None
|
| 990 |
self.llm_processor = None
|
|
|
|
| 991 |
|
| 992 |
app_state = AppState()
|
| 993 |
|
| 994 |
|
| 995 |
# ============================================================================
|
| 996 |
+
# Gradio 이벤트 핸들러
|
| 997 |
# ============================================================================
|
| 998 |
|
| 999 |
def load_data(regions):
|
| 1000 |
"""데이터 로드"""
|
|
|
|
|
|
|
|
|
|
| 1001 |
try:
|
| 1002 |
+
if not regions:
|
| 1003 |
+
return "⚠️ 지역을 선택해주세요!", None, None, None
|
| 1004 |
+
|
| 1005 |
+
if len(regions) > 5:
|
| 1006 |
+
return "⚠️ 최대 5개 지역까지 선택 가능합니다!", None, None, None
|
| 1007 |
+
|
| 1008 |
df = MarketDataLoader.load_multiple_regions(regions, sample_per_region=30000)
|
| 1009 |
+
|
| 1010 |
if df.empty:
|
| 1011 |
return "❌ 데이터 로드 실패!", None, None, None
|
| 1012 |
|
| 1013 |
+
app_state.df = df
|
| 1014 |
app_state.analyzer = MarketAnalyzer(df)
|
| 1015 |
|
|
|
|
| 1016 |
stats = f"""
|
| 1017 |
✅ **데이터 로드 완료!**
|
| 1018 |
|
|
|
|
| 1021 |
- 분석 지역: {', '.join(regions)}
|
| 1022 |
- 업종 수: {df['상권업종중분류명'].nunique()}개
|
| 1023 |
- 대분류: {df['상권업종대분류명'].nunique()}개
|
| 1024 |
+
|
| 1025 |
+
🎯 **새로운 기능 활용하기**
|
| 1026 |
+
1. 📊 인사이트 탭: 기본 분석 확인
|
| 1027 |
+
2. 🎯 창업 분석 탭: 성공 스코어 계산
|
| 1028 |
+
3. 🤖 AI 챗봇 탭: 무엇이든 물어보기
|
| 1029 |
"""
|
| 1030 |
|
| 1031 |
return stats, gr.update(visible=True), gr.update(visible=True), gr.update(visible=True)
|
|
|
|
| 1034 |
|
| 1035 |
|
| 1036 |
def generate_insights():
|
| 1037 |
+
"""기��� 인사이트 생성"""
|
| 1038 |
if app_state.analyzer is None:
|
| 1039 |
+
return [None] * 5
|
| 1040 |
|
| 1041 |
insights = app_state.analyzer.get_comprehensive_insights()
|
| 1042 |
+
map_html = app_state.analyzer.create_interactive_map(sample_size=3000)
|
| 1043 |
|
| 1044 |
result = [map_html]
|
| 1045 |
+
for insight in insights[:4]:
|
| 1046 |
if insight and insight['type'] == 'plot':
|
| 1047 |
result.append(insight['data'])
|
| 1048 |
else:
|
| 1049 |
result.append(None)
|
| 1050 |
|
| 1051 |
+
return result
|
| 1052 |
+
|
| 1053 |
+
|
| 1054 |
+
def calculate_startup_score(category, district, floor, budget):
|
| 1055 |
+
"""창업 성공 점수 계산"""
|
| 1056 |
+
if app_state.analyzer is None:
|
| 1057 |
+
return "❌ 먼저 데이터를 로드해주세요!", None
|
| 1058 |
+
|
| 1059 |
+
try:
|
| 1060 |
+
floor_num = int(floor) if floor else 1
|
| 1061 |
+
budget_num = float(budget) if budget else None
|
| 1062 |
+
|
| 1063 |
+
score = app_state.analyzer.startup_scorer.calculate_score(
|
| 1064 |
+
category, district, floor_num, budget_num
|
| 1065 |
+
)
|
| 1066 |
+
|
| 1067 |
+
# 결과 텍스트
|
| 1068 |
+
result_text = f"""
|
| 1069 |
+
# 🎯 창업 성공 확률 분석 결과
|
| 1070 |
+
|
| 1071 |
+
## 📊 종합 점수: **{score.total_score:.1f}점 / 100점**
|
| 1072 |
+
|
| 1073 |
+
{score.recommendation}
|
| 1074 |
+
|
| 1075 |
+
---
|
| 1076 |
+
|
| 1077 |
+
### 📈 세부 점수
|
| 1078 |
+
|
| 1079 |
+
| 항목 | 점수 | 설명 |
|
| 1080 |
+
|------|------|------|
|
| 1081 |
+
| 🎯 경쟁강도 | {score.competition_score:.1f}/20 | 동종 업종 경쟁 수준 |
|
| 1082 |
+
| 📊 포화도 | {score.saturation_score:.1f}/20 | 시장 포화 정도 |
|
| 1083 |
+
| 📍 입지 | {score.location_score:.1f}/25 | 상권 밀집도 및 유동인구 |
|
| 1084 |
+
| 🏢 층수 적합도 | {score.floor_score:.1f}/20 | 업종별 층수 선호도 |
|
| 1085 |
+
| 🌈 업종 다양성 | {score.diversity_score:.1f}/15 | 지역 내 업종 다양성 |
|
| 1086 |
+
|
| 1087 |
+
---
|
| 1088 |
+
|
| 1089 |
+
### 📋 상세 정보
|
| 1090 |
+
|
| 1091 |
+
- **지역**: {score.details.get('지역', 'N/A')}
|
| 1092 |
+
- **동종 업종 수**: {score.details.get('동종업종수', 0):,}개
|
| 1093 |
+
- **전체 점포 수**: {score.details.get('전체점포수', 0):,}개
|
| 1094 |
+
- **업종 비율**: {score.details.get('업종비율', 'N/A')}
|
| 1095 |
+
|
| 1096 |
+
---
|
| 1097 |
+
|
| 1098 |
+
### 💡 창업 팁
|
| 1099 |
+
|
| 1100 |
+
{'- 경쟁이 적은 시간대나 특화 메뉴로 차별화하세요' if score.competition_score < 15 else ''}
|
| 1101 |
+
{'- 온라인 마케팅과 배달 서비스를 적극 활용하세요' if score.location_score < 20 else ''}
|
| 1102 |
+
{'- 프랜차이즈 가맹을 고려해보세요' if score.saturation_score < 15 else ''}
|
| 1103 |
+
"""
|
| 1104 |
+
|
| 1105 |
+
# 점수 시각화 차트
|
| 1106 |
+
fig = go.Figure(data=[
|
| 1107 |
+
go.Bar(
|
| 1108 |
+
x=['경쟁강도', '포화도', '입지', '층수', '다양성'],
|
| 1109 |
+
y=[score.competition_score, score.saturation_score,
|
| 1110 |
+
score.location_score, score.floor_score, score.diversity_score],
|
| 1111 |
+
text=[f"{score.competition_score:.1f}/20",
|
| 1112 |
+
f"{score.saturation_score:.1f}/20",
|
| 1113 |
+
f"{score.location_score:.1f}/25",
|
| 1114 |
+
f"{score.floor_score:.1f}/20",
|
| 1115 |
+
f"{score.diversity_score:.1f}/15"],
|
| 1116 |
+
textposition='auto',
|
| 1117 |
+
marker_color=['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6']
|
| 1118 |
+
)
|
| 1119 |
+
])
|
| 1120 |
+
|
| 1121 |
+
fig.update_layout(
|
| 1122 |
+
title=f"창업 성공 확률: {score.total_score:.1f}점",
|
| 1123 |
+
yaxis_title='점수',
|
| 1124 |
+
height=400,
|
| 1125 |
+
showlegend=False
|
| 1126 |
+
)
|
| 1127 |
+
|
| 1128 |
+
return result_text, fig
|
| 1129 |
+
|
| 1130 |
+
except Exception as e:
|
| 1131 |
+
return f"❌ 오류: {str(e)}", None
|
| 1132 |
+
|
| 1133 |
+
|
| 1134 |
+
def analyze_radius(lat, lon, radius, category):
|
| 1135 |
+
"""반경 분석"""
|
| 1136 |
+
if app_state.analyzer is None:
|
| 1137 |
+
return "❌ 먼저 데이터를 로드해주세요!", None, None
|
| 1138 |
+
|
| 1139 |
+
try:
|
| 1140 |
+
lat_f = float(lat)
|
| 1141 |
+
lon_f = float(lon)
|
| 1142 |
+
radius_f = float(radius)
|
| 1143 |
+
|
| 1144 |
+
analysis = app_state.analyzer.radius_analyzer.analyze_radius(
|
| 1145 |
+
lat_f, lon_f, radius_f, category if category else None
|
| 1146 |
+
)
|
| 1147 |
+
|
| 1148 |
+
# 결과 텍스트
|
| 1149 |
+
result_text = f"""
|
| 1150 |
+
# 📍 반경 {radius_f}km 경쟁자 분석
|
| 1151 |
+
|
| 1152 |
+
## 🎯 분석 결과
|
| 1153 |
+
|
| 1154 |
+
- **총 점포 수**: {analysis['total_stores']:,}개
|
| 1155 |
+
- **동종 업종 수**: {analysis['category_stores']:,}개
|
| 1156 |
+
- **경쟁 강도**: {analysis['competition_level']}
|
| 1157 |
+
|
| 1158 |
+
---
|
| 1159 |
+
|
| 1160 |
+
### 📊 주요 업종 TOP 10
|
| 1161 |
+
|
| 1162 |
+
"""
|
| 1163 |
+
for i, (cat, count) in enumerate(list(analysis['category_distribution'].items())[:10], 1):
|
| 1164 |
+
result_text += f"{i}. **{cat}**: {count}개\n"
|
| 1165 |
+
|
| 1166 |
+
result_text += f"""
|
| 1167 |
+
---
|
| 1168 |
+
|
| 1169 |
+
### 💡 분석 의견
|
| 1170 |
+
|
| 1171 |
+
"""
|
| 1172 |
+
if analysis['category_stores'] == 0:
|
| 1173 |
+
result_text += "✅ 동종 업종이 없어 경쟁이 없습니다. 블루오션 기회!\n"
|
| 1174 |
+
elif analysis['category_stores'] < 5:
|
| 1175 |
+
result_text += "🟢 경쟁이 적습니다. 창업하기 좋은 환경입니다.\n"
|
| 1176 |
+
elif analysis['category_stores'] < 15:
|
| 1177 |
+
result_text += "🟡 적당한 경쟁이 있습니다. 차별화 전략이 필요합니다.\n"
|
| 1178 |
+
elif analysis['category_stores'] < 30:
|
| 1179 |
+
result_text += "🟠 경쟁이 치열합니다. 신중한 검토가 필요합니다.\n"
|
| 1180 |
+
else:
|
| 1181 |
+
result_text += "🔴 경쟁이 매우 치열합니다. 다른 지역을 고려하세요.\n"
|
| 1182 |
+
|
| 1183 |
+
# 지도 생성
|
| 1184 |
+
map_html = app_state.analyzer.radius_analyzer.create_radius_map(
|
| 1185 |
+
lat_f, lon_f, radius_f, category if category else None
|
| 1186 |
+
)
|
| 1187 |
+
|
| 1188 |
+
# 차트 생성
|
| 1189 |
+
top_10 = dict(list(analysis['category_distribution'].items())[:10])
|
| 1190 |
+
fig = px.bar(
|
| 1191 |
+
x=list(top_10.values()),
|
| 1192 |
+
y=list(top_10.keys()),
|
| 1193 |
+
orientation='h',
|
| 1194 |
+
title=f'반경 {radius_f}km 내 업종 분포',
|
| 1195 |
+
labels={'x': '점포 수', 'y': '업종'},
|
| 1196 |
+
color=list(top_10.values()),
|
| 1197 |
+
color_continuous_scale='reds'
|
| 1198 |
+
)
|
| 1199 |
+
fig.update_layout(height=500, showlegend=False)
|
| 1200 |
+
|
| 1201 |
+
return result_text, map_html, fig
|
| 1202 |
+
|
| 1203 |
+
except Exception as e:
|
| 1204 |
+
return f"❌ 오류: {str(e)}", None, None
|
| 1205 |
+
|
| 1206 |
+
|
| 1207 |
+
def recommend_locations(category, budget, regions):
|
| 1208 |
+
"""최적 입지 추천"""
|
| 1209 |
+
if app_state.analyzer is None:
|
| 1210 |
+
return "❌ 먼저 데이터를 로드해주세요!", None, None
|
| 1211 |
+
|
| 1212 |
+
try:
|
| 1213 |
+
budget_num = float(budget) if budget else None
|
| 1214 |
+
preferred = regions if regions else None
|
| 1215 |
+
|
| 1216 |
+
recommendations = app_state.analyzer.location_recommender.recommend_locations(
|
| 1217 |
+
category, budget_num, preferred, top_n=5
|
| 1218 |
+
)
|
| 1219 |
+
|
| 1220 |
+
if not recommendations:
|
| 1221 |
+
return "❌ 추천 결과가 없습니다.", None, None
|
| 1222 |
+
|
| 1223 |
+
# 결과 텍스트
|
| 1224 |
+
result_text = f"""
|
| 1225 |
+
# 🏆 최적 입지 TOP 5 추천
|
| 1226 |
+
|
| 1227 |
+
**업종**: {category}
|
| 1228 |
+
**예산**: {f'{budget_num:,.0f}만원' if budget_num else '미지정'}
|
| 1229 |
+
|
| 1230 |
+
---
|
| 1231 |
+
|
| 1232 |
+
"""
|
| 1233 |
+
|
| 1234 |
+
for i, rec in enumerate(recommendations, 1):
|
| 1235 |
+
medal = ['🥇', '🥈', '🥉', '4️⃣', '5️⃣'][i-1]
|
| 1236 |
+
result_text += f"""
|
| 1237 |
+
### {medal} {i}위: {rec['district']}
|
| 1238 |
+
|
| 1239 |
+
- **종합 점수**: {rec['score']:.1f}점
|
| 1240 |
+
- **경쟁 점수**: {rec['competition_score']:.1f}/20
|
| 1241 |
+
- **포화도 점수**: {rec['saturation_score']:.1f}/20
|
| 1242 |
+
- **총 점포**: {rec['total_stores']:,}개
|
| 1243 |
+
- **동종 업종**: {rec['category_stores']}개
|
| 1244 |
+
- **평가**: {rec['recommendation']}
|
| 1245 |
+
|
| 1246 |
+
---
|
| 1247 |
+
"""
|
| 1248 |
+
|
| 1249 |
+
# 지도 생성
|
| 1250 |
+
map_html = app_state.analyzer.location_recommender.create_recommendation_map(recommendations)
|
| 1251 |
+
|
| 1252 |
+
# 차트 생성
|
| 1253 |
+
fig = go.Figure(data=[
|
| 1254 |
+
go.Bar(
|
| 1255 |
+
x=[rec['district'] for rec in recommendations],
|
| 1256 |
+
y=[rec['score'] for rec in recommendations],
|
| 1257 |
+
text=[f"{rec['score']:.1f}점" for rec in recommendations],
|
| 1258 |
+
textposition='auto',
|
| 1259 |
+
marker_color=['#2ecc71', '#3498db', '#f39c12', '#e74c3c', '#95a5a6']
|
| 1260 |
+
)
|
| 1261 |
+
])
|
| 1262 |
+
|
| 1263 |
+
fig.update_layout(
|
| 1264 |
+
title='지역별 창업 적합도 점수',
|
| 1265 |
+
xaxis_title='지역',
|
| 1266 |
+
yaxis_title='점수',
|
| 1267 |
+
height=400,
|
| 1268 |
+
showlegend=False
|
| 1269 |
+
)
|
| 1270 |
+
|
| 1271 |
+
return result_text, map_html, fig
|
| 1272 |
+
|
| 1273 |
+
except Exception as e:
|
| 1274 |
+
return f"❌ 오류: {str(e)}", None, None
|
| 1275 |
+
|
| 1276 |
+
|
| 1277 |
+
def analyze_franchise(category, district):
|
| 1278 |
+
"""프랜차이즈 분석"""
|
| 1279 |
+
if app_state.analyzer is None:
|
| 1280 |
+
return "❌ 먼저 데이터를 로드해주세요!", None
|
| 1281 |
|
| 1282 |
+
try:
|
| 1283 |
+
analysis = app_state.analyzer.franchise_simulator.analyze_franchise_ratio(
|
| 1284 |
+
category, district if district else None
|
| 1285 |
+
)
|
| 1286 |
+
|
| 1287 |
+
result_text = f"""
|
| 1288 |
+
# 🏢 프랜차이즈 vs 개인사업자 분석
|
| 1289 |
+
|
| 1290 |
+
**업종**: {category}
|
| 1291 |
+
**지역**: {analysis['district']}
|
| 1292 |
+
|
| 1293 |
+
---
|
| 1294 |
+
|
| 1295 |
+
## 📊 현황
|
| 1296 |
+
|
| 1297 |
+
- **총 점포 수**: {analysis['total']:,}개
|
| 1298 |
+
- **프랜차이즈**: {analysis['franchise']:,}개 ({analysis['franchise_ratio']:.1f}%)
|
| 1299 |
+
- **개인사업자**: {analysis['individual']:,}개 ({100-analysis['franchise_ratio']:.1f}%)
|
| 1300 |
+
|
| 1301 |
+
---
|
| 1302 |
+
|
| 1303 |
+
## 💡 분석 의견
|
| 1304 |
+
|
| 1305 |
+
{analysis['message']}
|
| 1306 |
|
| 1307 |
+
---
|
| 1308 |
|
| 1309 |
+
## 🎯 전략 제안
|
| 1310 |
+
|
| 1311 |
+
"""
|
| 1312 |
+
|
| 1313 |
+
if analysis['franchise_ratio'] > 70:
|
| 1314 |
+
result_text += """
|
| 1315 |
+
- 프랜차이즈 가맹을 고려하세요 (브랜드 인지도 활용)
|
| 1316 |
+
- 틈새 시장 공략 (프랜차이즈가 제공하지 못하는 서비스)
|
| 1317 |
+
- 지역 밀착형 마케팅 강화
|
| 1318 |
+
"""
|
| 1319 |
+
elif analysis['franchise_ratio'] > 50:
|
| 1320 |
+
result_text += """
|
| 1321 |
+
- 차별화된 메뉴나 서비스 개발
|
| 1322 |
+
- SNS 마케팅 적극 활용
|
| 1323 |
+
- 고객 충성도 프로그램 운영
|
| 1324 |
+
"""
|
| 1325 |
+
else:
|
| 1326 |
+
result_text += """
|
| 1327 |
+
- 개인 브랜드 구축에 집중
|
| 1328 |
+
- 합리적인 가격 정책
|
| 1329 |
+
- 지역 커뮤니티와의 협력
|
| 1330 |
+
"""
|
| 1331 |
+
|
| 1332 |
+
# 차트 생성
|
| 1333 |
+
fig = app_state.analyzer.franchise_simulator.create_comparison_chart(analysis)
|
| 1334 |
+
|
| 1335 |
+
return result_text, fig
|
| 1336 |
+
|
| 1337 |
+
except Exception as e:
|
| 1338 |
+
return f"❌ 오류: {str(e)}", None
|
| 1339 |
+
|
| 1340 |
+
|
| 1341 |
+
def chat_respond_stream(message, history):
|
| 1342 |
+
"""스트리밍 챗봇 응답"""
|
| 1343 |
if app_state.analyzer is None:
|
| 1344 |
+
history.append([message, "❌ 먼저 데이터를 로드해주세요!"])
|
| 1345 |
+
return history
|
| 1346 |
|
| 1347 |
data_context = app_state.analyzer.analyze_for_llm()
|
| 1348 |
|
|
|
|
| 1349 |
try:
|
| 1350 |
if app_state.llm_processor is None:
|
| 1351 |
app_state.llm_processor = LLMQueryProcessor()
|
| 1352 |
|
| 1353 |
+
# 채팅 히스토리 변환
|
| 1354 |
chat_hist = []
|
| 1355 |
for user_msg, bot_msg in history:
|
| 1356 |
chat_hist.append({"role": "user", "content": user_msg})
|
| 1357 |
+
if bot_msg: # bot_msg가 None이 아닌 경우만
|
| 1358 |
+
chat_hist.append({"role": "assistant", "content": bot_msg})
|
| 1359 |
+
|
| 1360 |
+
# 스트리밍 응답
|
| 1361 |
+
history.append([message, ""])
|
| 1362 |
|
| 1363 |
+
for chunk in app_state.llm_processor.process_query_stream(message, data_context, chat_hist):
|
| 1364 |
+
history[-1][1] += chunk
|
| 1365 |
+
yield history
|
| 1366 |
+
time.sleep(0.01) # 부드러운 스트리밍을 위한 딜레이
|
| 1367 |
|
| 1368 |
except ValueError as e:
|
|
|
|
| 1369 |
response = f"""📊 **기본 데이터 분석 결과**
|
| 1370 |
|
| 1371 |
**전체 현황**
|
|
|
|
| 1373 |
- 업종 종류: {data_context['업종_수']}개
|
| 1374 |
- 1층 비율: {data_context.get('1층_비율', 'N/A')}
|
| 1375 |
|
| 1376 |
+
⚠️ **AI 분석 활성화 방법**
|
| 1377 |
+
환경변수 설정: `FIREWORKS_API_KEY`
|
| 1378 |
+
"""
|
| 1379 |
+
history[-1][1] = response
|
| 1380 |
+
yield history
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1381 |
|
| 1382 |
|
| 1383 |
# ============================================================================
|
| 1384 |
# Gradio UI
|
| 1385 |
# ============================================================================
|
| 1386 |
|
| 1387 |
+
with gr.Blocks(title="AI 상권 분석 시스템 Pro Max", theme=gr.themes.Soft()) as demo:
|
| 1388 |
gr.Markdown("""
|
| 1389 |
+
# 🏪 AI 상권 분석 시스템 Pro Max
|
| 1390 |
+
*전국 상가(상권) 데이터 기반 + AI 창업 컨설팅 | Powered by Qwen3-235B*
|
| 1391 |
|
| 1392 |
+
**✨ 프리미엄 기능**: 창업 성공 스코어링 | 반경 경쟁자 분석 | 최적 입지 추천 | 프랜차이즈 비교 | 인터랙티브 지도
|
| 1393 |
""")
|
| 1394 |
|
| 1395 |
with gr.Row():
|
| 1396 |
with gr.Column(scale=1):
|
| 1397 |
gr.Markdown("### ⚙️ 설정")
|
| 1398 |
|
| 1399 |
+
api_status = "✅ API 키 설정됨" if os.getenv("FIREWORKS_API_KEY") else "⚠️ API 키 미설정"
|
|
|
|
| 1400 |
gr.Markdown(f"**🔑 API 상태**: {api_status}")
|
| 1401 |
|
| 1402 |
region_select = gr.CheckboxGroup(
|
| 1403 |
choices=list(MarketDataLoader.REGIONS.keys()),
|
| 1404 |
value=['서울'],
|
| 1405 |
+
label="📍 분석 지역 선택 (최대 5개)"
|
| 1406 |
)
|
| 1407 |
|
| 1408 |
load_btn = gr.Button("📊 데이터 로드", variant="primary", size="lg")
|
|
|
|
| 1409 |
status_box = gr.Markdown("👈 지역을 선택하고 데이터를 로드하세요!")
|
| 1410 |
|
| 1411 |
with gr.Column(scale=3):
|
| 1412 |
with gr.Tabs() as tabs:
|
| 1413 |
+
# 탭 1: 기본 인사이트
|
| 1414 |
+
with gr.Tab("📊 인사이트", id=0):
|
| 1415 |
insights_content = gr.Column(visible=False)
|
| 1416 |
|
| 1417 |
with insights_content:
|
| 1418 |
+
gr.Markdown("### 🗺️ 인터랙티브 히트맵 (클릭하면 좌표 자동 입력)")
|
| 1419 |
map_output = gr.HTML()
|
| 1420 |
|
| 1421 |
+
gr.Markdown("### 📈 주요 인사이트")
|
|
|
|
|
|
|
| 1422 |
with gr.Row():
|
| 1423 |
chart1 = gr.Plot(label="업종별 점포 수")
|
| 1424 |
chart2 = gr.Plot(label="대분류 분포")
|
|
|
|
| 1426 |
with gr.Row():
|
| 1427 |
chart3 = gr.Plot(label="층별 분포")
|
| 1428 |
chart4 = gr.Plot(label="업종 다양성")
|
| 1429 |
+
|
| 1430 |
+
# 탭 2: 창업 분석 (신규)
|
| 1431 |
+
with gr.Tab("🎯 창업 분석", id=1):
|
| 1432 |
+
startup_content = gr.Column(visible=False)
|
| 1433 |
+
|
| 1434 |
+
with startup_content:
|
| 1435 |
+
gr.Markdown("## 🎯 창업 성공 확률 계산기")
|
| 1436 |
|
| 1437 |
with gr.Row():
|
| 1438 |
+
with gr.Column():
|
| 1439 |
+
startup_category = gr.Textbox(label="업종명", placeholder="예: 한식음식점")
|
| 1440 |
+
startup_district = gr.Textbox(label="지역명", placeholder="예: 강남구")
|
| 1441 |
+
with gr.Column():
|
| 1442 |
+
startup_floor = gr.Number(label="희망 층수", value=1)
|
| 1443 |
+
startup_budget = gr.Number(label="예산 (만원)", value=5000)
|
| 1444 |
+
|
| 1445 |
+
startup_btn = gr.Button("📊 성공 확률 계산", variant="primary")
|
| 1446 |
+
startup_result = gr.Markdown()
|
| 1447 |
+
startup_chart = gr.Plot()
|
| 1448 |
+
|
| 1449 |
+
gr.Markdown("---")
|
| 1450 |
+
gr.Markdown("## 📍 반경 경쟁자 분석")
|
| 1451 |
|
| 1452 |
with gr.Row():
|
| 1453 |
+
with gr.Column():
|
| 1454 |
+
radius_lat = gr.Number(label="위도 (지도에서 클릭)", value=37.5665)
|
| 1455 |
+
radius_lon = gr.Number(label="경도 (지도에서 클릭)", value=126.9780)
|
| 1456 |
+
with gr.Column():
|
| 1457 |
+
radius_km = gr.Slider(0.5, 5.0, value=1.0, step=0.5, label="반경 (km)")
|
| 1458 |
+
radius_category = gr.Textbox(label="업종명 (선택)", placeholder="예: 커피")
|
| 1459 |
+
|
| 1460 |
+
radius_btn = gr.Button("🔍 반경 분석", variant="primary")
|
| 1461 |
+
radius_result = gr.Markdown()
|
| 1462 |
|
| 1463 |
with gr.Row():
|
| 1464 |
+
radius_map = gr.HTML()
|
| 1465 |
+
radius_chart = gr.Plot()
|
| 1466 |
+
|
| 1467 |
+
gr.Markdown("---")
|
| 1468 |
+
gr.Markdown("## 🏆 최적 입지 추천")
|
| 1469 |
+
|
| 1470 |
+
with gr.Row():
|
| 1471 |
+
with gr.Column():
|
| 1472 |
+
recommend_category = gr.Textbox(label="업종명", placeholder="예: 카페")
|
| 1473 |
+
recommend_budget = gr.Number(label="예산 (만원)", value=5000)
|
| 1474 |
+
with gr.Column():
|
| 1475 |
+
recommend_regions = gr.CheckboxGroup(
|
| 1476 |
+
choices=list(MarketDataLoader.REGIONS.keys()),
|
| 1477 |
+
label="선호 지역 (선택)"
|
| 1478 |
+
)
|
| 1479 |
+
|
| 1480 |
+
recommend_btn = gr.Button("🎯 입지 추천받기", variant="primary")
|
| 1481 |
+
recommend_result = gr.Markdown()
|
| 1482 |
+
|
| 1483 |
+
with gr.Row():
|
| 1484 |
+
recommend_map = gr.HTML()
|
| 1485 |
+
recommend_chart = gr.Plot()
|
| 1486 |
+
|
| 1487 |
+
gr.Markdown("---")
|
| 1488 |
+
gr.Markdown("## 🏢 프랜차이즈 vs 개인 분석")
|
| 1489 |
+
|
| 1490 |
+
with gr.Row():
|
| 1491 |
+
franchise_category = gr.Textbox(label="업종명", placeholder="예: 치킨전문점")
|
| 1492 |
+
franchise_district = gr.Textbox(label="지역명 (선택)", placeholder="예: 송파구")
|
| 1493 |
+
|
| 1494 |
+
franchise_btn = gr.Button("📊 프랜차이즈 분석", variant="primary")
|
| 1495 |
+
franchise_result = gr.Markdown()
|
| 1496 |
+
franchise_chart = gr.Plot()
|
| 1497 |
|
| 1498 |
+
# 탭 3: AI 챗봇
|
| 1499 |
+
with gr.Tab("🤖 AI 챗봇", id=2):
|
| 1500 |
chat_content = gr.Column(visible=False)
|
| 1501 |
|
| 1502 |
with chat_content:
|
| 1503 |
gr.Markdown("""
|
| 1504 |
### 💡 샘플 질문
|
| 1505 |
+
강남에서 카페 창업? | 치킨집 포화 지역? | 1층이 유리한 업종? | 프랜차이즈 vs 개인?
|
| 1506 |
""")
|
| 1507 |
|
| 1508 |
+
chatbot = gr.Chatbot(height=500, label="AI 상권 분석 어시스턴트 (스트리밍)")
|
| 1509 |
|
| 1510 |
with gr.Row():
|
| 1511 |
msg_input = gr.Textbox(
|
| 1512 |
+
placeholder="무엇이든 물어보세요!",
|
| 1513 |
show_label=False,
|
| 1514 |
scale=4
|
| 1515 |
)
|
| 1516 |
submit_btn = gr.Button("전송", variant="primary", scale=1)
|
| 1517 |
|
|
|
|
| 1518 |
with gr.Row():
|
| 1519 |
sample_btn1 = gr.Button("강남에서 카페 창업?", size="sm")
|
| 1520 |
sample_btn2 = gr.Button("치킨집 포화 지역?", size="sm")
|
| 1521 |
sample_btn3 = gr.Button("1층이 유리한 업종?", size="sm")
|
| 1522 |
+
sample_btn4 = gr.Button("프랜차이즈 vs 개인?", size="sm")
|
| 1523 |
|
| 1524 |
# 이벤트 핸들러
|
| 1525 |
load_btn.click(
|
| 1526 |
fn=load_data,
|
| 1527 |
inputs=[region_select],
|
| 1528 |
+
outputs=[status_box, insights_content, startup_content, chat_content]
|
| 1529 |
).then(
|
| 1530 |
fn=generate_insights,
|
| 1531 |
+
outputs=[map_output, chart1, chart2, chart3, chart4]
|
| 1532 |
+
)
|
| 1533 |
+
|
| 1534 |
+
# 창업 분석 이벤트
|
| 1535 |
+
startup_btn.click(
|
| 1536 |
+
fn=calculate_startup_score,
|
| 1537 |
+
inputs=[startup_category, startup_district, startup_floor, startup_budget],
|
| 1538 |
+
outputs=[startup_result, startup_chart]
|
| 1539 |
)
|
| 1540 |
|
| 1541 |
+
# 반경 분석 이벤트
|
| 1542 |
+
radius_btn.click(
|
| 1543 |
+
fn=analyze_radius,
|
| 1544 |
+
inputs=[radius_lat, radius_lon, radius_km, radius_category],
|
| 1545 |
+
outputs=[radius_result, radius_map, radius_chart]
|
| 1546 |
+
)
|
| 1547 |
+
|
| 1548 |
+
# 입지 추천 이벤트
|
| 1549 |
+
recommend_btn.click(
|
| 1550 |
+
fn=recommend_locations,
|
| 1551 |
+
inputs=[recommend_category, recommend_budget, recommend_regions],
|
| 1552 |
+
outputs=[recommend_result, recommend_map, recommend_chart]
|
| 1553 |
+
)
|
| 1554 |
+
|
| 1555 |
+
# 프랜차이즈 분석 이벤트
|
| 1556 |
+
franchise_btn.click(
|
| 1557 |
+
fn=analyze_franchise,
|
| 1558 |
+
inputs=[franchise_category, franchise_district],
|
| 1559 |
+
outputs=[franchise_result, franchise_chart]
|
| 1560 |
+
)
|
| 1561 |
+
|
| 1562 |
+
# 챗봇 이벤트 (스트리밍)
|
| 1563 |
submit_btn.click(
|
| 1564 |
+
fn=chat_respond_stream,
|
| 1565 |
inputs=[msg_input, chatbot],
|
| 1566 |
outputs=[chatbot]
|
| 1567 |
).then(
|
|
|
|
| 1570 |
)
|
| 1571 |
|
| 1572 |
msg_input.submit(
|
| 1573 |
+
fn=chat_respond_stream,
|
| 1574 |
inputs=[msg_input, chatbot],
|
| 1575 |
outputs=[chatbot]
|
| 1576 |
).then(
|
|
|
|
| 1579 |
)
|
| 1580 |
|
| 1581 |
# 샘플 버튼 이벤트
|
| 1582 |
+
sample_btn1.click(lambda: "강남에서 카페 창업하려면 어떤 점을 고려해야 할까요?", outputs=[msg_input])
|
| 1583 |
+
sample_btn2.click(lambda: "치킨집이 포화된 지역은 어디인가요?", outputs=[msg_input])
|
| 1584 |
+
sample_btn3.click(lambda: "1층이 유리한 업종은 무엇인가요?", outputs=[msg_input])
|
| 1585 |
+
sample_btn4.click(lambda: "프랜차이즈와 개인 사업자의 장단점을 비교해주세요.", outputs=[msg_input])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1586 |
|
| 1587 |
gr.Markdown("""
|
| 1588 |
---
|
| 1589 |
+
### 🎯 프리미엄 기능 가이드
|
| 1590 |
+
|
| 1591 |
+
**1. 창업 성공 스코어링** 🎯
|
| 1592 |
+
- 업종, 지역, 층수, 예산 입력으로 성공 확률 계산
|
| 1593 |
+
- 5가지 지표 (경쟁강도, 포화도, 입지, 층수, 다양성)
|
| 1594 |
+
|
| 1595 |
+
**2. 반경 경쟁자 분석** 📍
|
| 1596 |
+
- 지도에서 클릭하여 위치 선택
|
| 1597 |
+
- 반경 0.5~5km 내 경쟁자 현황 분석
|
| 1598 |
+
|
| 1599 |
+
**3. 최적 입지 추천** 🏆
|
| 1600 |
+
- AI가 최적의 창업 지역 TOP 5 추천
|
| 1601 |
+
- 각 지역별 점수와 장단점 제공
|
| 1602 |
+
|
| 1603 |
+
**4. 프랜차이즈 vs 개인** 🏢
|
| 1604 |
+
- 업종별 프랜차이즈 비율 분석
|
| 1605 |
+
- 창업 형태 선택을 위한 전략 제안
|
| 1606 |
+
|
| 1607 |
+
**5. AI 챗봇 (스트리밍)** 🤖
|
| 1608 |
+
- Qwen3-235B 모델 (최대 4096 토큰)
|
| 1609 |
+
- 실시간 스트리밍 응답
|
| 1610 |
+
- 데이터 기반 맞춤형 조언
|
| 1611 |
+
|
| 1612 |
+
### 🔑 API 설정
|
| 1613 |
+
환경변수: `export FIREWORKS_API_KEY="your_key"`
|
|
|
|
|
|
|
| 1614 |
""")
|
| 1615 |
|
|
|
|
| 1616 |
if __name__ == "__main__":
|
| 1617 |
demo.launch(server_name="0.0.0.0", server_port=7860, share=False)
|