""" AI 기반 상권 분석 시스템 - Comic Classic Theme 버전 Dataset: https://huggingface.co/datasets/ginipick/market """ import gradio as gr import pandas as pd import numpy as np from typing import Dict, List, Tuple import json from datasets import load_dataset import plotly.express as px import plotly.graph_objects as go from plotly.subplots import make_subplots import folium from folium.plugins import HeatMap, MarkerCluster import requests from collections import Counter import re import os import time # ============================================================================ # Brave 웹검색 클라이언트 # ============================================================================ class BraveSearchClient: """Brave Search API 클라이언트""" def __init__(self, api_key: str = None): self.api_key = api_key or os.getenv("BRAVE_API_KEY") self.base_url = "https://api.search.brave.com/res/v1/web/search" def search(self, query: str, count: int = 5) -> str: """웹 검색 수행""" if not self.api_key: return "⚠️ Brave Search API 키가 설정되지 않았습니다." headers = { "Accept": "application/json", "X-Subscription-Token": self.api_key } params = { "q": query, "count": count, "text_decorations": False, "search_lang": "ko" } try: response = requests.get(self.base_url, headers=headers, params=params, timeout=10) if response.status_code == 200: data = response.json() results = [] if 'web' in data and 'results' in data['web']: for item in data['web']['results'][:count]: title = item.get('title', '') description = item.get('description', '') url = item.get('url', '') results.append(f"📄 **{title}**\n{description}\n🔗 {url}") return "\n\n".join(results) if results else "검색 결과가 없습니다." else: return f"⚠️ 검색 실패: {response.status_code}" except Exception as e: return f"⚠️ 검색 오류: {str(e)}" # ============================================================================ # 데이터 로더 클래스 # ============================================================================ class MarketDataLoader: """허깅페이스 상권 데이터 로더""" REGIONS = { '서울': '서울_202506', '경기': '경기_202506', '부산': '부산_202506', '대구': '대구_202506', '인천': '인천_202506', '광주': '광주_202506', '대전': '대전_202506', '울산': '울산_202506', '세종': '세종_202506', '경남': '경남_202506', '경북': '경북_202506', '전남': '전남_202506', '전북': '전북_202506', '충남': '충남_202506', '충북': '충북_202506', '강원': '강원_202506', '제주': '제주_202506' } # 업종 분류 매핑 CATEGORY_MAPPING = { 'G2': '소매업', 'I1': '숙박업', 'I2': '음식점업', 'L1': '부동산업', 'M1': '전문/과학/기술', 'N1': '사업지원/임대', 'P1': '교육서비스', 'Q1': '보건의료', 'R1': '예술/스포츠/여가', 'S2': '수리/개인서비스' } @staticmethod def load_region_data(region: str, sample_size: int = 30000) -> pd.DataFrame: """지역별 데이터 로드""" try: file_name = f"소상공인시장진흥공단_상가(상권)정보_{MarketDataLoader.REGIONS[region]}.csv" dataset = load_dataset("ginipick/market", data_files=file_name, split="train") df = dataset.to_pandas() if len(df) > sample_size: df = df.sample(n=sample_size, random_state=42) return df except Exception as e: print(f"데이터 로드 실패: {str(e)}") return pd.DataFrame() @staticmethod def load_multiple_regions(regions: List[str], sample_per_region: int = 30000) -> pd.DataFrame: """여러 지역 데이터 로드""" dfs = [] for region in regions: df = MarketDataLoader.load_region_data(region, sample_per_region) if not df.empty: dfs.append(df) if dfs: return pd.concat(dfs, ignore_index=True) return pd.DataFrame() # ============================================================================ # 상권 분석 클래스 # ============================================================================ class MarketAnalyzer: """상권 데이터 분석 엔진""" def __init__(self, df: pd.DataFrame): self.df = df self.prepare_data() def prepare_data(self): """데이터 전처리""" if '경도' in self.df.columns: self.df['경도'] = pd.to_numeric(self.df['경도'], errors='coerce') if '위도' in self.df.columns: self.df['위도'] = pd.to_numeric(self.df['위도'], errors='coerce') self.df = self.df.dropna(subset=['경도', '위도']) # 층 정보 정제 if '층정보' in self.df.columns: self.df['층정보_숫자'] = self.df['층정보'].apply(self._parse_floor) def _parse_floor(self, floor_str): """층 정보를 숫자로 변환""" if pd.isna(floor_str): return None floor_str = str(floor_str) if '지하' in floor_str or 'B' in floor_str: match = re.search(r'\d+', floor_str) return -int(match.group()) if match else -1 elif '1층' in floor_str or floor_str == '1': return 1 else: match = re.search(r'\d+', floor_str) return int(match.group()) if match else None def get_comprehensive_insights(self) -> List[Dict]: """포괄적인 인사이트 생성""" insights = [] insights.append(self._create_top_categories_chart()) insights.append(self._create_major_category_pie()) insights.append(self._create_floor_analysis()) insights.append(self._create_diversity_index()) insights.append(self._create_franchise_analysis()) insights.append(self._create_floor_preference()) insights.append(self._create_district_density()) insights.append(self._create_category_correlation()) insights.append(self._create_subcategory_trends()) insights.append(self._create_regional_specialization()) return insights def _create_top_categories_chart(self) -> Dict: """업종별 점포 수 차트""" if '상권업종중분류명' not in self.df.columns: return None top_categories = self.df['상권업종중분류명'].value_counts().head(15) fig = px.bar( x=top_categories.values, y=top_categories.index, orientation='h', labels={'x': '점포 수', 'y': '업종'}, title='🏆 상위 업종 TOP 15', color=top_categories.values, color_continuous_scale='blues' ) fig.update_layout(showlegend=False, height=500) return {'type': 'plot', 'data': fig, 'title': '업종별 점포 수 분석'} def _create_major_category_pie(self) -> Dict: """대분류별 분포""" if '상권업종대분류코드' not in self.df.columns: return None major_counts = self.df['상권업종대분류코드'].value_counts() labels = [MarketDataLoader.CATEGORY_MAPPING.get(code, code) for code in major_counts.index] fig = px.pie( values=major_counts.values, names=labels, title='📊 업종 대분류 분포', hole=0.4, color_discrete_sequence=px.colors.qualitative.Set3 ) fig.update_traces(textposition='inside', textinfo='percent+label') return {'type': 'plot', 'data': fig, 'title': '대분류별 상권 구성'} def _create_floor_analysis(self) -> Dict: """층별 분포 상세 분석""" if '층정보_숫자' not in self.df.columns: return None floor_data = self.df['층정보_숫자'].dropna() floor_counts = floor_data.value_counts().sort_index() underground = floor_counts[floor_counts.index < 0].sum() first_floor = floor_counts.get(1, 0) upper_floors = floor_counts[floor_counts.index > 1].sum() fig = go.Figure(data=[ go.Bar( x=['지하', '1층', '2층 이상'], y=[underground, first_floor, upper_floors], text=[f'{underground:,}
({underground/len(floor_data)*100:.1f}%)', f'{first_floor:,}
({first_floor/len(floor_data)*100:.1f}%)', f'{upper_floors:,}
({upper_floors/len(floor_data)*100:.1f}%)'], textposition='auto', marker_color=['#e74c3c', '#3498db', '#95a5a6'] ) ]) fig.update_layout( title='🏢 층별 점포 분포 (지하 vs 1층 vs 상층)', xaxis_title='층 구분', yaxis_title='점포 수', height=400 ) return {'type': 'plot', 'data': fig, 'title': '층별 입지 분석'} def _create_diversity_index(self) -> Dict: """지역별 업종 다양성 지수""" if '시군구명' not in self.df.columns or '상권업종중분류명' not in self.df.columns: return None diversity_data = [] for district in self.df['시군구명'].unique()[:20]: district_df = self.df[self.df['시군구명'] == district] num_categories = district_df['상권업종중분류명'].nunique() total_stores = len(district_df) diversity_score = (num_categories / total_stores) * 100 diversity_data.append({ '지역': district, '다양성지수': diversity_score, '업종수': num_categories, '점포수': total_stores }) diversity_df = pd.DataFrame(diversity_data).sort_values('다양성지수', ascending=False) fig = px.bar( diversity_df, x='다양성지수', y='지역', orientation='h', title='🎨 지역별 업종 다양성 지수 (업종 수 / 점포 수 × 100)', labels={'다양성지수': '다양성 지수', '지역': '시군구'}, color='다양성지수', color_continuous_scale='viridis' ) fig.update_layout(height=500) return {'type': 'plot', 'data': fig, 'title': '상권 다양성 분석'} def _create_franchise_analysis(self) -> Dict: """프랜차이즈 vs 개인사업자 분석""" if '브랜드명' not in self.df.columns: return None franchise_count = self.df['브랜드명'].notna().sum() individual_count = self.df['브랜드명'].isna().sum() fig = go.Figure(data=[ go.Pie( labels=['개인사업자', '프랜차이즈'], values=[individual_count, franchise_count], hole=0.4, marker_colors=['#3498db', '#e74c3c'], textinfo='label+percent+value', texttemplate='%{label}
%{value:,}개
(%{percent})' ) ]) fig.update_layout( title='🏪 개인사업자 vs 프랜차이즈 비율', height=400 ) return {'type': 'plot', 'data': fig, 'title': '사업자 유형 분석'} def _create_floor_preference(self) -> Dict: """업종별 층 선호도""" if '층정보_숫자' not in self.df.columns or '상권업종중분류명' not in self.df.columns: return None top_categories = self.df['상권업종중분류명'].value_counts().head(10).index floor_pref_data = [] for category in top_categories: cat_df = self.df[self.df['상권업종중분류명'] == category] floor_dist = cat_df['층정보_숫자'].dropna() if len(floor_dist) > 0: underground = (floor_dist < 0).sum() first_floor = (floor_dist == 1).sum() upper_floors = (floor_dist > 1).sum() floor_pref_data.append({ '업종': category, '지하': underground, '1층': first_floor, '2층 이상': upper_floors }) pref_df = pd.DataFrame(floor_pref_data) fig = go.Figure() fig.add_trace(go.Bar(name='지하', x=pref_df['업종'], y=pref_df['지하'], marker_color='#e74c3c')) fig.add_trace(go.Bar(name='1층', x=pref_df['업종'], y=pref_df['1층'], marker_color='#3498db')) fig.add_trace(go.Bar(name='2층 이상', x=pref_df['업종'], y=pref_df['2층 이상'], marker_color='#95a5a6')) fig.update_layout( title='🏢 업종별 층 선호도 (상위 10개 업종)', xaxis_title='업종', yaxis_title='점포 수', barmode='stack', height=500, xaxis_tickangle=-45 ) return {'type': 'plot', 'data': fig, 'title': '층별 선호도 분석'} def _create_district_density(self) -> Dict: """시군구별 상권 밀집도""" if '시군구명' not in self.df.columns: return None district_counts = self.df['시군구명'].value_counts().head(20) fig = px.bar( x=district_counts.values, y=district_counts.index, orientation='h', title='📍 시군구별 점포 밀집도 TOP 20', labels={'x': '점포 수', 'y': '시군구'}, color=district_counts.values, color_continuous_scale='reds' ) fig.update_layout(showlegend=False, height=600) return {'type': 'plot', 'data': fig, 'title': '지역 밀집도 분석'} def _create_category_correlation(self) -> Dict: """업종 상관관계""" if '시군구명' not in self.df.columns or '상권업종중분류명' not in self.df.columns: return None top_categories = self.df['상권업종중분류명'].value_counts().head(10).index.tolist() districts = self.df['시군구명'].unique() correlation_matrix = np.zeros((len(top_categories), len(top_categories))) for i, cat1 in enumerate(top_categories): for j, cat2 in enumerate(top_categories): if i != j: coexist_count = 0 for district in districts: district_df = self.df[self.df['시군구명'] == district] has_cat1 = cat1 in district_df['상권업종중분류명'].values has_cat2 = cat2 in district_df['상권업종중분류명'].values if has_cat1 and has_cat2: coexist_count += 1 correlation_matrix[i][j] = coexist_count fig = go.Figure(data=go.Heatmap( z=correlation_matrix, x=top_categories, y=top_categories, colorscale='Blues', text=np.round(correlation_matrix, 1), texttemplate='%{text}', textfont={"size": 10} )) fig.update_layout( title='🔗 업종 상관관계 매트릭스 (같은 지역 동시 출현율)', xaxis_title='업종', yaxis_title='업종', height=600, xaxis_tickangle=-45 ) return {'type': 'plot', 'data': fig, 'title': '업종 공존 분석'} def _create_subcategory_trends(self) -> Dict: """소분류 트렌드""" if '상권업종소분류명' not in self.df.columns: return None subcat_counts = self.df['상권업종소분류명'].value_counts().head(20) fig = px.treemap( names=subcat_counts.index, parents=[''] * len(subcat_counts), values=subcat_counts.values, title='🔍 소분류 업종 트렌드 TOP 20', color=subcat_counts.values, color_continuous_scale='greens' ) fig.update_layout(height=600) return {'type': 'plot', 'data': fig, 'title': '세부 업종 분석'} def _create_regional_specialization(self) -> Dict: """지역별 특화 업종""" if '시도명' not in self.df.columns or '상권업종중분류명' not in self.df.columns: return None specialization_data = [] for region in self.df['시도명'].unique(): region_df = self.df[self.df['시도명'] == region] top_categories = region_df['상권업종중분류명'].value_counts().head(3) for category, count in top_categories.items(): specialization_data.append({ '지역': region, '특화업종': category, '점포수': count }) spec_df = pd.DataFrame(specialization_data) fig = px.sunburst( spec_df, path=['지역', '특화업종'], values='점포수', title='🎯 지역별 특화 업종 (각 지역 TOP 3)', color='점포수', color_continuous_scale='oranges' ) fig.update_layout(height=700) return {'type': 'plot', 'data': fig, 'title': '지역 특화 분석'} def create_density_map(self, sample_size: int = 1000) -> str: """점포 밀집도 지도 생성""" df_sample = self.df.sample(n=min(sample_size, len(self.df)), random_state=42) center_lat = df_sample['위도'].mean() center_lon = df_sample['경도'].mean() m = folium.Map(location=[center_lat, center_lon], zoom_start=11, tiles='OpenStreetMap') heat_data = [[row['위도'], row['경도']] for _, row in df_sample.iterrows()] HeatMap(heat_data, radius=15, blur=25, max_zoom=13).add_to(m) return m._repr_html_() def analyze_for_llm(self) -> Dict: """LLM 컨텍스트용 분석 데이터""" context = { '총_점포_수': len(self.df), '지역_수': self.df['시도명'].nunique() if '시도명' in self.df.columns else 0, '업종_수': self.df['상권업종중분류명'].nunique() if '상권업종중분류명' in self.df.columns else 0, } if '상권업종중분류명' in self.df.columns: context['상위_업종_5'] = self.df['상권업종중분류명'].value_counts().head(5).to_dict() if '층정보_숫자' in self.df.columns: first_floor_ratio = (self.df['층정보_숫자'] == 1).sum() / len(self.df) * 100 context['1층_비율'] = f"{first_floor_ratio:.1f}%" return context # ============================================================================ # LLM 쿼리 프로세서 (스트리밍 지원 + 웹검색) # ============================================================================ class LLMQueryProcessor: """Fireworks AI 기반 자연어 처리 (스트리밍 지원 + 웹검색)""" def __init__(self, api_key: str = None): self.api_key = api_key or os.getenv("FIREWORKS_API_KEY") self.base_url = "https://api.fireworks.ai/inference/v1/chat/completions" if not self.api_key: raise ValueError("❌ FIREWORKS_API_KEY 환경변수를 설정하거나 API 키를 입력해주세요!") def process_query_stream(self, query: str, data_context: Dict, chat_history: List = None, web_search_results: str = None): """자연어 쿼리 처리 (스트리밍 모드) - 웹검색 결과 포함""" web_context = "" if web_search_results and "⚠️" not in web_search_results: web_context = f""" 🌐 **최신 웹 검색 정보** {web_search_results} 위 웹 검색 결과를 참고하여 최신 정보와 트렌드를 반영해주세요. """ system_prompt = f"""당신은 한국 상권 데이터 분석 전문가입니다. 📊 **현재 분석 데이터** {json.dumps(data_context, ensure_ascii=False, indent=2)} {web_context} 구체적인 숫자와 비율로 정량적 분석을 제공하세요. 창업, 투자, 경쟁 분석 관점에서 실용적 인사이트를 제공하세요. 웹 검색 결과가 제공된 경우 최신 트렌드와 함께 분석하세요. 반드시 한국어로 답변하세요.""" messages = [{"role": "system", "content": system_prompt}] if chat_history: messages.extend(chat_history[-6:]) messages.append({"role": "user", "content": query}) payload = { "model": "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507", "max_tokens": 4800, "temperature": 0.7, "messages": messages, "stream": True } headers = { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json" } try: response = requests.post( self.base_url, headers=headers, json=payload, timeout=60, stream=True ) if response.status_code == 200: for line in response.iter_lines(): if line: line_text = line.decode('utf-8') if line_text.startswith('data: '): data_str = line_text[6:] if data_str.strip() == '[DONE]': break try: data = json.loads(data_str) if 'choices' in data and len(data['choices']) > 0: delta = data['choices'][0].get('delta', {}) content = delta.get('content', '') if content: yield content except json.JSONDecodeError: continue else: yield f"⚠️ API 오류: {response.status_code}" except requests.exceptions.Timeout: yield "⚠️ API 응답 시간 초과. 잠시 후 다시 시도해주세요." except requests.exceptions.ConnectionError: yield "⚠️ 네트워크 연결 오류. 인터넷 연결을 확인해주세요." except Exception as e: yield f"❌ 오류: {str(e)}" # ============================================================================ # 전역 상태 # ============================================================================ class AppState: def __init__(self): self.analyzer = None self.llm_processor = None self.brave_client = None self.chat_history = [] app_state = AppState() # ============================================================================ # Gradio 인터페이스 함수 # ============================================================================ def load_data(regions): """데이터 로드""" if not regions: return "❌ 최소 1개 지역을 선택해주세요!", None, None, None try: df = MarketDataLoader.load_multiple_regions(regions, sample_per_region=30000) if df.empty: return "❌ 데이터 로드 실패!", None, None, None app_state.analyzer = MarketAnalyzer(df) stats = f""" ✅ **데이터 로드 완료!** {'=' * 40} 📊 **분석 통계** • 총 점포: {len(df):,}개 • 분석 지역: {', '.join(regions)} • 업종 수: {df['상권업종중분류명'].nunique()}개 • 대분류: {df['상권업종대분류명'].nunique()}개 {'=' * 40} 💡 이제 인사이트를 확인하거나 AI에게 질문하세요! """ return stats, gr.update(visible=True), gr.update(visible=True), gr.update(visible=True) except Exception as e: return f"❌ 오류: {str(e)}", None, None, None def generate_insights(): """인사이트 생성""" if app_state.analyzer is None: return [None] * 11 insights = app_state.analyzer.get_comprehensive_insights() map_html = app_state.analyzer.create_density_map(sample_size=2000) result = [map_html] for insight in insights: if insight and insight['type'] == 'plot': result.append(insight['data']) else: result.append(None) while len(result) < 11: result.append(None) return result[:11] def chat_respond(message, history): """챗봇 응답 (스트리밍 모드 + 웹검색)""" if app_state.analyzer is None: yield history + [[message, "❌ 먼저 데이터를 로드해주세요!"]] return data_context = app_state.analyzer.analyze_for_llm() try: if app_state.llm_processor is None: app_state.llm_processor = LLMQueryProcessor() if app_state.brave_client is None: try: app_state.brave_client = BraveSearchClient() except: app_state.brave_client = None web_results = None if app_state.brave_client and app_state.brave_client.api_key: search_query = f"한국 상권 창업 트렌드 {message}" web_results = app_state.brave_client.search(search_query, count=3) chat_hist = [] for user_msg, bot_msg in history: chat_hist.append({"role": "user", "content": user_msg}) chat_hist.append({"role": "assistant", "content": bot_msg}) history = history + [[message, ""]] if web_results and "⚠️" not in web_results: history[-1][1] = "🔍 웹 검색 중...\n\n" yield history full_response = "" for chunk in app_state.llm_processor.process_query_stream(message, data_context, chat_hist, web_results): full_response += chunk history[-1][1] = full_response yield history except ValueError as e: response = f"""📊 **기본 데이터 분석 결과** **전체 현황** - 총 점포 수: {data_context['총_점포_수']:,}개 - 업종 종류: {data_context['업종_수']}개 - 1층 비율: {data_context.get('1층_비율', 'N/A')} ⚠️ **AI 분석 사용 방법** 환경변수를 설정하세요: ```bash export FIREWORKS_API_KEY="your_api_key_here" export BRAVE_API_KEY="your_brave_api_key_here" ```""" history = history + [[message, response]] yield history # ============================================================================ # 🎨 Comic Classic Theme CSS # ============================================================================ css = """ /* ===== 🎨 Google Fonts Import ===== */ @import url('https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@400;700&family=Noto+Sans+KR:wght@400;700&display=swap'); /* ===== 🎨 Comic Classic 배경 - 빈티지 페이퍼 + 도트 패턴 ===== */ .gradio-container { background-color: #FEF9C3 !important; background-image: radial-gradient(#1F2937 1px, transparent 1px) !important; background-size: 20px 20px !important; min-height: 100vh !important; font-family: 'Noto Sans KR', 'Comic Neue', cursive, sans-serif !important; } /* ===== 허깅페이스 상단 요소 숨김 ===== */ .huggingface-space-header, #space-header, .space-header, [class*="space-header"], .svelte-1ed2p3z, .space-header-badge, .header-badge, [data-testid="space-header"], .svelte-kqij2n, .svelte-1ax1toq, .embed-container > div:first-child { display: none !important; visibility: hidden !important; height: 0 !important; width: 0 !important; overflow: hidden !important; opacity: 0 !important; pointer-events: none !important; } /* ===== Footer 완전 숨김 ===== */ footer, .footer, .gradio-container footer, .built-with, [class*="footer"], .gradio-footer, .main-footer, div[class*="footer"], .show-api, .built-with-gradio, a[href*="gradio.app"], a[href*="huggingface.co/spaces"] { display: none !important; visibility: hidden !important; height: 0 !important; padding: 0 !important; margin: 0 !important; } /* ===== 메인 컨테이너 ===== */ #col-container { max-width: 1400px; margin: 0 auto; } /* ===== 🎨 헤더 타이틀 - 코믹 스타일 ===== */ .header-text h1 { font-family: 'Bangers', cursive !important; color: #1F2937 !important; font-size: 3.2rem !important; font-weight: 400 !important; text-align: center !important; margin-bottom: 0.5rem !important; text-shadow: 4px 4px 0px #FACC15, 6px 6px 0px #1F2937 !important; letter-spacing: 3px !important; -webkit-text-stroke: 2px #1F2937 !important; } /* ===== 🎨 서브타이틀 ===== */ .subtitle { text-align: center !important; font-family: 'Noto Sans KR', 'Comic Neue', cursive !important; font-size: 1.1rem !important; color: #1F2937 !important; margin-bottom: 1.5rem !important; font-weight: 700 !important; } /* ===== 🎨 카드/패널 - 만화 프레임 스타일 ===== */ .gr-panel, .gr-box, .gr-form, .block, .gr-group { background: #FFFFFF !important; border: 3px solid #1F2937 !important; border-radius: 8px !important; box-shadow: 6px 6px 0px #1F2937 !important; transition: all 0.2s ease !important; } .gr-panel:hover, .block:hover { transform: translate(-2px, -2px) !important; box-shadow: 8px 8px 0px #1F2937 !important; } /* ===== 🎨 입력 필드 (Textbox) ===== */ textarea, input[type="text"], input[type="number"] { background: #FFFFFF !important; border: 3px solid #1F2937 !important; border-radius: 8px !important; color: #1F2937 !important; font-family: 'Noto Sans KR', 'Comic Neue', cursive !important; font-size: 1rem !important; font-weight: 700 !important; transition: all 0.2s ease !important; } textarea:focus, input[type="text"]:focus, input[type="number"]:focus { border-color: #3B82F6 !important; box-shadow: 4px 4px 0px #3B82F6 !important; outline: none !important; } textarea::placeholder { color: #9CA3AF !important; font-weight: 400 !important; } /* ===== 🎨 Primary 버튼 - 코믹 블루 ===== */ .gr-button-primary, button.primary, .gr-button.primary { background: #3B82F6 !important; border: 3px solid #1F2937 !important; border-radius: 8px !important; color: #FFFFFF !important; font-family: 'Noto Sans KR', 'Bangers', cursive !important; font-weight: 700 !important; font-size: 1.2rem !important; letter-spacing: 1px !important; padding: 14px 28px !important; box-shadow: 5px 5px 0px #1F2937 !important; transition: all 0.1s ease !important; text-shadow: 1px 1px 0px #1F2937 !important; } .gr-button-primary:hover, button.primary:hover, .gr-button.primary:hover { background: #2563EB !important; transform: translate(-2px, -2px) !important; box-shadow: 7px 7px 0px #1F2937 !important; } .gr-button-primary:active, button.primary:active, .gr-button.primary:active { transform: translate(3px, 3px) !important; box-shadow: 2px 2px 0px #1F2937 !important; } /* ===== 🎨 Secondary 버튼 - 코믹 레드 ===== */ .gr-button-secondary, button.secondary { background: #EF4444 !important; border: 3px solid #1F2937 !important; border-radius: 8px !important; color: #FFFFFF !important; font-family: 'Noto Sans KR', 'Bangers', cursive !important; font-weight: 700 !important; font-size: 1rem !important; letter-spacing: 1px !important; box-shadow: 4px 4px 0px #1F2937 !important; transition: all 0.1s ease !important; text-shadow: 1px 1px 0px #1F2937 !important; } .gr-button-secondary:hover, button.secondary:hover { background: #DC2626 !important; transform: translate(-2px, -2px) !important; box-shadow: 6px 6px 0px #1F2937 !important; } /* ===== 🎨 Small 버튼 ===== */ button.sm, .gr-button-sm { background: #10B981 !important; border: 2px solid #1F2937 !important; border-radius: 6px !important; color: #FFFFFF !important; font-family: 'Noto Sans KR', cursive !important; font-weight: 700 !important; font-size: 0.9rem !important; padding: 8px 16px !important; box-shadow: 3px 3px 0px #1F2937 !important; transition: all 0.1s ease !important; } button.sm:hover, .gr-button-sm:hover { background: #059669 !important; transform: translate(-1px, -1px) !important; box-shadow: 4px 4px 0px #1F2937 !important; } /* ===== 🎨 로그 출력 영역 ===== */ .info-log textarea { background: #1F2937 !important; color: #10B981 !important; font-family: 'Courier New', monospace !important; font-size: 0.9rem !important; font-weight: 400 !important; border: 3px solid #10B981 !important; border-radius: 8px !important; box-shadow: 4px 4px 0px #10B981 !important; } /* ===== 🎨 아코디언 - 말풍선 스타일 ===== */ .gr-accordion { background: #FACC15 !important; border: 3px solid #1F2937 !important; border-radius: 8px !important; box-shadow: 4px 4px 0px #1F2937 !important; } .gr-accordion-header { color: #1F2937 !important; font-family: 'Noto Sans KR', 'Comic Neue', cursive !important; font-weight: 700 !important; font-size: 1.1rem !important; } /* ===== 🎨 체크박스 그룹 ===== */ .gr-checkbox-group { background: #FFFFFF !important; border: 3px solid #1F2937 !important; border-radius: 8px !important; padding: 10px !important; } input[type="checkbox"] { accent-color: #3B82F6 !important; width: 18px !important; height: 18px !important; } /* ===== 🎨 탭 스타일 ===== */ .gr-tab-nav { background: #FACC15 !important; border: 3px solid #1F2937 !important; border-radius: 8px 8px 0 0 !important; box-shadow: 4px 4px 0px #1F2937 !important; } .gr-tab-nav button { font-family: 'Noto Sans KR', 'Comic Neue', cursive !important; font-weight: 700 !important; color: #1F2937 !important; border: none !important; padding: 12px 20px !important; } .gr-tab-nav button.selected { background: #3B82F6 !important; color: #FFFFFF !important; border-radius: 6px 6px 0 0 !important; } /* ===== 🎨 챗봇 스타일 ===== */ .gr-chatbot { background: #FFFFFF !important; border: 3px solid #1F2937 !important; border-radius: 8px !important; box-shadow: 6px 6px 0px #1F2937 !important; } .gr-chatbot .message { font-family: 'Noto Sans KR', sans-serif !important; } /* ===== 🎨 라벨 스타일 ===== */ label, .gr-input-label, .gr-block-label { color: #1F2937 !important; font-family: 'Noto Sans KR', 'Comic Neue', cursive !important; font-weight: 700 !important; font-size: 1rem !important; } /* ===== 🎨 Markdown 스타일 ===== */ .gr-markdown { font-family: 'Noto Sans KR', 'Comic Neue', cursive !important; color: #1F2937 !important; } .gr-markdown h1, .gr-markdown h2, .gr-markdown h3 { font-family: 'Bangers', 'Noto Sans KR', cursive !important; color: #1F2937 !important; text-shadow: 2px 2px 0px #FACC15 !important; } /* ===== 🎨 Plot 영역 ===== */ .gr-plot { border: 3px solid #1F2937 !important; border-radius: 8px !important; box-shadow: 4px 4px 0px #1F2937 !important; background: #FFFFFF !important; } /* ===== 🎨 HTML 영역 (지도) ===== */ .gr-html { border: 4px solid #1F2937 !important; border-radius: 8px !important; box-shadow: 6px 6px 0px #FACC15 !important; overflow: hidden !important; } /* ===== 🎨 스크롤바 - 코믹 스타일 ===== */ ::-webkit-scrollbar { width: 12px; height: 12px; } ::-webkit-scrollbar-track { background: #FEF9C3; border: 2px solid #1F2937; } ::-webkit-scrollbar-thumb { background: #3B82F6; border: 2px solid #1F2937; border-radius: 0px; } ::-webkit-scrollbar-thumb:hover { background: #EF4444; } /* ===== 🎨 선택 하이라이트 ===== */ ::selection { background: #FACC15; color: #1F2937; } /* ===== 🎨 링크 스타일 ===== */ a { color: #3B82F6 !important; text-decoration: none !important; font-weight: 700 !important; } a:hover { color: #EF4444 !important; } /* ===== 🎨 Row/Column 간격 ===== */ .gr-row { gap: 1.5rem !important; } .gr-column { gap: 1rem !important; } /* ===== 🎨 Badge 스타일 ===== */ .badge-container { display: flex; justify-content: center; gap: 15px; flex-wrap: wrap; margin: 20px 0; } .comic-badge { display: inline-flex; align-items: center; gap: 8px; padding: 12px 24px; border: 3px solid #1F2937; border-radius: 8px; text-decoration: none; font-weight: 700; font-size: 1em; transition: all 0.2s ease; box-shadow: 4px 4px 0px #1F2937; font-family: 'Noto Sans KR', sans-serif; } .comic-badge:hover { transform: translate(-2px, -2px); box-shadow: 6px 6px 0px #1F2937; } .comic-badge-yellow { background: #FACC15; color: #1F2937; } .comic-badge-blue { background: #3B82F6; color: #FFFFFF; } .comic-badge-green { background: #10B981; color: #FFFFFF; } /* ===== 반응형 조정 ===== */ @media (max-width: 768px) { .header-text h1 { font-size: 2rem !important; text-shadow: 3px 3px 0px #FACC15, 4px 4px 0px #1F2937 !important; } .gr-button-primary, button.primary { padding: 12px 20px !important; font-size: 1rem !important; } .gr-panel, .block { box-shadow: 4px 4px 0px #1F2937 !important; } } /* ===== 🎨 다크모드 비활성화 ===== */ @media (prefers-color-scheme: dark) { .gradio-container { background-color: #FEF9C3 !important; } } """ # ============================================================================ # Gradio UI # ============================================================================ with gr.Blocks(title="AI 상권 분석 시스템", css=css) as demo: # HOME Badge gr.HTML("""
HOME
""") # Header Title gr.Markdown( """ # 🏪 AI 상권 분석 시스템 PRO 📊 """, elem_classes="header-text" ) gr.Markdown( """

