ginipick commited on
Commit
2f7c9b3
·
verified ·
1 Parent(s): f1ac0c2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1208 -447
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 get_comprehensive_insights(self) -> List[Dict]:
120
- """포괄적인 인사이트 생성"""
121
- insights = []
122
 
123
- # 1. 업종별 점포 (상권업종중분류)
124
- insights.append(self._create_top_categories_chart())
125
 
126
- # 2. 대분류별 분포 (파이 차트)
127
- insights.append(self._create_major_category_pie())
128
 
129
- # 3. 층별 분포 상세 분석
130
- insights.append(self._create_floor_analysis())
 
131
 
132
- # 4. 지역별 업종 다양성 지수
133
- insights.append(self._create_diversity_index())
 
 
 
 
134
 
135
- # 5. 프랜차이즈 vs 개인사업자 분석
136
- insights.append(self._create_franchise_analysis())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
- # 6. 업종별 층 선호도
139
- insights.append(self._create_floor_preference())
 
 
140
 
141
- # 7. 시군구별 상권 밀집도 TOP 20
142
- insights.append(self._create_district_density())
 
 
 
 
143
 
144
- # 8. 업종 상관관계 (같은 지역에 자주 나타나는 업종)
145
- insights.append(self._create_category_correlation())
 
 
 
146
 
147
- # 9. 소분류 트렌드 (상위 20개)
148
- insights.append(self._create_subcategory_trends())
 
 
 
149
 
150
- # 10. 지역별 특화 업종
151
- insights.append(self._create_regional_specialization())
 
 
 
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='🏢 층별 점포 분포 (지하 vs 1층 vs 상층)',
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]: # 상위 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
- diversity_df = pd.DataFrame(diversity_data).sort_values('다양성 지수', ascending=False).head(15)
243
 
244
  fig = px.bar(
245
- diversity_df,
246
  x='다양성 지수',
247
  y='시군구',
248
  orientation='h',
249
- title='🌈 지역별 업종 다양성 지수 (상위 15개)',
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, api_key: str = None):
508
- # 환경변수에서 API 키 가져오기
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("FIREWORKS_API_KEY 환경변수를 설정하거나 API 키를 입력해주세요!")
 
 
 
514
 
515
- def process_query(self, query: str, data_context: Dict, chat_history: List = None, max_retries: int = 3) -> str:
516
- """자연어 쿼리 처리 (재시도 로직 포함)"""
517
- system_prompt = f"""당신은 한국 상권 데이터 분석 전문가입니다.
518
-
519
- 📊 **현재 분석 데이터**
520
- {json.dumps(data_context, ensure_ascii=False, indent=2)}
521
 
522
- 구체적인 숫자와 비율로 정량적 분석을 제공하세요.
523
- 창업, 투자, 경쟁 분석 관점에서 실용적 인사이트를 제공하세요."""
 
 
 
524
 
 
 
 
 
 
 
 
525
  messages = [{"role": "system", "content": system_prompt}]
526
- if chat_history:
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
- for attempt in range(max_retries):
544
- try:
545
- # 타임아웃을 60초로 증가
546
- response = requests.post(
547
- self.base_url,
548
- headers=headers,
549
- json=payload,
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
- return "⚠️ 최대 재시도 횟수 초과. 잠시 후 다시 시도해주세요."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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] * 11
636
 
637
  insights = app_state.analyzer.get_comprehensive_insights()
638
- map_html = app_state.analyzer.create_density_map(sample_size=2000)
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
- # 부족한 차트는 None으로 채우기
648
- while len(result) < 11:
649
- result.append(None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
650
 
651
- return result[:11]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
 
 
653
 
654
- def chat_respond(message, history):
655
- """챗봇 응답"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
  if app_state.analyzer is None:
657
- return history + [[message, "❌ 먼저 데이터를 로드해주세요!"]]
 
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
- chat_hist.append({"role": "assistant", "content": bot_msg})
 
 
 
 
670
 
671
- response = app_state.llm_processor.process_query(message, data_context, chat_hist)
 
 
 
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
- ```bash
685
- export FIREWORKS_API_KEY="your_api_key_here"
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
- *전국 상가(상권) 데이터 기반 실시간 분석 | Powered by Fireworks AI*
702
 
703
- **✨ 10가지 심층 분석 제공**: 업종 트렌드, 경쟁 강도, 입지 분석, 프랜차이즈 비율, 지역 특화, 층별 선호도 등
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
- with gr.Tab("📊 인사이트 대시보드", id=0) as tab1:
 
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
- chart5 = gr.Plot(label="프랜차이즈 분석")
746
- chart6 = gr.Plot(label=" 선호도")
 
 
 
 
 
 
 
 
 
 
 
747
 
748
  with gr.Row():
749
- chart7 = gr.Plot(label="지역 밀집도")
750
- chart8 = gr.Plot(label="업종 상관관계")
 
 
 
 
 
 
 
751
 
752
  with gr.Row():
753
- chart9 = gr.Plot(label="소분류 트렌드")
754
- chart10 = gr.Plot(label="지역 특화")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
755
 
756
- with gr.Tab("🤖 AI 분석 챗봇", id=1) as tab2:
 
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=400, label="AI 상권 분석 어시스턴트")
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("프랜차이즈 점유율?", size="sm")
781
 
782
  # 이벤트 핸들러
783
  load_btn.click(
784
  fn=load_data,
785
  inputs=[region_select],
786
- outputs=[status_box, insights_content, chat_content, tab1]
787
  ).then(
788
  fn=generate_insights,
789
- outputs=[map_output, chart1, chart2, chart3, chart4, chart5, chart6, chart7, chart8, chart9, chart10]
 
 
 
 
 
 
 
790
  )
791
 
792
- # 챗봇 이벤트 (API 키 파라미터 제거)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
793
  submit_btn.click(
794
- fn=chat_respond,
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=chat_respond,
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
- for btn, text in [
813
- (sample_btn1, "강남에서 카페 창업?"),
814
- (sample_btn2, "치킨집 포화 지역?"),
815
- (sample_btn3, "1층이 유리한 업종?"),
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
- 1. 지역 선택 → 2. 데이터 로드 → 3. 10가지 인사이트 확인 또는 AI에게 질문
827
-
828
- ### 🔑 AI 챗봇 활성화 방법
829
- 환경변수 ���정:
830
- ```bash
831
- export FIREWORKS_API_KEY="your_api_key_here"
832
- ```
833
-
834
- Hugging Face Space에서는:
835
- 1. Settings 메뉴 클릭
836
- 2. Variables 선택
837
- 3. New variable 추가: `FIREWORKS_API_KEY`
838
-
839
- ### 📊 제공되는 10가지 분석
840
- 1. **업종별 점포 수**: 가장 많은 업종 TOP 15
841
- 2. **대분류 분포**: 소매/음식/서비스 대분류 비율
842
- 3. **층별 분포**: 지하/1층/상층 입지 분석
843
- 4. **업종 다양성**: 지역별 업종 다양성 지수
844
- 5. **프랜차이즈 분석**: 개인 vs 프랜차이즈 비율
845
- 6. **층 선호도**: 업종별 선호 층수
846
- 7. **지역 밀집도**: 점포 수 상위 지역
847
- 8. **업종 상관관계**: 같이 나타나는 업종 패턴
848
- 9. **소분류 트렌드**: 세부 업종 분포
849
- 10. **지역 특화**: 각 지역의 특화 업종
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)