⚡ 전국 상가(상권) 데이터 실시간 분석 | 스트리밍 + 웹검색 🔍 | 10가지 심층 인사이트 🚀

""", ) # 배지 gr.HTML("""
💬 오픈채팅 바로가기 🍌 나노 바나나 애드온 무료 서비스
""") # API 상태 api_status = "✅ 설정됨" if os.getenv("FIREWORKS_API_KEY") else "⚠️ 미설정" brave_status = "✅ 활성화" if os.getenv("BRAVE_API_KEY") else "⚠️ 비활성화" with gr.Row(equal_height=False): # 왼쪽 컬럼 - 설정 with gr.Column(scale=1, min_width=300): gr.Markdown("### ⚙️ 분석 설정") gr.Markdown(f""" **🔑 API 상태** - Fireworks AI: {api_status} - Brave Search: {brave_status} """) region_select = gr.CheckboxGroup( choices=list(MarketDataLoader.REGIONS.keys()), value=['서울'], label="📍 분석 지역 선택 (최대 5개 권장)" ) load_btn = gr.Button( "📊 데이터 로드하기!", variant="primary", size="lg" ) with gr.Accordion("📜 로드 상태", open=True): status_box = gr.Markdown( "👈 지역을 선택하고 데이터를 로드하세요!", elem_classes="info-log" ) # 오른쪽 컬럼 - 메인 콘텐츠 with gr.Column(scale=3, min_width=600): with gr.Tabs() as tabs: # 탭 1: 인사이트 대시보드 with gr.Tab("📊 인사이트 대시보드", id=0) as tab1: insights_content = gr.Column(visible=False) with insights_content: gr.Markdown("### 🗺️ 점포 밀집도 히트맵") map_output = gr.HTML() gr.Markdown("---") gr.Markdown("### 📈 10가지 심층 상권 인사이트") with gr.Row(): chart1 = gr.Plot(label="🏆 업종별 점포 수") chart2 = gr.Plot(label="📊 대분류 분포") with gr.Row(): chart3 = gr.Plot(label="🏢 층별 분포") chart4 = gr.Plot(label="🎨 업종 다양성") with gr.Row(): chart5 = gr.Plot(label="🏪 프랜차이즈 분석") chart6 = gr.Plot(label="📍 층 선호도") with gr.Row(): chart7 = gr.Plot(label="🔥 지역 밀집도") chart8 = gr.Plot(label="🔗 업종 상관관계") with gr.Row(): chart9 = gr.Plot(label="🔍 소분류 트렌드") chart10 = gr.Plot(label="🎯 지역 특화") # 탭 2: AI 챗봇 with gr.Tab("🤖 AI 분석 챗봇 ⚡🔍", id=1) as tab2: chat_content = gr.Column(visible=False) with chat_content: gr.Markdown(""" ### 💡 예시 질문 강남에서 카페 창업? | 치킨집 포화 지역? | 1층이 유리한 업종? | 프랜차이즈 점유율? ⚡ **스트리밍**: AI 응답이 실시간으로 표시됩니다! 🔍 **웹검색**: 최신 상권 트렌드를 자동 반영합니다! """) chatbot = gr.Chatbot( height=450, label="AI 상권 분석 어시스턴트" ) with gr.Row(): msg_input = gr.Textbox( placeholder="무엇이든 물어보세요! (예: 강남에서 카페 창업하려면?)", show_label=False, scale=4 ) submit_btn = gr.Button("🚀 전송", variant="primary", scale=1) with gr.Row(): sample_btn1 = gr.Button("☕ 강남 카페 창업?", size="sm") sample_btn2 = gr.Button("🍗 치킨집 포화 지역?", size="sm") sample_btn3 = gr.Button("🏢 1층 유리한 업종?", size="sm") sample_btn4 = gr.Button("🏪 프랜차이즈 점유율?", size="sm") # 사용 가이드 gr.Markdown(""" --- ### 📖 사용 가이드 1️⃣ 지역 선택 → 2️⃣ 데이터 로드 → 3️⃣ 10가지 인사이트 확인 또는 AI에게 질문! ### 📊 제공되는 10가지 분석 | 분석 항목 | 설명 | |----------|------| | 🏆 업종별 점포 수 | 가장 많은 업종 TOP 15 | | 📊 대분류 분포 | 소매/음식/서비스 등 비율 | | 🏢 층별 분포 | 지하/1층/상층 입지 분석 | | 🎨 업종 다양성 | 지역별 업종 다양성 지수 | | 🏪 프랜차이즈 분석 | 개인 vs 프랜차이즈 비율 | | 📍 층 선호도 | 업종별 선호 층수 | | 🔥 지역 밀집도 | 점포 수 상위 지역 | | 🔗 업종 상관관계 | 같이 나타나는 업종 패턴 | | 🔍 소분류 트렌드 | 세부 업종 분포 | | 🎯 지역 특화 | 각 지역의 특화 업종 | 💡 **Tip**: API 키 없이도 10가지 시각화 분석과 기본 통계를 확인할 수 있습니다! """) # 이벤트 핸들러 load_btn.click( fn=load_data, inputs=[region_select], outputs=[status_box, insights_content, chat_content, tab1] ).then( fn=generate_insights, outputs=[map_output, chart1, chart2, chart3, chart4, chart5, chart6, chart7, chart8, chart9, chart10] ) # 챗봇 이벤트 submit_btn.click( fn=chat_respond, inputs=[msg_input, chatbot], outputs=[chatbot] ).then( fn=lambda: "", outputs=[msg_input] ) msg_input.submit( fn=chat_respond, inputs=[msg_input, chatbot], outputs=[chatbot] ).then( fn=lambda: "", outputs=[msg_input] ) # 샘플 버튼 이벤트 def create_sample_click(text): def handler(history): for result in chat_respond(text, history or []): yield result return handler sample_btn1.click(fn=create_sample_click("강남에서 카페 창업하려면 어떻게 해야 하나요?"), inputs=[chatbot], outputs=[chatbot]) sample_btn2.click(fn=create_sample_click("치킨집이 가장 포화된 지역은 어디인가요?"), inputs=[chatbot], outputs=[chatbot]) sample_btn3.click(fn=create_sample_click("1층이 유리한 업종은 무엇인가요?"), inputs=[chatbot], outputs=[chatbot]) sample_btn4.click(fn=create_sample_click("프랜차이즈 점유율이 높은 업종은?"), inputs=[chatbot], outputs=[chatbot]) # 실행 if __name__ == "__main__": demo.launch(server_name="0.0.0.0", server_port=7860, share=False)