Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| import os | |
| import sys | |
| import csv | |
| import json | |
| import sqlite3 | |
| import hashlib | |
| import requests | |
| import time | |
| import random | |
| import re | |
| import uuid | |
| from datetime import datetime | |
| from typing import List, Dict, Optional, Tuple, Any, Generator | |
| from enum import Enum | |
| import numpy as np | |
| # Optional imports | |
| try: | |
| import replicate | |
| HAS_REPLICATE = True | |
| except ImportError: | |
| HAS_REPLICATE = False | |
| print("⚠️ replicate 패키지 없음 - 이미지 생성 비활성화") | |
| try: | |
| import gradio as gr | |
| HAS_GRADIO = True | |
| except ImportError: | |
| HAS_GRADIO = False | |
| print("⚠️ gradio 패키지 없음 - CLI 모드로 실행") | |
| # ============================================================================ | |
| # JSON 안전 파싱 유틸리티 (v3.2 강화!) | |
| # ============================================================================ | |
| def parse_json_safely(response: str, default: Dict) -> Dict: | |
| """안전한 JSON 파싱 - 여러 방법 시도 (v3.2 강화 버전)""" | |
| if not response: | |
| return default | |
| response = response.strip() | |
| # 방법 1: 직접 파싱 | |
| try: | |
| result = json.loads(response) | |
| if isinstance(result, dict) and result: | |
| return result | |
| except: | |
| pass | |
| # 방법 2: 코드 블록 제거 후 파싱 (```json ... ``` 처리) | |
| try: | |
| if "```" in response: | |
| code_block_pattern = r'```(?:json)?\s*([\s\S]*?)```' | |
| matches = re.findall(code_block_pattern, response) | |
| for match in matches: | |
| match = match.strip() | |
| if match.startswith("{"): | |
| try: | |
| result = json.loads(match) | |
| if isinstance(result, dict) and result: | |
| return result | |
| except: | |
| continue | |
| except: | |
| pass | |
| # 방법 3: 중첩 브라켓 깊이 추적으로 정확한 JSON 추출 (v3.2 NEW!) | |
| try: | |
| depth = 0 | |
| start_idx = -1 | |
| for i, char in enumerate(response): | |
| if char == '{': | |
| if depth == 0: | |
| start_idx = i | |
| depth += 1 | |
| elif char == '}': | |
| depth -= 1 | |
| if depth == 0 and start_idx >= 0: | |
| json_str = response[start_idx:i+1] | |
| result = json.loads(json_str) | |
| if isinstance(result, dict) and result: | |
| return result | |
| except: | |
| pass | |
| # 방법 4: { } 블록 단순 추출 (정리 후) | |
| try: | |
| cleaned = re.sub(r'[\n\r\t]+', ' ', response) | |
| cleaned = re.sub(r'\s+', ' ', cleaned) | |
| start = cleaned.find("{") | |
| end = cleaned.rfind("}") + 1 | |
| if start >= 0 and end > start: | |
| json_str = cleaned[start:end] | |
| result = json.loads(json_str) | |
| if isinstance(result, dict) and result: | |
| return result | |
| except: | |
| pass | |
| # 방법 5: 흔한 JSON 오류 수정 후 재시도 (v3.2 NEW!) | |
| try: | |
| fixed = response.replace("'", '"') # 작은따옴표 → 큰따옴표 | |
| fixed = re.sub(r',\s*}', '}', fixed) # trailing comma 제거 | |
| fixed = re.sub(r',\s*]', ']', fixed) # trailing comma 제거 | |
| start = fixed.find("{") | |
| end = fixed.rfind("}") + 1 | |
| if start >= 0 and end > start: | |
| result = json.loads(fixed[start:end]) | |
| if isinstance(result, dict) and result: | |
| return result | |
| except: | |
| pass | |
| return default | |
| # ============================================================================ | |
| # 랜덤 유틸리티 (v3.2 NEW!) | |
| # ============================================================================ | |
| def get_random_seed() -> int: | |
| """매번 다른 랜덤 시드 생성 - 타임스탬프 + 랜덤 오프셋""" | |
| return int(time.time() * 1000) % (2**31) + random.randint(0, 10000) | |
| def shuffle_and_pick(items: List, count: int = 1) -> List: | |
| """리스트를 셔플하고 count개 선택""" | |
| if not items: | |
| return [] | |
| items_copy = items.copy() | |
| random.shuffle(items_copy) | |
| return items_copy[:count] | |
| def random_choice_weighted(items: List, weights: List[float] = None) -> Any: | |
| """가중치 기반 랜덤 선택""" | |
| if not items: | |
| return None | |
| if weights and len(weights) == len(items): | |
| total = sum(weights) | |
| r = random.uniform(0, total) | |
| cumulative = 0 | |
| for item, weight in zip(items, weights): | |
| cumulative += weight | |
| if r <= cumulative: | |
| return item | |
| return random.choice(items) | |
| # ============================================================================ | |
| # Brave Search API 클라이언트 | |
| # ============================================================================ | |
| class BraveSearchClient: | |
| """Brave Search API 클라이언트 - 재료/풍미 검색용""" | |
| def __init__(self, api_key: str = None): | |
| self.api_key = api_key or os.environ.get("BRAVE_API_KEY", "") | |
| self.base_url = "https://api.search.brave.com/res/v1/web/search" | |
| def search(self, query: str, count: int = 10) -> List[Dict]: | |
| """Brave Search API로 검색""" | |
| if not self.api_key: | |
| return [] | |
| headers = { | |
| "Accept": "application/json", | |
| "Accept-Encoding": "gzip", | |
| "X-Subscription-Token": self.api_key | |
| } | |
| params = { | |
| "q": query, | |
| "count": count, | |
| "search_lang": "ko", | |
| "country": "kr" | |
| } | |
| try: | |
| response = requests.get(self.base_url, headers=headers, params=params, timeout=10) | |
| response.raise_for_status() | |
| data = response.json() | |
| results = [] | |
| for item in data.get("web", {}).get("results", []): | |
| results.append({ | |
| "title": item.get("title", ""), | |
| "url": item.get("url", ""), | |
| "description": item.get("description", ""), | |
| "snippet": item.get("extra_snippets", []) | |
| }) | |
| return results | |
| except Exception as e: | |
| print(f"⚠️ Brave Search 오류: {e}") | |
| return [] | |
| # ============================================================================ | |
| # 창발적 레시피 엔진 - 6대 차원 데이터 로더 (v3.2 확장!) | |
| # ============================================================================ | |
| class CulinaryDataLoader: | |
| """6대 차원 요리 데이터 로더 - 창발적 레시피 생성의 핵심 (v3.2 확장)""" | |
| def __init__(self, json_path: str = "culinary_data.json"): | |
| self.data = self._load_data(json_path) | |
| def _load_data(self, json_path: str) -> Dict: | |
| possible_paths = [ | |
| json_path, | |
| os.path.join(os.path.dirname(__file__), json_path), | |
| os.path.join(os.path.dirname(__file__), "data", json_path), | |
| os.path.join(os.getcwd(), json_path), | |
| ] | |
| for path in possible_paths: | |
| if os.path.exists(path): | |
| with open(path, 'r', encoding='utf-8') as f: | |
| print(f"✅ 창발 엔진 데이터 로드: {path}") | |
| return json.load(f) | |
| print("⚠️ culinary_data.json 없음 - 내장 폴백 데이터 사용") | |
| return self._get_fallback_data() | |
| def _get_fallback_data(self) -> Dict: | |
| """내장 폴백 데이터 (v3.2 확장!)""" | |
| return { | |
| "D1_Ingredients": { | |
| "proteins": {"red_meat": [{"id": "hanwoo_beef", "name": "한우", "name_en": "Korean Hanwoo", "substitutes": ["wagyu_beef"]}]}, | |
| "seasonings": {"fermented_korean": [ | |
| {"id": "gochujang", "name": "고추장", "name_en": "Gochujang", "substitutes": ["sriracha_miso"]}, | |
| {"id": "doenjang", "name": "된장", "name_en": "Doenjang", "substitutes": ["miso"]}, | |
| {"id": "ganjang", "name": "간장", "name_en": "Soy Sauce", "substitutes": ["tamari"]} | |
| ]} | |
| }, | |
| "D2_FlavorsAromas": { | |
| "profiles": [ | |
| {"id": "FP_001", "ingredient": "김치", "compounds": ["Lactic Acid", "Allyl Sulfides"], | |
| "emergent_pairings": ["Cheddar Cheese", "Taco", "Pizza", "Mac and Cheese", "Grilled Cheese", "Quesadilla", "Ramen", "Risotto", "Burger", "Hot Dog"]}, | |
| {"id": "FP_002", "ingredient": "고추장", "compounds": ["Capsaicin", "Maltose"], | |
| "emergent_pairings": ["Chocolate", "Honey", "Vanilla Ice Cream", "Caramel", "Bacon", "Maple Syrup", "Peanut Butter", "Mango", "Coconut"]}, | |
| {"id": "FP_003", "ingredient": "된장", "compounds": ["Glutamates", "Pyrazines"], | |
| "emergent_pairings": ["Chocolate", "Caramel", "Butter", "Coffee", "Walnut", "Apple", "Pear", "Brie Cheese", "Truffle"]}, | |
| {"id": "FP_004", "ingredient": "참기름", "compounds": ["Sesamol"], | |
| "emergent_pairings": ["Chocolate", "Honey", "Strawberry", "Vanilla", "Citrus", "Tahini", "Halva"]}, | |
| {"id": "FP_005", "ingredient": "간장", "compounds": ["Glutamates"], | |
| "emergent_pairings": ["Chocolate", "Caramel", "Vanilla Ice Cream", "Coffee", "Butterscotch", "Toffee"]}, | |
| {"id": "FP_006", "ingredient": "청국장", "compounds": ["Natto Kinase", "Glutamates"], | |
| "emergent_pairings": ["Parmesan", "Miso", "Blue Cheese", "Truffle", "Anchovy"]}, | |
| {"id": "FP_007", "ingredient": "막걸리", "compounds": ["Lactic Acid", "Ethanol"], | |
| "emergent_pairings": ["Cream", "Peach", "Ginger", "Honey", "Rice Pudding"]}, | |
| {"id": "FP_008", "ingredient": "고춧가루", "compounds": ["Capsaicin", "Carotenoids"], | |
| "emergent_pairings": ["Mango", "Lime", "Pineapple", "Coconut", "Dark Chocolate", "Orange"]} | |
| ], | |
| "compound_sharing_map": { | |
| "pyrazines": ["Coffee", "Chocolate", "Roasted Meat", "Doenjang"], | |
| "glutamates": ["Parmesan", "Tomato", "Soy Sauce", "Miso", "Mushroom"], | |
| "linalool": ["Lavender", "Basil", "Coriander", "Citrus"], | |
| "limonene": ["Lemon", "Orange", "Lime", "Grapefruit", "Dill"] | |
| } | |
| }, | |
| "D3_CookingMethods": { | |
| "heat_based": { | |
| "dry_heat": [ | |
| {"id": "CM_DH_001", "name": "Roasting", "name_ko": "로스팅/구이", "temp_range": "150-230°C", "time_range": "20분-4시간"}, | |
| {"id": "CM_DH_002", "name": "Grilling", "name_ko": "그릴링/직화구이", "temp_range": "200-300°C", "time_range": "2-30분"}, | |
| {"id": "CM_DH_003", "name": "Searing", "name_ko": "시어링", "temp_range": "230-290°C", "time_range": "30초-3분"}, | |
| {"id": "CM_DH_004", "name": "Smoking", "name_ko": "훈연", "temp_range": "70-120°C", "time_range": "2-12시간"}, | |
| {"id": "CM_DH_005", "name": "Baking", "name_ko": "베이킹", "temp_range": "150-220°C", "time_range": "15분-2시간"} | |
| ], | |
| "moist_heat": [ | |
| {"id": "CM_MH_001", "name": "Braising", "name_ko": "브레이징/조림", "temp_range": "150-175°C", "time_range": "2-6시간"}, | |
| {"id": "CM_MH_002", "name": "Sous_Vide", "name_ko": "수비드", "temp_range": "52-85°C", "time_range": "30분-72시간"}, | |
| {"id": "CM_MH_003", "name": "Steaming", "name_ko": "찌기", "temp_range": "100°C", "time_range": "10-60분"}, | |
| {"id": "CM_MH_004", "name": "Poaching", "name_ko": "포칭", "temp_range": "65-85°C", "time_range": "5-30분"}, | |
| {"id": "CM_MH_005", "name": "Blanching", "name_ko": "데치기", "temp_range": "100°C", "time_range": "30초-5분"} | |
| ], | |
| "fat_based": [ | |
| {"id": "CM_FB_001", "name": "Confit", "name_ko": "콩피", "temp_range": "80-100°C", "time_range": "2-12시간"}, | |
| {"id": "CM_FB_002", "name": "Deep_Frying", "name_ko": "튀김", "temp_range": "175-190°C", "time_range": "2-10분"}, | |
| {"id": "CM_FB_003", "name": "Pan_Frying", "name_ko": "팬프라잉", "temp_range": "150-180°C", "time_range": "3-15분"}, | |
| {"id": "CM_FB_004", "name": "Stir_Frying", "name_ko": "볶음", "temp_range": "200-250°C", "time_range": "2-5분"} | |
| ] | |
| } | |
| }, | |
| "D4_Textures": { | |
| "primary_textures": [ | |
| {"id": "TX_001", "name": "Crispy", "name_ko": "바삭함", "contrast_pairing": ["Creamy", "Soft", "Silky"], "achieved_by": ["Deep Frying", "High-Heat Roasting", "Dehydrating"]}, | |
| {"id": "TX_002", "name": "Creamy", "name_ko": "크리미함", "contrast_pairing": ["Crispy", "Crunchy"], "achieved_by": ["Emulsifying", "Pureeing", "Whipping"]}, | |
| {"id": "TX_003", "name": "Chewy", "name_ko": "쫄깃함", "contrast_pairing": ["Crispy", "Tender"], "achieved_by": ["Gluten development", "Long cooking"]}, | |
| {"id": "TX_004", "name": "Tender", "name_ko": "부드러움", "contrast_pairing": ["Chewy", "Crunchy"], "achieved_by": ["Low-slow cooking", "Braising", "Sous vide"]}, | |
| {"id": "TX_005", "name": "Crunchy", "name_ko": "아삭함", "contrast_pairing": ["Soft", "Creamy"], "achieved_by": ["Raw vegetables", "Light frying", "Pickling"]}, | |
| {"id": "TX_006", "name": "Silky", "name_ko": "실키함", "contrast_pairing": ["Crispy", "Crunchy"], "achieved_by": ["Fine straining", "Slow reduction"]}, | |
| {"id": "TX_007", "name": "Fluffy", "name_ko": "폭신함", "contrast_pairing": ["Dense", "Crispy"], "achieved_by": ["Whipping", "Leavening", "Folding"]} | |
| ], | |
| "texture_contrasts": [ | |
| {"combination": ["Crispy", "Creamy"], "effect": "가장 만족스러운 대비", "examples": ["크림 크로켓", "아이스크림 튀김"]}, | |
| {"combination": ["Tender", "Crispy"], "effect": "겉바속촉", "examples": ["치킨", "돈까스"]}, | |
| {"combination": ["Chewy", "Crunchy"], "effect": "씹는 재미", "examples": ["떡볶이+튀김", "모찌+너츠"]}, | |
| {"combination": ["Silky", "Crispy"], "effect": "우아한 대비", "examples": ["감자퓨레+튀긴파슬리"]} | |
| ] | |
| }, | |
| "D5_Architecture": { | |
| "structures": [ | |
| {"id": "A_001", "name": "Layered", "name_ko": "적층형", "twist_logic": ["Deconstruct", "Roll into cylinder", "Stack vertically", "Fan out"], "korean_examples": ["비빔밥", "불고기 쌈"]}, | |
| {"id": "A_002", "name": "Wrapped", "name_ko": "포용형/싸기", "twist_logic": ["Invert", "Fry entire", "Double wrap", "Partial reveal"], "korean_examples": ["김밥", "만두", "쌈"]}, | |
| {"id": "A_003", "name": "Mixed", "name_ko": "혼합형", "twist_logic": ["Separate and reconstruct", "Sphere-ify", "Gradient mixing"], "korean_examples": ["비빔밥", "볶음밥"]}, | |
| {"id": "A_004", "name": "Coated", "name_ko": "코팅형", "twist_logic": ["Double coat", "Make core liquid", "Transparent coating"], "korean_examples": ["치킨", "돈까스", "탕수육"]}, | |
| {"id": "A_005", "name": "Stuffed", "name_ko": "채움형", "twist_logic": ["Reverse stuffing order", "Multiple layers", "Surprise center"], "korean_examples": ["두부선", "오이선"]}, | |
| {"id": "A_006", "name": "Skewered", "name_ko": "꼬치형", "twist_logic": ["Alternating ingredients", "Gradient cooking", "Dipping presentation"], "korean_examples": ["산적", "떡꼬치"]}, | |
| {"id": "A_007", "name": "Soup-based", "name_ko": "국물형", "twist_logic": ["Concentrated essence", "Layered broth", "Solid-liquid contrast"], "korean_examples": ["설렁탕", "된장찌개"]} | |
| ] | |
| }, | |
| "D6_CulinaryGrammar": { | |
| "korean": [ | |
| {"id": "CG_KOR_001", "name": "기본 갖은양념", "base_aromatics": ["마늘", "대파", "생강"], "core_liquids": ["간장", "참기름"]}, | |
| {"id": "CG_KOR_002", "name": "고추장 베이스", "base": ["고추장"], "enhancers": ["간장", "참기름", "설탕"], "applications": ["비빔밥", "떡볶이"]}, | |
| {"id": "CG_KOR_003", "name": "된장 베이스", "base": ["된장"], "enhancers": ["멸치육수", "참기름"], "applications": ["된장찌개", "쌈장"]}, | |
| {"id": "CG_KOR_004", "name": "간장 베이스", "base": ["간장", "물엿"], "enhancers": ["마늘", "생강"], "applications": ["갈비찜", "장조림"]} | |
| ], | |
| "japanese": [ | |
| {"id": "CG_JPN_001", "name": "다시 베이스", "base": ["다시마", "가쓰오부시"], "applications": ["미소시루", "라멘"]}, | |
| {"id": "CG_JPN_002", "name": "데리야키", "base": ["간장", "미림", "사케"], "applications": ["닭고기", "연어"]}, | |
| {"id": "CG_JPN_003", "name": "폰즈", "base": ["간장", "유자", "식초"], "applications": ["샤브샤브", "회"]} | |
| ], | |
| "french": [ | |
| {"id": "CG_FRA_001", "name": "미르푸아", "base_aromatics": ["양파", "당근", "셀러리"], "core_fat": ["버터"]}, | |
| {"id": "CG_FRA_002", "name": "브뢴 버터", "base": ["버터"], "applications": ["생선", "채소"]}, | |
| {"id": "CG_FRA_003", "name": "부케 가르니", "base": ["타임", "월계수잎", "파슬리"], "applications": ["스톡", "스튜"]} | |
| ], | |
| "italian": [ | |
| {"id": "CG_ITA_001", "name": "소프리토", "base_aromatics": ["양파", "당근", "셀러리"], "core_fat": ["올리브오일"]}, | |
| {"id": "CG_ITA_002", "name": "아글리오 올리오", "base": ["마늘", "올리브오일", "페페론치노"], "applications": ["파스타"]}, | |
| {"id": "CG_ITA_003", "name": "피카타", "base": ["레몬", "버터", "케이퍼"], "applications": ["치킨", "생선"]} | |
| ], | |
| "indian": [ | |
| {"id": "CG_IND_001", "name": "타르카/템퍼링", "base_fat": ["기"], "spices": ["커민씨", "머스타드씨"]}, | |
| {"id": "CG_IND_002", "name": "마살라 베이스", "spices": ["강황", "커민", "코리앤더"], "applications": ["커리"]} | |
| ], | |
| "chinese": [ | |
| {"id": "CG_CHN_001", "name": "향신채 베이스", "base_aromatics": ["대파", "생강", "마늘"]}, | |
| {"id": "CG_CHN_002", "name": "마라 베이스", "base": ["고추", "화자오", "두반장"], "applications": ["마라탕", "훠궈"]} | |
| ], | |
| "thai": [ | |
| {"id": "CG_THA_001", "name": "커리 페이스트", "base": ["레몬그라스", "갈랑갈", "고추"]}, | |
| {"id": "CG_THA_002", "name": "태국 드레싱", "base": ["라임", "피시소스", "팜슈가"], "applications": ["쏨땀", "얌운센"]} | |
| ], | |
| "mexican": [ | |
| {"id": "CG_MEX_001", "name": "소프리토 멕시칸", "base": ["양파", "마늘", "토마토", "할라페뇨"]}, | |
| {"id": "CG_MEX_002", "name": "몰레", "base": ["칠리", "초콜릿", "향신료"], "applications": ["치킨 몰레"]} | |
| ] | |
| }, | |
| "emergence_rules": { | |
| "variable_crossing": {"description": "한 조리법의 변수를 다른 조리법에 적용"}, | |
| "context_shift": {"description": "재료의 풍미 프로파일을 다른 문화권 요리에 이식"}, | |
| "architecture_inversion": {"description": "요리 구조를 뒤집거나 재배치"}, | |
| "grammar_transplantation": {"description": "한 문화의 양념 문법을 다른 요리에 이식"}, | |
| "texture_contrast_optimization": {"description": "대비되는 텍스처를 의도적으로 조합"}, | |
| "flavor_compound_pairing": {"description": "공유 화합물 기반 예상치 못한 페어링"} | |
| } | |
| } | |
| def get_ingredient(self, ingredient_id: str) -> Optional[Dict]: | |
| d1 = self.data.get("D1_Ingredients", {}) | |
| for category in d1.values(): | |
| if isinstance(category, dict): | |
| for subcategory in category.values(): | |
| if isinstance(subcategory, list): | |
| for item in subcategory: | |
| if item.get("id") == ingredient_id: | |
| return item | |
| return None | |
| def search_ingredients(self, query: str) -> List[Dict]: | |
| results = [] | |
| query_lower = query.lower() | |
| d1 = self.data.get("D1_Ingredients", {}) | |
| for category in d1.values(): | |
| if isinstance(category, dict): | |
| for subcategory in category.values(): | |
| if isinstance(subcategory, list): | |
| for item in subcategory: | |
| if query_lower in item.get("name", "").lower() or query_lower in item.get("name_en", "").lower(): | |
| results.append(item) | |
| elif isinstance(category, list): | |
| for item in category: | |
| if query_lower in item.get("name", "").lower() or query_lower in item.get("name_en", "").lower(): | |
| results.append(item) | |
| return results | |
| def get_flavor_profile(self, ingredient_name: str) -> Optional[Dict]: | |
| for p in self.data.get("D2_FlavorsAromas", {}).get("profiles", []): | |
| if ingredient_name in p.get("ingredient", ""): | |
| return p | |
| return None | |
| def get_emergent_pairings(self, ingredient_name: str) -> List[str]: | |
| profile = self.get_flavor_profile(ingredient_name) | |
| if profile: | |
| pairings = profile.get("emergent_pairings", []).copy() | |
| random.shuffle(pairings) # v3.2: 매번 다른 순서! | |
| return pairings | |
| return [] | |
| def get_random_flavor_profile(self) -> Optional[Dict]: | |
| """v3.2 NEW: 랜덤 풍미 프로파일 선택""" | |
| profiles = self.data.get("D2_FlavorsAromas", {}).get("profiles", []) | |
| return random.choice(profiles) if profiles else None | |
| def find_shared_compounds(self, ingredient1: str, ingredient2: str) -> List[str]: | |
| compound_map = self.data.get("D2_FlavorsAromas", {}).get("compound_sharing_map", {}) | |
| shared = [] | |
| for compound, ingredients in compound_map.items(): | |
| if ingredient1 in str(ingredients) and ingredient2 in str(ingredients): | |
| shared.append(compound) | |
| return shared | |
| def get_cooking_method(self, method_id: str) -> Optional[Dict]: | |
| d3 = self.data.get("D3_CookingMethods", {}) | |
| for category in d3.values(): | |
| if isinstance(category, dict): | |
| for subcategory in category.values(): | |
| if isinstance(subcategory, list): | |
| for method in subcategory: | |
| if method.get("id") == method_id: | |
| return method | |
| return None | |
| def search_cooking_methods(self, query: str) -> List[Dict]: | |
| results = [] | |
| query_lower = query.lower() | |
| d3 = self.data.get("D3_CookingMethods", {}) | |
| for category in d3.values(): | |
| if isinstance(category, dict): | |
| for subcategory in category.values(): | |
| if isinstance(subcategory, list): | |
| for method in subcategory: | |
| if query_lower in method.get("name", "").lower() or query_lower in method.get("name_ko", "").lower(): | |
| results.append(method) | |
| return results | |
| def get_random_cooking_method(self) -> Optional[Dict]: | |
| """v3.2 NEW: 랜덤 조리법 선택""" | |
| d3 = self.data.get("D3_CookingMethods", {}) | |
| all_methods = [] | |
| for category in d3.values(): | |
| if isinstance(category, dict): | |
| for subcategory in category.values(): | |
| if isinstance(subcategory, list): | |
| all_methods.extend(subcategory) | |
| return random.choice(all_methods) if all_methods else None | |
| def get_texture(self, name: str) -> Optional[Dict]: | |
| for t in self.data.get("D4_Textures", {}).get("primary_textures", []): | |
| if t.get("name") == name or t.get("name_ko") == name: | |
| return t | |
| return None | |
| def get_random_texture(self) -> Optional[Dict]: | |
| """v3.2 NEW: 랜덤 텍스처 선택""" | |
| textures = self.data.get("D4_Textures", {}).get("primary_textures", []) | |
| return random.choice(textures) if textures else None | |
| def get_texture_contrasts(self, texture_name: str) -> List[str]: | |
| texture = self.get_texture(texture_name) | |
| return texture.get("contrast_pairing", []) if texture else [] | |
| def suggest_texture_combination(self) -> Dict: | |
| contrasts = self.data.get("D4_Textures", {}).get("texture_contrasts", []) | |
| return random.choice(contrasts) if contrasts else {} | |
| def get_architecture(self, arch_name: str) -> Optional[Dict]: | |
| for s in self.data.get("D5_Architecture", {}).get("structures", []): | |
| if s.get("id") == arch_name or s.get("name") == arch_name or s.get("name_ko") == arch_name: | |
| return s | |
| return None | |
| def get_random_architecture(self) -> Optional[Dict]: | |
| """v3.2 NEW: 랜덤 구조 선택""" | |
| structures = self.data.get("D5_Architecture", {}).get("structures", []) | |
| return random.choice(structures) if structures else None | |
| def get_twist_logic(self, arch_name: str) -> List[str]: | |
| structure = self.get_architecture(arch_name) | |
| return structure.get("twist_logic", []) if structure else [] | |
| def get_grammar(self, culture: str) -> List[Dict]: | |
| return self.data.get("D6_CulinaryGrammar", {}).get(culture.lower(), []) | |
| def get_random_grammar(self) -> Tuple[str, Dict]: | |
| """v3.2 NEW: 랜덤 문화+문법 선택""" | |
| grammars = self.data.get("D6_CulinaryGrammar", {}) | |
| cultures = list(grammars.keys()) | |
| if not cultures: | |
| return "korean", {} | |
| culture = random.choice(cultures) | |
| grammar_list = grammars.get(culture, []) | |
| return culture, random.choice(grammar_list) if grammar_list else {} | |
| def get_all_cultures(self) -> List[str]: | |
| return list(self.data.get("D6_CulinaryGrammar", {}).keys()) | |
| # ============================================================================ | |
| # 창발적 레시피 생성 엔진 (v3.2 랜덤 강화!) | |
| # ============================================================================ | |
| class EmergenceEngine: | |
| """창발적 레시피 생성 엔진 - 6대 차원 데이터 기반 (v3.2 랜덤 강화!)""" | |
| def __init__(self, llm, data_loader: CulinaryDataLoader): | |
| self.llm = llm | |
| self.data = data_loader | |
| random.seed(get_random_seed()) # v3.2: 초기화 시 새 시드 | |
| def _get_unique_id(self) -> str: | |
| """v3.2 NEW: 고유 ID 생성""" | |
| return str(uuid.uuid4())[:8] | |
| def generate_emergent_recipe(self, base_dish: str, emergence_type: str, **kwargs) -> Dict: | |
| """창발적 레시피 생성 메인 함수 (v3.2: 매 호출마다 새 시드!)""" | |
| random.seed(get_random_seed()) # v3.2: 매 호출마다 새 시드! | |
| if emergence_type == "variable_crossing": | |
| return self._variable_crossing(base_dish, kwargs.get("method1", ""), kwargs.get("method2", "")) | |
| elif emergence_type == "context_shift": | |
| return self._context_shift(base_dish, kwargs.get("target_cuisine", "italian")) | |
| elif emergence_type == "architecture_inversion": | |
| return self._architecture_inversion(base_dish, kwargs.get("new_structure", "Wrapped")) | |
| elif emergence_type == "grammar_transplantation": | |
| return self._grammar_transplant(base_dish, kwargs.get("new_culture", "french")) | |
| elif emergence_type == "flavor_compound_pairing": | |
| return self._compound_pairing(base_dish, kwargs.get("ingredient", "")) | |
| elif emergence_type == "texture_contrast": | |
| return self._texture_contrast(base_dish, kwargs.get("target_texture", "Crispy")) | |
| return {"error": "Unknown emergence type"} | |
| def _variable_crossing(self, dish: str, method1: str, method2: str) -> Dict: | |
| """변수 교차 - v3.2: 랜덤 요소 강화""" | |
| # 빈 값이면 랜덤 선택! | |
| if not method1: | |
| m1_data = self.data.get_random_cooking_method() | |
| method1 = m1_data.get("name_ko", "수비드") if m1_data else "수비드" | |
| if not method2: | |
| m2_data = self.data.get_random_cooking_method() | |
| method2 = m2_data.get("name_ko", "시어링") if m2_data else "시어링" | |
| # method1과 같으면 다시 선택 | |
| while method2 == method1: | |
| m2_data = self.data.get_random_cooking_method() | |
| method2 = m2_data.get("name_ko", "그릴링") if m2_data else "그릴링" | |
| m1_list = self.data.search_cooking_methods(method1) | |
| m2_list = self.data.search_cooking_methods(method2) | |
| m1 = m1_list[0] if m1_list else {"name_ko": method1, "temp_range": "중온", "time_range": "보통"} | |
| m2 = m2_list[0] if m2_list else {"name_ko": method2, "temp_range": "저온", "time_range": "장시간"} | |
| # v3.2: 랜덤 트위스트 요소 추가 | |
| random_twist = random.choice([ | |
| "온도와 시간의 역전", "순서 바꾸기", "동시 적용", | |
| "단계적 전환", "펄스 방식", "그라디언트 적용" | |
| ]) | |
| random_texture_goal = random.choice([ | |
| "겉바속촉", "촉촉함 극대화", "크러스트 강화", "부드러움 극대화" | |
| ]) | |
| prompt = f"""창발적 요리 생성: 변수 교차 | |
| 기본 요리: {dish} | |
| 조리법 1: {m1.get('name_ko', method1)} - 온도: {m1.get('temp_range', '')}, 시간: {m1.get('time_range', '')} | |
| 조리법 2: {m2.get('name_ko', method2)} - 온도: {m2.get('temp_range', '')}, 시간: {m2.get('time_range', '')} | |
| 랜덤 트위스트: {random_twist} | |
| 텍스처 목표: {random_texture_goal} | |
| 고유ID: {self._get_unique_id()} | |
| 두 조리법의 변수(온도, 시간, 매개체)를 창의적으로 결합하여 완전히 새로운 요리를 제안하세요. | |
| 반드시 한국어로 JSON 형식 응답: | |
| {{"name": "새 요리명", "concept": "컨셉 설명", "technique": "결합된 기법 설명", "steps": ["조리 단계1", "조리 단계2", "조리 단계3", "조리 단계4", "조리 단계5"], "expected_texture": "예상 질감", "flavor_notes": "예상 풍미", "innovation_point": "혁신 포인트"}}""" | |
| response = self.llm.chat([{"role": "user", "content": prompt}]) | |
| default = { | |
| "name": f"{dish} - {m1.get('name_ko', method1)}+{m2.get('name_ko', method2)} 하이브리드", | |
| "concept": f"{m2.get('name_ko', method2)}의 변수를 적용한 후 {m1.get('name_ko', method1)}로 마무리하여 {random_texture_goal} 달성", | |
| "technique": f"{random_twist} 방식의 하이브리드 조리법", | |
| "steps": [ | |
| f"1. 재료를 상온에 30분 적응", | |
| f"2. {m2.get('name_ko', method2)} 방식으로 1차 조리", | |
| "3. 휴지 시간 (5분)", | |
| f"4. {m1.get('name_ko', method1)}로 마무리", | |
| "5. 플레이팅 및 가니쉬" | |
| ], | |
| "expected_texture": random_texture_goal, | |
| "flavor_notes": "깊은 풍미와 복합적인 맛의 레이어", | |
| "innovation_point": f"{random_twist}를 통한 새로운 조리 패러다임" | |
| } | |
| result = parse_json_safely(response, default) | |
| return { | |
| "emergence_type": "variable_crossing", | |
| "base_dish": dish, | |
| "applied_rules": { | |
| "method1": m1.get('name_ko', method1), | |
| "method2": m2.get('name_ko', method2), | |
| "random_twist": random_twist, | |
| "texture_goal": random_texture_goal | |
| }, | |
| "result": result | |
| } | |
| def _context_shift(self, dish: str, target_cuisine: str) -> Dict: | |
| """맥락 전환 - v3.2: 랜덤 요소 강화""" | |
| cultures = self.data.get_all_cultures() | |
| # 빈 값이거나 유효하지 않으면 랜덤 선택 | |
| if not target_cuisine or target_cuisine.lower() not in cultures: | |
| target_cuisine = random.choice(cultures) | |
| target_grammars = self.data.get_grammar(target_cuisine) | |
| culture_names = { | |
| "korean": "한식", "japanese": "일식", "chinese": "중식", | |
| "french": "프랑스", "italian": "이탈리아", "indian": "인도", | |
| "thai": "태국", "mexican": "멕시코" | |
| } | |
| culture_name = culture_names.get(target_cuisine.lower(), target_cuisine) | |
| # v3.2: 문법 중 랜덤 선택 | |
| selected_grammar = random.choice(target_grammars) if target_grammars else {"name": f"{culture_name} 스타일"} | |
| # v3.2: 랜덤 융합 레벨 | |
| random_fusion_level = random.choice([ | |
| "미묘한 힌트", "균형잡힌 융합", "대담한 변신", "완전한 재해석" | |
| ]) | |
| random_signature = random.choice([ | |
| "시그니처 소스 추가", "특별 가니쉬", "독특한 서빙 방식", "의외의 식감 요소", "향의 레이어링" | |
| ]) | |
| prompt = f"""창발적 요리 생성: 맥락 전환 | |
| 원래 요리: {dish} | |
| 목표 문화권: {culture_name} | |
| 해당 문화의 양념 문법: {json.dumps(selected_grammar, ensure_ascii=False)} | |
| 융합 레벨: {random_fusion_level} | |
| 시그니처 요소: {random_signature} | |
| 고유ID: {self._get_unique_id()} | |
| 원래 요리의 핵심 요소를 유지하면서 목표 문화권의 양념, 기법, 식재료로 완전히 재해석하세요. | |
| 반드시 한국어로 JSON 형식 응답: | |
| {{"name": "새 요리명", "concept": "컨셉 설명", "kept_elements": ["유지된 요소1", "유지된 요소2", "유지된 요소3"], "new_elements": ["새로 추가된 요소1", "새로 추가된 요소2", "새로 추가된 요소3"], "flavor_bridge": "두 문화를 연결하는 풍미 요소", "recipe_outline": "간단한 조리 개요", "signature_element": "시그니처 요소"}}""" | |
| response = self.llm.chat([{"role": "user", "content": prompt}]) | |
| default = { | |
| "name": f"{dish} alla {culture_name}", | |
| "concept": f"{dish}의 핵심을 {culture_name} 문법으로 {random_fusion_level} 수준 재해석", | |
| "kept_elements": [f"{dish}의 주재료", "핵심 컨셉", "영혼"], | |
| "new_elements": [f"{culture_name} 양념", f"{culture_name} 조리법", selected_grammar.get("name", "전통 기법")], | |
| "flavor_bridge": "감칠맛의 공통분모", | |
| "recipe_outline": f"{culture_name} 기법으로 {dish} 새롭게 조리", | |
| "signature_element": random_signature | |
| } | |
| result = parse_json_safely(response, default) | |
| return { | |
| "emergence_type": "context_shift", | |
| "base_dish": dish, | |
| "applied_rules": { | |
| "target_cuisine": culture_name, | |
| "grammar": selected_grammar.get("name", ""), | |
| "fusion_level": random_fusion_level, | |
| "signature": random_signature | |
| }, | |
| "result": result | |
| } | |
| def _architecture_inversion(self, dish: str, new_structure: str) -> Dict: | |
| """구조 전복 - v3.2: 랜덤 요소 강화""" | |
| # 빈 값이면 랜덤 선택 | |
| if not new_structure: | |
| arch = self.data.get_random_architecture() | |
| new_structure = arch.get("name", "Wrapped") if arch else "Wrapped" | |
| arch = self.data.get_architecture(new_structure) | |
| if not arch: | |
| arch = self.data.get_random_architecture() or {"name": new_structure, "name_ko": new_structure, "twist_logic": ["구조 변환"]} | |
| twist_options = arch.get("twist_logic", ["구조 변환"]) | |
| selected_twist = random.choice(twist_options) | |
| korean_examples = arch.get("korean_examples", []) | |
| # v3.2: 랜덤 프레젠테이션 스타일 | |
| random_presentation = random.choice([ | |
| "한입 사이즈로", "공유형 플래터로", "개인 포션으로", | |
| "타워 스타일로", "해체 스타일로", "서프라이즈 요소로" | |
| ]) | |
| prompt = f"""창발적 요리 생성: 구조 전복 | |
| 원래 요리: {dish} | |
| 새로운 구조: {new_structure} ({arch.get('name_ko', '')}) | |
| 적용할 변형: {selected_twist} | |
| 프레젠테이션: {random_presentation} | |
| 참고 예시: {korean_examples} | |
| 고유ID: {self._get_unique_id()} | |
| 원래 요리의 구성 요소를 완전히 새로운 구조로 재배치하세요. | |
| 반드시 한국어로 JSON 형식 응답: | |
| {{"name": "새 요리명", "original_structure": "원래 구조", "new_structure": "새 구조", "transformation": "변환 방법", "components": {{"original": ["원래 구성요소1", "원래 구성요소2"], "transformed": ["변환된 구성요소1", "변환된 구성요소2"]}}, "eating_experience": "먹는 경험", "visual_impact": "시각적 임팩트"}}""" | |
| response = self.llm.chat([{"role": "user", "content": prompt}]) | |
| default = { | |
| "name": f"{dish} {arch.get('name_ko', new_structure)} 스타일", | |
| "original_structure": "혼합형 또는 적층형", | |
| "new_structure": arch.get('name_ko', new_structure), | |
| "transformation": selected_twist, | |
| "components": { | |
| "original": [f"{dish}의 주재료", "양념", "부재료"], | |
| "transformed": ["구조 변환된 주재료", "새 형태 양념", "재배치된 부재료"] | |
| }, | |
| "eating_experience": f"{random_presentation} 방식의 새로운 식사 경험", | |
| "visual_impact": "구조 변환의 시각적 놀라움" | |
| } | |
| result = parse_json_safely(response, default) | |
| return { | |
| "emergence_type": "architecture_inversion", | |
| "base_dish": dish, | |
| "applied_rules": { | |
| "new_structure": new_structure, | |
| "twist_used": selected_twist, | |
| "presentation": random_presentation | |
| }, | |
| "result": result | |
| } | |
| def _grammar_transplant(self, dish: str, new_culture: str) -> Dict: | |
| """문법 이식 - v3.2: 랜덤 요소 강화""" | |
| # 빈 값이면 랜덤 선택 | |
| if not new_culture: | |
| new_culture, selected = self.data.get_random_grammar() | |
| else: | |
| grammars = self.data.get_grammar(new_culture) | |
| selected = random.choice(grammars) if grammars else {"name": f"{new_culture} 스타일"} | |
| culture_names = { | |
| "korean": "한식", "japanese": "일식", "french": "프랑스", | |
| "italian": "이탈리아", "indian": "인도", "chinese": "중식", | |
| "thai": "태국", "mexican": "멕시코" | |
| } | |
| culture_name = culture_names.get(new_culture.lower(), new_culture) | |
| # v3.2: 랜덤 강도 및 피니싱 | |
| random_intensity = random.choice(["미묘한", "균형잡힌", "강렬한", "압도적인"]) | |
| random_finishing = random.choice([ | |
| "허브 오일 피니시", "시트러스 제스트", "크리스피 토핑", | |
| "소스 드리즐", "훈연 향 추가", "발사믹 글레이즈" | |
| ]) | |
| prompt = f"""창발적 요리 생성: 문법 이식 | |
| 원래 요리: {dish} | |
| 이식할 양념 문법: {selected.get('name', culture_name)} ({culture_name}) | |
| 문법 구성: {json.dumps(selected, ensure_ascii=False)} | |
| 강도: {random_intensity} | |
| 피니싱: {random_finishing} | |
| 고유ID: {self._get_unique_id()} | |
| 원래 요리의 양념 체계를 완전히 새로운 문화권의 문법으로 대체하세요. | |
| 반드시 한국어로 JSON 형식 응답: | |
| {{"name": "새 요리명", "original_seasoning": "원래 양념", "new_seasoning": "새 양념", "flavor_profile": "새 풍미 프로파일", "cooking_adaptation": "조리법 변화", "finishing_touch": "마무리 터치", "pairing_suggestion": "페어링 제안"}}""" | |
| response = self.llm.chat([{"role": "user", "content": prompt}]) | |
| default = { | |
| "name": f"{dish} ({selected.get('name', culture_name)} 버전)", | |
| "original_seasoning": f"{dish}의 원래 양념", | |
| "new_seasoning": f"{selected.get('name', culture_name)} 양념 체계", | |
| "flavor_profile": f"{random_intensity} {culture_name} 특유의 풍미", | |
| "cooking_adaptation": f"{culture_name} 조리 기법 적용", | |
| "finishing_touch": random_finishing, | |
| "pairing_suggestion": f"{culture_name} 스타일 음료와 함께" | |
| } | |
| result = parse_json_safely(response, default) | |
| return { | |
| "emergence_type": "grammar_transplantation", | |
| "base_dish": dish, | |
| "applied_rules": { | |
| "new_culture": culture_name, | |
| "grammar": selected.get('name', ''), | |
| "intensity": random_intensity, | |
| "finishing": random_finishing | |
| }, | |
| "result": result | |
| } | |
| def _compound_pairing(self, dish: str, ingredient: str) -> Dict: | |
| """풍미 화합물 페어링 - v3.2: 랜덤 요소 대폭 강화!""" | |
| # 빈 값이면 랜덤 선택 | |
| if not ingredient: | |
| profile = self.data.get_random_flavor_profile() | |
| ingredient = profile.get("ingredient", "김치") if profile else "김치" | |
| profile = self.data.get_flavor_profile(ingredient) | |
| emergent = profile.get("emergent_pairings", []).copy() if profile else [] | |
| compounds = profile.get("compounds", []) if profile else [] | |
| if not emergent: | |
| emergent = ["초콜릿", "꿀", "커피", "바닐라", "캐러멜", "트러플", "파마산", "마카다미아", "블루치즈"] | |
| # v3.2: 셔플하여 매번 다른 페어링! | |
| random.shuffle(emergent) | |
| selected_pairing = emergent[0] | |
| # v3.2: 랜덤 적용 기법 | |
| random_technique = random.choice([ | |
| "인퓨전으로", "레이어링으로", "마무리 터치로", | |
| "소스 베이스로", "크러스트/코팅으로", "가니쉬로" | |
| ]) | |
| random_ratio = random.choice([ | |
| "미묘한 힌트 (5%)", "균형 (30%)", "주연급 (50%)", "대담한 전환 (70%)" | |
| ]) | |
| prompt = f"""창발적 요리 생성: 풍미 화합물 페어링 | |
| 기본 요리: {dish} | |
| 핵심 재료: {ingredient} | |
| 해당 재료의 화합물: {compounds} | |
| 창발적 페어링: {selected_pairing} | |
| 적용 기법: {random_technique} | |
| 비율: {random_ratio} | |
| 고유ID: {self._get_unique_id()} | |
| Food Pairing Theory에 기반하여, 공유 화합물을 통한 예상치 못한 재료 조합을 제안하세요. | |
| 반드시 한국어로 JSON 형식 응답: | |
| {{"name": "새 요리명", "unexpected_pairing": "{selected_pairing}", "shared_compounds": ["공유 화합물1", "공유 화합물2"], "flavor_harmony": "맛 조화 설명", "dish_description": "완성 요리 설명", "why_it_works": "왜 이 조합이 작동하는지", "application_method": "적용 방법", "tasting_notes": "테이스팅 노트"}}""" | |
| response = self.llm.chat([{"role": "user", "content": prompt}]) | |
| default = { | |
| "name": f"{dish} with {selected_pairing}", | |
| "unexpected_pairing": selected_pairing, | |
| "shared_compounds": compounds if compounds else ["피라진", "글루타메이트"], | |
| "flavor_harmony": f"{ingredient}와 {selected_pairing}의 예상치 못한 조화", | |
| "dish_description": f"{dish}에 {selected_pairing}을 {random_technique} 결합한 창의적 요리", | |
| "why_it_works": "공유하는 향 화합물이 맛의 조화를 만듦", | |
| "application_method": f"{random_technique} ({random_ratio})", | |
| "tasting_notes": "처음에는 익숙한 맛, 점차 새로운 풍미 발견" | |
| } | |
| result = parse_json_safely(response, default) | |
| return { | |
| "emergence_type": "flavor_compound_pairing", | |
| "base_dish": dish, | |
| "applied_rules": { | |
| "ingredient": ingredient, | |
| "selected_pairing": selected_pairing, | |
| "all_pairings": emergent[:5], # 상위 5개 페어링 옵션 | |
| "technique": random_technique, | |
| "ratio": random_ratio | |
| }, | |
| "result": result | |
| } | |
| def _texture_contrast(self, dish: str, target_texture: str) -> Dict: | |
| """텍스처 대비 - v3.2: 랜덤 요소 강화""" | |
| # 빈 값이면 랜덤 선택 | |
| if not target_texture: | |
| texture = self.data.get_random_texture() | |
| target_texture = texture.get("name", "Crispy") if texture else "Crispy" | |
| texture = self.data.get_texture(target_texture) | |
| if not texture: | |
| texture = self.data.get_random_texture() or { | |
| "name": target_texture, | |
| "name_ko": target_texture, | |
| "contrast_pairing": ["Soft", "Creamy"], | |
| "achieved_by": ["특수 조리법"] | |
| } | |
| contrasts = texture.get("contrast_pairing", ["Creamy", "Soft"]) | |
| achieved_by = texture.get("achieved_by", ["조리법"]) | |
| # v3.2: 대비 텍스처 랜덤 선택 | |
| selected_contrast = random.choice(contrasts) | |
| selected_method = random.choice(achieved_by) | |
| # v3.2: 랜덤 강도 | |
| random_intensity = random.choice(["미묘한", "확실한", "극적인", "압도적인"]) | |
| prompt = f"""창발적 요리 생성: 텍스처 대비 최적화 | |
| 기본 요리: {dish} | |
| 목표 텍스처: {target_texture} ({texture.get('name_ko', '')}) | |
| 대비 텍스처: {selected_contrast} | |
| 달성 방법: {selected_method} | |
| 대비 강도: {random_intensity} | |
| 고유ID: {self._get_unique_id()} | |
| {dish}에 {target_texture} 텍스처를 추가하여 질감의 대비를 극대화하는 새로운 버전을 제안하세요. | |
| 반드시 한국어로 JSON 형식 응답: | |
| {{"name": "새 요리명", "base_texture": "원래 텍스처", "added_texture": "추가된 텍스처", "contrast_effect": "대비 효과", "technique": "적용 기법", "eating_pleasure": "먹는 즐거움", "texture_journey": "질감의 여정", "key_component": "핵심 컴포넌트"}}""" | |
| response = self.llm.chat([{"role": "user", "content": prompt}]) | |
| default = { | |
| "name": f"{texture.get('name_ko', target_texture)} {dish}", | |
| "base_texture": "부드러움", | |
| "added_texture": texture.get('name_ko', target_texture), | |
| "contrast_effect": f"{random_intensity} {texture.get('name_ko', target_texture)}과 {selected_contrast}의 대비", | |
| "technique": selected_method, | |
| "eating_pleasure": "다양한 질감이 어우러진 즐거움", | |
| "texture_journey": f"처음 {texture.get('name_ko', target_texture)} → 중간 {selected_contrast} → 마무리 조화", | |
| "key_component": f"{selected_method}로 달성한 {texture.get('name_ko', target_texture)}" | |
| } | |
| result = parse_json_safely(response, default) | |
| return { | |
| "emergence_type": "texture_contrast", | |
| "base_dish": dish, | |
| "applied_rules": { | |
| "target_texture": target_texture, | |
| "contrast_texture": selected_contrast, | |
| "method": selected_method, | |
| "intensity": random_intensity | |
| }, | |
| "result": result | |
| } | |
| # ============================================================================ | |
| # GROQ AI CLIENT (v3.2: temperature 상향) | |
| # ============================================================================ | |
| from groq import Groq | |
| class GroqAI: | |
| """Groq AI API 클라이언트""" | |
| def __init__(self, api_key: str = None): | |
| self.api_key = api_key or os.environ.get("GROQ_API_KEY", "") | |
| self.model = "openai/gpt-oss-120b" | |
| self.client = Groq(api_key=self.api_key) if self.api_key else None | |
| def chat(self, messages: List[Dict], temperature: float = 0.8, max_tokens: int = 8192) -> str: | |
| """v3.2: 기본 temperature 0.7 → 0.8 상향 (더 창의적)""" | |
| if not self.client: | |
| return self._mock_response(messages) | |
| try: | |
| completion = self.client.chat.completions.create( | |
| model=self.model, | |
| messages=messages, | |
| temperature=temperature, | |
| max_completion_tokens=max_tokens, | |
| top_p=1, | |
| reasoning_effort="medium", | |
| stream=False, | |
| stop=None | |
| ) | |
| return completion.choices[0].message.content or "" | |
| except Exception as e: | |
| print(f"⚠️ Groq API 오류: {e}") | |
| return self._mock_response(messages) | |
| def _mock_response(self, messages: List[Dict]) -> str: | |
| """API 키 없을 때 목업 응답 (v3.2: 랜덤 강화!)""" | |
| last_msg = messages[-1].get("content", "") if messages else "" | |
| # v3.2: 랜덤 형용사/풍미/질감 | |
| random_adjectives = ["혁신적인", "놀라운", "독창적인", "대담한", "섬세한", "우아한", "파격적인", "정교한"] | |
| random_flavors = ["감칠맛", "깊은 풍미", "신선한 향", "복합적인 맛", "은은한 단맛", "스모키한 향"] | |
| random_textures = ["겉바속촉", "크리미하면서 크런치한", "부드럽고 탄력있는", "녹는 듯한"] | |
| adj = random.choice(random_adjectives) | |
| flavor = random.choice(random_flavors) | |
| texture = random.choice(random_textures) | |
| if "executive" in last_msg.lower() or "총괄" in last_msg.lower(): | |
| return json.dumps({ | |
| "concept": f"{adj} 감성으로 재해석한 정갈한 한 접시", | |
| "vision": "최상급 재료와 섬세한 손맛의 조화", | |
| "flavor_direction": f"{flavor}과 신선함의 균형", | |
| "presentation_style": "미니멀하면서도 우아한 담음새", | |
| "special_notes": "제철 식재료의 본연의 맛을 살림" | |
| }, ensure_ascii=False) | |
| elif "garde" in last_msg.lower() or "재료" in last_msg.lower(): | |
| return json.dumps({ | |
| "ingredients_detail": [ | |
| {"name": "한우 채끝", "amount": f"{random.randint(180, 220)}g", "quality": "1++등급", "role": "메인 단백질", "prep": "3cm 두께로 손질"}, | |
| {"name": "송이버섯", "amount": f"{random.randint(40, 60)}g", "quality": "국내산 특등급", "role": "향미 증진", "prep": "슬라이스"}, | |
| {"name": "마늘", "amount": f"{random.randint(15, 25)}g", "quality": "의성 마늘", "role": "베이스 향", "prep": "편썰기"}, | |
| {"name": "참기름", "amount": f"{random.randint(8, 12)}ml", "quality": "전통 압착", "role": "풍미", "prep": "마무리용"}, | |
| {"name": "천일염", "amount": "적량", "quality": "신안 천일염", "role": "간", "prep": "그대로"} | |
| ], | |
| "flavor_profile": {"감칠맛": random.randint(7, 10), "짠맛": random.randint(3, 5), "단맛": random.randint(2, 4), "고소함": random.randint(6, 8)}, | |
| "pairing_suggestions": shuffle_and_pick(["숙성 레드와인", "트러플 오일", "발사믹 글레이즈", "블루치즈", "아루굴라"], 3) | |
| }, ensure_ascii=False) | |
| elif "research" in last_msg.lower() or "연구" in last_msg.lower(): | |
| return json.dumps({ | |
| "innovative_elements": [ | |
| {"technique": "저온 숙성", "application": f"4°C에서 {random.randint(14, 28)}일 드라이에이징으로 효소 작용 극대화"}, | |
| {"technique": "마이야르 반응", "application": f"{random.randint(220, 250)}°C 고온에서 크러스트 형성"}, | |
| {"technique": "휴지(레스팅)", "application": f"조리 후 {random.randint(3, 7)}분간 휴지로 육즙 안정화"} | |
| ], | |
| "scientific_notes": "콜라겐이 젤라틴으로 전환되어 촉촉함 유지, 마이야르 반응으로 150+ 가지 풍미 화합물 생성", | |
| "texture_targets": [texture, "녹는 듯한 부드러움", "적절한 씹는 맛"], | |
| "michelin_benchmark": "3스타 레스토랑의 정밀 온도 제어 기법 적용" | |
| }, ensure_ascii=False) | |
| elif "saucier" in last_msg.lower() or "조리" in last_msg.lower(): | |
| return json.dumps({ | |
| "cooking_sequence": [ | |
| {"step": 1, "action": "재료 상온 적응", "duration": "30분", "temp": "실온(20°C)", "technique": "템퍼링", "detail": "냉장고에서 꺼내 상온에 두어 균일한 조리"}, | |
| {"step": 2, "action": "팬 예열", "duration": "5분", "temp": "230°C", "technique": "고온 예열", "detail": "주철팬을 연기가 날 때까지 예열"}, | |
| {"step": 3, "action": "시어링", "duration": "2분", "temp": "230°C", "technique": "시어링", "detail": "한 면당 1분씩, 크러스트 형성"}, | |
| {"step": 4, "action": "버터 베이스팅", "duration": "3분", "temp": "180°C", "technique": "아로제", "detail": "버터, 마늘, 허브로 베이스팅"}, | |
| {"step": 5, "action": "휴지", "duration": "5분", "temp": "실온", "technique": "레스팅", "detail": "알루미늄 호일로 덮어 휴지"}, | |
| {"step": 6, "action": "슬라이스 및 플레이팅", "duration": "3분", "temp": "실온", "technique": "프레젠테이션", "detail": "결 반대 방향으로 슬라이스"} | |
| ], | |
| "critical_points": ["고기 온도가 조리 품질의 80%를 결정", "휴지 시간 필수", "팬 온도 충분히 올릴 것"], | |
| "total_time": f"{random.randint(45, 55)}분", | |
| "difficulty": "중상급" | |
| }, ensure_ascii=False) | |
| elif "patissier" in last_msg.lower() or "플레이팅" in last_msg.lower(): | |
| return json.dumps({ | |
| "plating_design": { | |
| "plate": f"무광 블랙 세라믹 원형 접시 ({random.randint(26, 30)}cm)", | |
| "layout": "황금비율(1:1.618)에 맞춘 비대칭 중앙 배치", | |
| "garnish": shuffle_and_pick(["마이크로 그린", "식용 금박", "송이버섯 슬라이스", "허브 오일 점", "에디블 플라워"], 4), | |
| "sauce_work": "접시 한쪽에 소스를 숟가락으로 드래그" | |
| }, | |
| "color_palette": ["고동색(고기)", "진녹색(허브)", "금색(오일)", "흰색(소금)"], | |
| "visual_elements": ["높이감 있는 적층", "자연스러운 소스 흐름", "질감의 대비"], | |
| "final_touches": ["플뢰르 드 셀", "갓 간 후추", "트러플 오일 한 방울", "마이크로 그린"] | |
| }, ensure_ascii=False) | |
| elif "inspector" in last_msg.lower() or "심사" in last_msg.lower(): | |
| base_score = random.randint(80, 92) | |
| return json.dumps({ | |
| "scores": { | |
| "taste": {"score": random.randint(16, 19), "max": 20, "comment": f"{flavor}의 레이어가 인상적"}, | |
| "technique": {"score": random.randint(15, 18), "max": 20, "comment": "정교한 온도 제어와 타이밍"}, | |
| "creativity": {"score": random.randint(14, 17), "max": 20, "comment": f"{adj} 해석"}, | |
| "presentation": {"score": random.randint(16, 19), "max": 20, "comment": "시각적 임팩트와 세련된 미학"}, | |
| "ingredients": {"score": random.randint(17, 20), "max": 20, "comment": "최상급 재료의 완벽한 선택"} | |
| }, | |
| "total_score": base_score, | |
| "rating": "⭐⭐⭐ (미슐랭 3스타급)" if base_score >= 88 else "⭐⭐ (미슐랭 2스타급)", | |
| "passed": True, | |
| "strengths": shuffle_and_pick(["재료의 품질과 신선함", "기술적 완성도", "담음새의 예술성", "풍미의 깊이", "창의성"], 3), | |
| "improvements": shuffle_and_pick(["소스의 농도 미세 조정", "채소 요소 강화 가능", "향의 복합성 추가"], 2), | |
| "final_verdict": "미슐랭 수준의 완성도를 보여주는 뛰어난 요리" | |
| }, ensure_ascii=False) | |
| # 창발적 레시피 기본 응답 (v3.2: 랜덤 강화!) | |
| return json.dumps({ | |
| "name": f"{adj} 퓨전 요리", | |
| "concept": f"전통과 {adj} 혁신의 만남", | |
| "technique": "복합 조리 기법 적용", | |
| "steps": ["재료 준비", "1차 조리", f"{flavor} 레이어링", f"{texture} 마무리", "플레이팅"], | |
| "expected_texture": texture, | |
| "flavor_notes": f"{flavor}과 신선한 향의 {adj} 균형", | |
| "innovation_point": f"{adj} 발상의 전환" | |
| }, ensure_ascii=False) | |
| # ============================================================================ | |
| # IMAGE GENERATOR (v3.2: 창발적 레시피 이미지 생성 추가!) | |
| # ============================================================================ | |
| class ImageGenerator: | |
| """Replicate 이미지 생성기 - google/nano-banana-pro (v3.2: 창발적 레시피 이미지 생성 추가!)""" | |
| def __init__(self): | |
| self.model = "google/nano-banana-pro" | |
| def generate(self, prompt: str, style: str = "photorealistic") -> Optional[str]: | |
| """일반 이미지 생성 - nano-banana-pro 모델""" | |
| if not HAS_REPLICATE or not os.environ.get("REPLICATE_API_TOKEN"): | |
| return None | |
| try: | |
| style_prompts = { | |
| "photorealistic": "Professional food photography, Michelin star quality, elegant plating, studio lighting, 8k resolution, appetizing, delicious looking", | |
| "artistic": "Artistic food photography, dramatic lighting, creative composition, fine dining aesthetic", | |
| "minimal": "Minimalist food photography, clean background, natural lighting, elegant simplicity" | |
| } | |
| full_prompt = f"{prompt}. {style_prompts.get(style, style_prompts['photorealistic'])}" | |
| # nano-banana-pro API 호출 | |
| output = replicate.run( | |
| self.model, | |
| input={ | |
| "prompt": full_prompt, | |
| "resolution": "2K", | |
| "aspect_ratio": "1:1", | |
| "output_format": "png", | |
| "safety_filter_level": "block_only_high" | |
| } | |
| ) | |
| # nano-banana-pro는 단일 FileOutput 객체 반환 | |
| if output: | |
| # output.url 또는 str(output) 사용 | |
| if hasattr(output, 'url'): | |
| return str(output.url) | |
| else: | |
| return str(output) | |
| return None | |
| except Exception as e: | |
| print(f"⚠️ 이미지 생성 오류: {e}") | |
| return None | |
| def generate_kfood(self, dish_name: str, category: str, recipe_result: Dict = None) -> Optional[str]: | |
| """K-FOOD 이미지 생성 - 레시피 결과 기반 정확한 프롬프트""" | |
| # 한국어 요리명을 영어로 변환 | |
| korean_to_english = { | |
| "비빔밥": "Bibimbap with colorful vegetables, egg, gochujang sauce", | |
| "떡볶이": "Tteokbokki with red spicy sauce, fish cakes, green onions", | |
| "김치찌개": "Kimchi Jjigae stew with tofu, pork, red broth", | |
| "불고기": "Bulgogi marinated beef, caramelized edges, sesame", | |
| "삼겹살": "Samgyeopsal grilled pork belly slices, lettuce wraps", | |
| "된장찌개": "Doenjang Jjigae with tofu, zucchini, mushrooms", | |
| "갈비": "Galbi Korean BBQ short ribs, glossy glaze", | |
| "냉면": "Naengmyeon cold noodles in icy broth, egg, cucumber", | |
| "잡채": "Japchae glass noodles with vegetables, beef strips", | |
| "돼지국밥": "Dwaeji Gukbap pork rice soup, milky broth, green onions", | |
| "순두부찌개": "Sundubu Jjigae soft tofu stew, bubbling red broth, egg", | |
| "삼계탕": "Samgyetang ginseng chicken soup, whole chicken, jujubes", | |
| "보쌈": "Bossam steamed pork slices, kimchi, ssam vegetables", | |
| "김밥": "Kimbap rice rolls, colorful filling visible, sliced", | |
| "만두": "Mandu dumplings, golden pan-fried, dipping sauce", | |
| "파전": "Pajeon green onion pancake, crispy edges, seafood visible", | |
| "칼국수": "Kalguksu knife-cut noodles in clear broth, zucchini", | |
| } | |
| dish_en = korean_to_english.get(dish_name, dish_name) | |
| # 카테고리별 스타일 요소 | |
| category_styles = { | |
| "한정식": "traditional Korean table setting, multiple banchan dishes, elegant ceramics", | |
| "김치/발효": "fermented Korean ingredients, earthy tones, traditional onggi pottery", | |
| "국/탕/찌개": "bubbling hot stew, stone pot (dolsot), steam rising, rich broth", | |
| "구이/적": "grilled Korean BBQ, sizzling on grill, char marks, lettuce wrap setup", | |
| "면/만두": "Korean noodles or dumplings, chopsticks, soup bowl", | |
| "떡/한과": "Korean rice cakes and traditional sweets, colorful, artistic", | |
| "해물요리": "Korean seafood dish, fresh seafood, coastal presentation", | |
| "퓨전한식": "modern Korean fusion, contemporary plating, innovative presentation", | |
| } | |
| style_element = category_styles.get(category, "authentic Korean presentation") | |
| # 레시피 결과에서 추가 정보 추출 | |
| extra_elements = [] | |
| if recipe_result: | |
| concept = recipe_result.get("concept", {}) | |
| if isinstance(concept, dict): | |
| if concept.get("presentation_style"): | |
| extra_elements.append(concept.get("presentation_style")) | |
| plating = recipe_result.get("plating", {}) | |
| if isinstance(plating, dict): | |
| if plating.get("garnish"): | |
| garnishes = plating.get("garnish", []) | |
| if isinstance(garnishes, list) and garnishes: | |
| extra_elements.append(f"garnished with {', '.join(garnishes[:3])}") | |
| extra_str = ", ".join(extra_elements) if extra_elements else "" | |
| prompt = f"Korean cuisine: {dish_en}. {style_element}. {extra_str}. Beautiful Korean ceramic dish (백자 or 청자 style), traditional Korean restaurant ambiance, steam visible if hot dish, appetizing and authentic" | |
| print(f"🇰🇷 K-FOOD 이미지 프롬프트: {prompt[:150]}...") | |
| return self.generate(prompt) | |
| def generate_emergent(self, result: Dict, emergence_type: str) -> Optional[str]: | |
| """v3.2 NEW: 창발적 레시피 이미지 생성! - 레시피 결과 기반 정확한 프롬프트""" | |
| recipe_result = result.get("result", {}) | |
| recipe_name = recipe_result.get("name", "창발적 요리") | |
| base_dish = result.get("base_dish", "") | |
| applied_rules = result.get("applied_rules", {}) | |
| # 한국어 요리명을 영어로 변환하는 매핑 | |
| korean_to_english = { | |
| "비빔밥": "Bibimbap (Korean mixed rice bowl)", | |
| "떡볶이": "Tteokbokki (Korean spicy rice cakes)", | |
| "김치찌개": "Kimchi Jjigae (Korean kimchi stew)", | |
| "불고기": "Bulgogi (Korean BBQ beef)", | |
| "삼겹살": "Samgyeopsal (Korean pork belly)", | |
| "김치": "Kimchi (Korean fermented vegetables)", | |
| "된장찌개": "Doenjang Jjigae (Korean soybean paste stew)", | |
| "갈비": "Galbi (Korean short ribs)", | |
| "냉면": "Naengmyeon (Korean cold noodles)", | |
| "잡채": "Japchae (Korean glass noodles)", | |
| "돼지국밥": "Dwaeji Gukbap (Korean pork rice soup)", | |
| "순두부찌개": "Sundubu Jjigae (Korean soft tofu stew)", | |
| "삼계탕": "Samgyetang (Korean ginseng chicken soup)", | |
| "보쌈": "Bossam (Korean wrapped pork)", | |
| "족발": "Jokbal (Korean pig's feet)", | |
| "김밥": "Kimbap (Korean rice rolls)", | |
| "만두": "Mandu (Korean dumplings)", | |
| "파전": "Pajeon (Korean green onion pancake)", | |
| "칼국수": "Kalguksu (Korean knife-cut noodles)", | |
| "감자탕": "Gamjatang (Korean pork bone stew)", | |
| } | |
| # 기본 요리 영어 변환 | |
| base_dish_en = korean_to_english.get(base_dish, base_dish) | |
| # 레시피 결과에서 구체적인 정보 추출 | |
| concept = recipe_result.get("concept", "") | |
| technique = recipe_result.get("technique", "") | |
| expected_texture = recipe_result.get("expected_texture", "") | |
| flavor_notes = recipe_result.get("flavor_notes", "") | |
| new_structure = recipe_result.get("new_structure", "") | |
| added_texture = recipe_result.get("added_texture", "") | |
| unexpected_pairing = recipe_result.get("unexpected_pairing", "") | |
| new_seasoning = recipe_result.get("new_seasoning", "") | |
| # 기본 프롬프트 구성요소 | |
| base_elements = [] | |
| # 창발 유형별 핵심 시각 요소 추출 | |
| if emergence_type == "variable_crossing": | |
| method1 = applied_rules.get("method1", "") | |
| method2 = applied_rules.get("method2", "") | |
| twist = applied_rules.get("twist", "") | |
| # 조리법에 따른 시각적 특성 | |
| method_visuals = { | |
| "수비드": "perfectly cooked, even pink center, moist texture", | |
| "시어링": "golden brown crust, caramelized surface, Maillard reaction", | |
| "그릴링": "char marks, smoky appearance, grilled texture", | |
| "로스팅": "crispy golden skin, roasted appearance", | |
| "브레이징": "tender falling-apart meat, rich sauce", | |
| "튀김": "crispy golden fried coating, crunchy texture", | |
| "찌기": "steamed, moist, delicate texture", | |
| "콩피": "tender confit, slow-cooked, rich", | |
| } | |
| visual1 = method_visuals.get(method1, "professionally cooked") | |
| visual2 = method_visuals.get(method2, "expertly prepared") | |
| base_elements = [ | |
| f"A dish of {base_dish_en}", | |
| f"showing {visual1} combined with {visual2}", | |
| f"texture: {expected_texture}" if expected_texture else "", | |
| f"technique: {technique}" if technique else "", | |
| ] | |
| elif emergence_type == "context_shift": | |
| target_cuisine = applied_rules.get("target_cuisine", "") | |
| fusion_level = applied_rules.get("fusion", "") | |
| cuisine_visuals = { | |
| "이탈리아": "Italian style plating, pasta elements, olive oil drizzle, fresh basil, parmesan", | |
| "프랑스": "French fine dining presentation, elegant sauce, refined garnish", | |
| "일식": "Japanese minimalist plating, wasabi, soy sauce, nori, chopsticks", | |
| "인도": "Indian spices visible, curry colors, naan bread, colorful presentation", | |
| "태국": "Thai style, lime wedge, fresh herbs, chili, coconut elements", | |
| "멕시코": "Mexican style, tortilla, avocado, lime, cilantro, vibrant colors", | |
| "한식": "Korean banchan style, gochujang red, sesame seeds, Korean ceramics", | |
| } | |
| cuisine_visual = cuisine_visuals.get(target_cuisine, f"{target_cuisine} cuisine style") | |
| base_elements = [ | |
| f"{base_dish_en} reimagined in {target_cuisine} style", | |
| cuisine_visual, | |
| f"fusion level: {fusion_level}" if fusion_level else "creative fusion", | |
| f"keeping original essence with new cultural presentation", | |
| ] | |
| elif emergence_type == "architecture_inversion": | |
| new_struct = applied_rules.get("new_structure", "") | |
| presentation = applied_rules.get("presentation", "") | |
| structure_visuals = { | |
| "Wrapped": "wrapped in thin wrapper, dumpling or spring roll style, enclosed filling", | |
| "Layered": "layered presentation, stacked components, visible layers", | |
| "Stuffed": "stuffed presentation, filling visible, pocket style", | |
| "Skewered": "on skewers, yakitori style, grilled pieces on stick", | |
| "Soup-based": "served in broth, soup bowl presentation, floating ingredients", | |
| "Coated": "coated with sauce or breading, glazed surface", | |
| "포용형": "wrapped style, enclosed in wrapper", | |
| "적층형": "layered stack, multiple tiers", | |
| "채움형": "stuffed, filled presentation", | |
| } | |
| struct_visual = structure_visuals.get(new_struct, "deconstructed presentation") | |
| base_elements = [ | |
| f"{base_dish_en} transformed into {new_struct} structure", | |
| struct_visual, | |
| f"presentation: {presentation}" if presentation else "artistic plating", | |
| "innovative food architecture, deconstructed and reconstructed", | |
| ] | |
| elif emergence_type == "grammar_transplantation": | |
| new_culture = applied_rules.get("new_culture", "") | |
| finishing = applied_rules.get("finishing", "") | |
| intensity = applied_rules.get("intensity", "") | |
| grammar_visuals = { | |
| "한식": "gochujang glaze, sesame oil sheen, Korean red pepper flakes", | |
| "일식": "miso glaze, dashi elements, Japanese seven spice", | |
| "프랑스": "French butter sauce, herb butter, fine herbs", | |
| "이탈리아": "Italian herb oil, basil pesto, balsamic reduction", | |
| "인도": "Indian curry spices, turmeric yellow, garam masala", | |
| "태국": "Thai fish sauce glaze, lemongrass, Thai basil", | |
| "멕시코": "chipotle sauce, Mexican spice rub, lime zest", | |
| } | |
| grammar_visual = grammar_visuals.get(new_culture, f"{new_culture} seasoning style") | |
| base_elements = [ | |
| f"{base_dish_en} with {new_culture} seasoning and flavors", | |
| grammar_visual, | |
| f"finishing touch: {finishing}" if finishing else "", | |
| f"intensity: {intensity}" if intensity else "", | |
| f"new seasoning: {new_seasoning}" if new_seasoning else "", | |
| ] | |
| elif emergence_type == "flavor_compound_pairing": | |
| ingredient = applied_rules.get("ingredient", "") | |
| pairing = applied_rules.get("selected_pairing", "") | |
| tech = applied_rules.get("technique", "") | |
| ratio = applied_rules.get("ratio", "") | |
| # 재료별 시각적 특성 | |
| ingredient_visuals = { | |
| "김치": "fermented red kimchi, Korean chili flakes visible", | |
| "고추장": "red gochujang sauce, glossy red glaze", | |
| "된장": "brown miso-like paste, earthy tones", | |
| "간장": "dark soy glaze, caramelized shine", | |
| "참기름": "sesame oil drizzle, nutty golden oil drops", | |
| } | |
| pairing_visuals = { | |
| "Chocolate": "chocolate sauce drizzle, dark cocoa elements", | |
| "Cheese": "melted cheese, cheese shavings", | |
| "Honey": "honey drizzle, golden sticky glaze", | |
| "Bacon": "crispy bacon bits, smoky meat garnish", | |
| "Coffee": "coffee reduction sauce, espresso elements", | |
| "Caramel": "caramel sauce, golden sticky coating", | |
| "Truffle": "truffle shavings, black truffle slices", | |
| "Mango": "fresh mango cubes, tropical fruit garnish", | |
| } | |
| ing_visual = ingredient_visuals.get(ingredient, ingredient) | |
| pair_visual = pairing_visuals.get(pairing, pairing) | |
| base_elements = [ | |
| f"{base_dish_en} featuring {ingredient} paired with {pairing}", | |
| f"showing {ing_visual}", | |
| f"combined with {pair_visual}", | |
| f"application: {tech}" if tech else "", | |
| f"unexpected but harmonious flavor combination", | |
| ] | |
| elif emergence_type == "texture_contrast": | |
| target_texture = applied_rules.get("target_texture", "") | |
| contrast = applied_rules.get("contrast_texture", "") | |
| intensity = applied_rules.get("intensity", "") | |
| texture_visuals = { | |
| "Crispy": "golden crispy crust, crunchy coating visible", | |
| "Creamy": "smooth creamy sauce, velvety texture", | |
| "Chewy": "chewy texture, glutinous appearance", | |
| "Tender": "fork-tender, soft falling apart", | |
| "Crunchy": "crunchy bits, textured crumbs", | |
| "Silky": "silky smooth surface, glossy finish", | |
| "Fluffy": "light fluffy texture, airy appearance", | |
| "바삭함": "crispy golden surface", | |
| "크리미": "creamy smooth sauce", | |
| "쫄깃함": "chewy bouncy texture", | |
| } | |
| tex1_visual = texture_visuals.get(target_texture, target_texture) | |
| tex2_visual = texture_visuals.get(contrast, contrast) | |
| base_elements = [ | |
| f"{base_dish_en} with {target_texture} and {contrast} texture contrast", | |
| f"showing {tex1_visual}", | |
| f"contrasted with {tex2_visual}", | |
| f"intensity: {intensity}" if intensity else "", | |
| "perfect textural harmony visible", | |
| ] | |
| else: | |
| base_elements = [ | |
| f"Creative fusion dish based on {base_dish_en}", | |
| "innovative modern gastronomy", | |
| "artistic plating", | |
| ] | |
| # 프롬프트 조합 | |
| visual_elements = [e for e in base_elements if e] # 빈 문자열 제거 | |
| # 최종 프롬프트 생성 | |
| prompt = f"{', '.join(visual_elements)}. Professional food photography, Michelin star quality plating, elegant ceramic plate, studio lighting, appetizing presentation, 8k resolution, food magazine cover quality" | |
| print(f"🖼️ 이미지 프롬프트: {prompt[:200]}...") # 디버그 로그 | |
| return self.generate(prompt) | |
| # ============================================================================ | |
| # SOMA AGENT TEAM (6명의 전문 셰프) | |
| # ============================================================================ | |
| class AgentRole(Enum): | |
| EXECUTIVE_CHEF = "executive_chef" | |
| GARDE_MANGER = "garde_manger" | |
| RESEARCH_CHEF = "research_chef" | |
| SAUCIER = "saucier" | |
| PATISSIER = "patissier" | |
| MICHELIN_INSPECTOR = "michelin_inspector" | |
| AGENT_PROMPTS = { | |
| AgentRole.EXECUTIVE_CHEF: """당신은 미슐랭 3스타 레스토랑의 총괄 셰프입니다. | |
| 요청된 요리에 대한 전체적인 비전과 컨셉을 제시하세요. | |
| 반드시 한국어로 JSON 형식으로만 응답하세요: | |
| { | |
| "concept": "요리 컨셉", | |
| "vision": "요리 비전", | |
| "flavor_direction": "맛의 방향성", | |
| "presentation_style": "프레젠테이션 스타일", | |
| "special_notes": "특별 노트" | |
| }""", | |
| AgentRole.GARDE_MANGER: """당신은 미슐랭 레스토랑의 재료 전문가(가르드 망제)입니다. | |
| 최상의 재료를 선별하고 상세한 재료 목록을 작성하세요. | |
| 반드시 한국어로 JSON 형식으로만 응답하세요: | |
| { | |
| "ingredients_detail": [ | |
| {"name": "재료명", "amount": "분량", "quality": "품질등급", "role": "역할", "prep": "손질법"} | |
| ], | |
| "flavor_profile": {"감칠맛": 0-10, "짠맛": 0-10, "단맛": 0-10, "매운맛": 0-10, "신맛": 0-10}, | |
| "pairing_suggestions": ["페어링 제안들"] | |
| }""", | |
| AgentRole.RESEARCH_CHEF: """당신은 R&D 셰프로서 최신 조리 과학을 적용합니다. | |
| 혁신적인 기법과 과학적 접근을 제안하세요. | |
| 반드시 한국어로 JSON 형식으로만 응답하세요: | |
| { | |
| "innovative_elements": [ | |
| {"technique": "기법명", "application": "적용 방법"} | |
| ], | |
| "scientific_notes": "과학적 설명", | |
| "texture_targets": ["목표 텍스처들"], | |
| "michelin_benchmark": "미슐랭 수준 벤치마크" | |
| }""", | |
| AgentRole.SAUCIER: """당신은 소시에(소스/조리 전문)로서 완벽한 조리 순서를 설계합니다. | |
| 단계별 상세 조리법을 작성하세요. | |
| 반드시 한국어로 JSON 형식으로만 응답하세요: | |
| { | |
| "cooking_sequence": [ | |
| {"step": 1, "action": "동작", "duration": "시간", "temp": "온도", "technique": "기법", "detail": "상세설명"} | |
| ], | |
| "critical_points": ["핵심 포인트들"], | |
| "total_time": "총 소요시간", | |
| "difficulty": "난이도" | |
| }""", | |
| AgentRole.PATISSIER: """당신은 파티시에/플레이팅 전문가입니다. | |
| 미슐랭 수준의 플레이팅 디자인을 제시하세요. | |
| 반드시 한국어로 JSON 형식으로만 응답하세요: | |
| { | |
| "plating_design": { | |
| "plate": "접시 종류", | |
| "layout": "배치 방식", | |
| "garnish": ["가니쉬 목록"], | |
| "sauce_work": "소스 작업" | |
| }, | |
| "color_palette": ["색상 팔레트"], | |
| "visual_elements": ["시각적 요소들"], | |
| "final_touches": ["마무리 터치"] | |
| }""", | |
| AgentRole.MICHELIN_INSPECTOR: """당신은 미슐랭 가이드 심사관입니다. | |
| 제시된 레시피를 엄격하게 평가하세요. | |
| 반드시 한국어로 JSON 형식으로만 응답하세요: | |
| { | |
| "scores": { | |
| "taste": {"score": 0-20, "max": 20, "comment": "맛 평가"}, | |
| "technique": {"score": 0-20, "max": 20, "comment": "기술 평가"}, | |
| "creativity": {"score": 0-20, "max": 20, "comment": "창의성 평가"}, | |
| "presentation": {"score": 0-20, "max": 20, "comment": "프레젠테이션 평가"}, | |
| "ingredients": {"score": 0-20, "max": 20, "comment": "재료 평가"} | |
| }, | |
| "total_score": 0-100, | |
| "rating": "미슐랭 등급", | |
| "passed": true/false, | |
| "strengths": ["강점들"], | |
| "improvements": ["개선점들"], | |
| "final_verdict": "최종 평가" | |
| }""" | |
| } | |
| class SOMATeam: | |
| """SOMA Agent Team - 6명의 전문 셰프가 협업하여 레시피 개발""" | |
| def __init__(self, llm: GroqAI): | |
| self.llm = llm | |
| self.workflow = [ | |
| AgentRole.EXECUTIVE_CHEF, | |
| AgentRole.GARDE_MANGER, | |
| AgentRole.RESEARCH_CHEF, | |
| AgentRole.SAUCIER, | |
| AgentRole.PATISSIER, | |
| AgentRole.MICHELIN_INSPECTOR | |
| ] | |
| def run_agent(self, role: AgentRole, context: Dict) -> Dict: | |
| """개별 에이전트 실행 - 안전한 파싱 적용""" | |
| prompt = AGENT_PROMPTS.get(role, "") | |
| user_request = context.get('user_request', '') | |
| ingredients = context.get('ingredients', []) | |
| constraints = context.get('constraints', {}) | |
| previous = context.get('previous_outputs', {}) | |
| prev_summary = "" | |
| for key, value in previous.items(): | |
| if value and isinstance(value, dict): | |
| prev_summary += f"\n{key} 결과: {json.dumps(value, ensure_ascii=False)[:500]}" | |
| message = f"""{prompt} | |
| 요리 요청: {user_request} | |
| 사용 가능 재료: {', '.join(ingredients) if ingredients else '제한 없음'} | |
| 제약 조건: {json.dumps(constraints, ensure_ascii=False) if constraints else '없음'} | |
| {prev_summary if prev_summary else ''} | |
| 반드시 위에서 요청한 JSON 형식으로만 응답하세요.""" | |
| response = self.llm.chat([{"role": "system", "content": prompt}, {"role": "user", "content": message}]) | |
| defaults = { | |
| AgentRole.EXECUTIVE_CHEF: { | |
| "concept": f"{user_request}의 현대적 재해석", | |
| "vision": "최상급 재료와 섬세한 기법의 조화", | |
| "flavor_direction": "깊은 감칠맛과 신선함의 균형", | |
| "presentation_style": "미니멀하고 세련된 담음새", | |
| "special_notes": "제철 식재료 활용" | |
| }, | |
| AgentRole.GARDE_MANGER: { | |
| "ingredients_detail": [ | |
| {"name": "주재료", "amount": "200g", "quality": "특등급", "role": "메인", "prep": "손질 완료"}, | |
| {"name": "부재료", "amount": "100g", "quality": "상등급", "role": "보조", "prep": "다듬기"}, | |
| {"name": "양념 재료", "amount": "적량", "quality": "국산", "role": "풍미", "prep": "다지기"} | |
| ], | |
| "flavor_profile": {"감칠맛": 8, "짠맛": 5, "단맛": 3, "매운맛": 2, "신맛": 2}, | |
| "pairing_suggestions": ["레드와인", "파마산 치즈", "트러플 오일"] | |
| }, | |
| AgentRole.RESEARCH_CHEF: { | |
| "innovative_elements": [ | |
| {"technique": "저온 조리", "application": "62도에서 최적 텍스처 달성"}, | |
| {"technique": "마이야르 반응", "application": "고온에서 풍미 극대화"} | |
| ], | |
| "scientific_notes": "단백질 변성과 마이야르 반응을 통한 풍미 극대화", | |
| "texture_targets": ["겉바속촉", "부드러운 내부", "크리스피한 외부"], | |
| "michelin_benchmark": "3성급 기법 적용" | |
| }, | |
| AgentRole.SAUCIER: { | |
| "cooking_sequence": [ | |
| {"step": 1, "action": "재료 손질", "duration": "15분", "temp": "실온", "technique": "기본 손질", "detail": "모든 재료를 적절한 크기로 준비"}, | |
| {"step": 2, "action": "밑준비", "duration": "10분", "temp": "실온", "technique": "마리네이드", "detail": "양념에 재우기"}, | |
| {"step": 3, "action": "예열", "duration": "5분", "temp": "180°C", "technique": "팬 예열", "detail": "팬을 충분히 예열"}, | |
| {"step": 4, "action": "조리", "duration": "15분", "temp": "중불-강불", "technique": "볶음/구이", "detail": "핵심 조리 단계"}, | |
| {"step": 5, "action": "마무리", "duration": "5분", "temp": "약불", "technique": "휴지", "detail": "맛 안정화"} | |
| ], | |
| "critical_points": ["온도 관리가 핵심", "타이밍 엄수", "재료 투입 순서 중요"], | |
| "total_time": "50분", | |
| "difficulty": "중급" | |
| }, | |
| AgentRole.PATISSIER: { | |
| "plating_design": { | |
| "plate": "흰색 원형 접시 (28cm)", | |
| "layout": "중앙 배치, 비대칭 포인트", | |
| "garnish": ["마이크로 그린", "식용꽃", "소스 점", "허브 오일"], | |
| "sauce_work": "숟가락으로 우아하게 드리즐" | |
| }, | |
| "color_palette": ["갈색", "녹색", "흰색", "금색 포인트"], | |
| "visual_elements": ["높이감", "질감 대비", "색상 조화", "여백의 미"], | |
| "final_touches": ["플뢰르 드 셀", "후추", "허브 오일", "마이크로 그린"] | |
| }, | |
| AgentRole.MICHELIN_INSPECTOR: { | |
| "scores": { | |
| "taste": {"score": 17, "max": 20, "comment": "풍미의 깊이와 균형이 훌륭함"}, | |
| "technique": {"score": 16, "max": 20, "comment": "안정적인 기법 구사"}, | |
| "creativity": {"score": 15, "max": 20, "comment": "독창적 요소 가미"}, | |
| "presentation": {"score": 17, "max": 20, "comment": "시각적으로 매력적"}, | |
| "ingredients": {"score": 18, "max": 20, "comment": "고품질 재료 사용"} | |
| }, | |
| "total_score": 83, | |
| "rating": "⭐⭐ (미슐랭 2스타급)", | |
| "passed": True, | |
| "strengths": ["재료 품질", "기본기 탄탄", "담음새 정갈"], | |
| "improvements": ["창의성 강화 여지", "시그니처 요소 개발"], | |
| "final_verdict": "높은 완성도의 요리로, 미슐랭 수준에 근접" | |
| } | |
| } | |
| result = parse_json_safely(response, defaults.get(role, {})) | |
| return result | |
| KFOOD_AGENT_PROMPTS = { | |
| "총괄셰프": """당신은 한식 미슐랭 3스타 총괄 셰프입니다. | |
| 전통 한식의 철학과 현대적 해석을 결합한 비전을 제시하세요. | |
| 반드시 한국어로 JSON 형식으로만 응답하세요: | |
| { | |
| "concept": "요리 컨셉", | |
| "korean_philosophy": "한식 철학 (오미, 오색, 약식동원 등)", | |
| "seasonal_approach": "계절적 접근", | |
| "regional_influence": "지역적 영향" | |
| }""", | |
| "재료전문가": """당신은 한식 재료 전문가입니다. | |
| 최상의 한국 전통 재료를 선별하고 상세 목록을 작성하세요. | |
| 반드시 한국어로 JSON 형식으로만 응답하세요: | |
| { | |
| "ingredients_detail": [ | |
| {"name": "재료명", "amount": "분량", "role": "역할", "traditional_use": "전통적 쓰임새", "prep": "손질법"} | |
| ], | |
| "sourcing": "재료 조달 정보", | |
| "quality_notes": "품질 노트" | |
| }""", | |
| "발효연구원": """당신은 발효 전문가입니다. | |
| 한국 전통 발효의 과학과 응용을 설명하세요. | |
| 반드시 한국어로 JSON 형식으로만 응답하세요: | |
| { | |
| "fermentation_notes": { | |
| "base": "발효 베이스", | |
| "time": "발효 시간", | |
| "technique": "발효 기법", | |
| "flavor_development": "풍미 발달 과정" | |
| }, | |
| "microbe_culture": "미생물 문화", | |
| "optimal_conditions": "최적 조건" | |
| }""", | |
| "조리장인": """당신은 한식 조리 장인입니다. | |
| 전통 기법과 현대 기술을 결합한 조리법을 설계하세요. | |
| 반드시 한국어로 JSON 형식으로만 응답하세요: | |
| { | |
| "cooking_sequence": [ | |
| {"step": 1, "action": "동작", "duration": "시간", "temp": "온도", "korean_technique": "한식 기법", "detail": "상세설명"} | |
| ], | |
| "critical_points": ["핵심 포인트들"], | |
| "total_time": "총 소요시간", | |
| "difficulty": "난이도" | |
| }""", | |
| "플레이팅전문가": """당신은 한식 플레이팅 전문가입니다. | |
| 전통 미학과 현대적 감각을 조화시킨 플레이팅을 디자인하세요. | |
| 반드시 한국어로 JSON 형식으로만 응답하세요: | |
| { | |
| "plating_design": { | |
| "vessel": "그릇 종류 (백자, 옹기, 유기 등)", | |
| "layout": "배치 방식", | |
| "garnish": ["고명 목록"], | |
| "korean_elements": ["한국적 요소들"] | |
| }, | |
| "color_harmony": "색의 조화 (오방색 등)", | |
| "visual_story": "시각적 스토리" | |
| }""" | |
| } | |
| class KFoodSOMATeam: | |
| """K-FOOD 전문 SOMA Agent Team""" | |
| def __init__(self, llm:GroqAI): | |
| self.llm = llm | |
| self.workflow = list(KFOOD_AGENT_PROMPTS.keys()) | |
| def run_agent(self, role: str, context: Dict) -> Dict: | |
| """K-FOOD 에이전트 실행 - 안전한 파싱 적용""" | |
| prompt = KFOOD_AGENT_PROMPTS.get(role, "") | |
| user_request = context.get('user_request', '') | |
| category = context.get('category', '') | |
| regional = context.get('regional_style', '') | |
| season = context.get('season', '') | |
| spice = context.get('spice_level', 5) | |
| previous = context.get('previous_outputs', {}) | |
| prev_summary = "" | |
| for k, v in previous.items(): | |
| if v and isinstance(v, dict): | |
| prev_summary += f"\n{k} 결과: {json.dumps(v, ensure_ascii=False)[:500]}" | |
| message = f"""{prompt} | |
| 요리 요청: {user_request} | |
| 카테고리: {category} | |
| 지역 스타일: {regional} | |
| 계절: {season} | |
| 매운맛 레벨: {spice}/10 | |
| {prev_summary if prev_summary else ''} | |
| 반드시 위에서 요청한 JSON 형식으로만 응답하세요.""" | |
| response = self.llm.chat([{"role": "user", "content": message}]) | |
| defaults = { | |
| "총괄셰프": { | |
| "concept": f"{user_request}의 현대적 한식 재해석", | |
| "korean_philosophy": "오미(五味)의 조화와 약식동원(藥食同源)의 정신", | |
| "seasonal_approach": f"{season} 제철 재료의 본연의 맛 활용", | |
| "regional_influence": f"{regional} 지역의 맛 특성 반영" | |
| }, | |
| "재료전문가": { | |
| "ingredients_detail": [ | |
| {"name": "주재료", "amount": "200g", "role": "메인", "traditional_use": "전통 요리의 핵심 재료", "prep": "전통 방식으로 손질"}, | |
| {"name": "양념 재료", "amount": "적량", "role": "풍미", "traditional_use": "갖은양념의 기본", "prep": "다지기"}, | |
| {"name": "고명", "amount": "소량", "role": "마무리", "traditional_use": "색과 향 추가", "prep": "채썰기"} | |
| ], | |
| "sourcing": "국내산 유기농 재료 우선, 제철 재료 활용", | |
| "quality_notes": "신선도와 원산지 확인 필수" | |
| }, | |
| "발효연구원": { | |
| "fermentation_notes": { | |
| "base": "전통 고추장/된장 베이스", | |
| "time": "최소 6개월 자연 숙성", | |
| "technique": "옹기 항아리에서 전통 방식 발효", | |
| "flavor_development": "시간이 만들어낸 깊은 감칠맛과 복합적 풍미" | |
| }, | |
| "microbe_culture": "유익균이 풍부한 전통 종균 활용", | |
| "optimal_conditions": "15-25°C, 적정 습도 유지" | |
| }, | |
| "조리장인": { | |
| "cooking_sequence": [ | |
| {"step": 1, "action": "양념장 준비", "duration": "15분", "temp": "실온", "korean_technique": "갖은양념 배합", "detail": "마늘, 생강, 간장, 참기름 등 배합"}, | |
| {"step": 2, "action": "재료 손질", "duration": "20분", "temp": "실온", "korean_technique": "전통 써는 법", "detail": "재료 특성에 맞게 손질"}, | |
| {"step": 3, "action": "밑간 및 재우기", "duration": "30분", "temp": "냉장", "korean_technique": "간 배기", "detail": "양념이 배도록 재우기"}, | |
| {"step": 4, "action": "불 조절 조리", "duration": "20분", "temp": "중불-강불", "korean_technique": "센불/약불 조절", "detail": "한식 특유의 불 조절"}, | |
| {"step": 5, "action": "담기", "duration": "5분", "temp": "실온", "korean_technique": "정갈한 담음새", "detail": "전통 방식으로 정갈하게"} | |
| ], | |
| "critical_points": ["불 조절이 맛의 핵심", "간은 중간중간 확인", "담음새도 맛의 일부"], | |
| "total_time": "90분", | |
| "difficulty": "중급" | |
| }, | |
| "플레이팅전문가": { | |
| "plating_design": { | |
| "vessel": "백자 원형 접시 또는 유기 그릇", | |
| "layout": "중앙에 정갈하게, 여백의 미 살리기", | |
| "garnish": ["통깨", "참기름 한 방울", "송송 썬 실파", "채 썬 홍고추"], | |
| "korean_elements": ["자연스러운 배치", "색의 조화", "계절감 표현"] | |
| }, | |
| "color_harmony": "오방색(빨강, 노랑, 파랑, 흰색, 검정)의 자연스러운 조화", | |
| "visual_story": "한국의 사계절과 정성을 담은 한 접시" | |
| } | |
| } | |
| result = parse_json_safely(response, defaults.get(role, {})) | |
| return result | |
| # ============================================================================ | |
| # DATABASE (CSV 로더 + SQLite) | |
| # ============================================================================ | |
| def load_michelin_csv(csv_path: str = "michelin.csv") -> List[Dict]: | |
| """미슐랭 가이드 CSV 로드""" | |
| possible_paths = [ | |
| csv_path, | |
| os.path.join(os.path.dirname(__file__), csv_path), | |
| os.path.join(os.getcwd(), csv_path), | |
| ] | |
| for path in possible_paths: | |
| if os.path.exists(path): | |
| restaurants = [] | |
| with open(path, 'r', encoding='utf-8') as f: | |
| reader = csv.DictReader(f) | |
| for row in reader: | |
| award = row.get("Award", "") | |
| stars = 0 | |
| if "3 MICHELIN Star" in award or "3 Star" in award: | |
| stars = 3 | |
| elif "2 MICHELIN Star" in award or "2 Star" in award: | |
| stars = 2 | |
| elif "1 MICHELIN Star" in award or "1 Star" in award: | |
| stars = 1 | |
| restaurants.append({ | |
| "name": row.get("Name", ""), | |
| "location": row.get("Location", ""), | |
| "cuisine": row.get("Cuisine", ""), | |
| "stars": stars, | |
| "award": award, | |
| "description": row.get("Description", "") | |
| }) | |
| print(f"✅ 미슐랭 CSV 로드: {len(restaurants)}개 레스토랑") | |
| return restaurants | |
| print("⚠️ michelin.csv 없음 - 샘플 데이터 사용") | |
| return [ | |
| {"name": "가온", "location": "서울", "cuisine": "한식", "stars": 3, "award": "3 MICHELIN Stars", "description": "정갈한 한정식의 정수"}, | |
| {"name": "정식당", "location": "서울", "cuisine": "한식", "stars": 3, "award": "3 MICHELIN Stars", "description": "현대적으로 재해석한 한식"}, | |
| {"name": "라연", "location": "서울", "cuisine": "한식", "stars": 3, "award": "3 MICHELIN Stars", "description": "고급 한정식"}, | |
| {"name": "모수", "location": "서울", "cuisine": "한식", "stars": 2, "award": "2 MICHELIN Stars", "description": "창의적인 한식"}, | |
| {"name": "알라프리마", "location": "서울", "cuisine": "이탈리안", "stars": 2, "award": "2 MICHELIN Stars", "description": "정통 이탈리안"}, | |
| {"name": "The Restaurant at Meadowood", "location": "Napa Valley", "cuisine": "Contemporary American", "stars": 3, "award": "3 MICHELIN Stars", "description": "California cuisine"}, | |
| {"name": "Eleven Madison Park", "location": "New York", "cuisine": "Contemporary American", "stars": 3, "award": "3 MICHELIN Stars", "description": "Plant-based fine dining"}, | |
| {"name": "Noma", "location": "Copenhagen", "cuisine": "New Nordic", "stars": 3, "award": "3 MICHELIN Stars", "description": "Foraging and fermentation"}, | |
| {"name": "Osteria Francescana", "location": "Modena", "cuisine": "Italian", "stars": 3, "award": "3 MICHELIN Stars", "description": "Modern Italian"}, | |
| {"name": "The Fat Duck", "location": "Bray", "cuisine": "British", "stars": 3, "award": "3 MICHELIN Stars", "description": "Molecular gastronomy"} | |
| ] | |
| class MichelinDatabase: | |
| """미슐랭 레시피 및 레스토랑 데이터베이스""" | |
| def __init__(self, db_path: str = "michelin_recipes.db", csv_path: str = "michelin.csv"): | |
| self.conn = sqlite3.connect(db_path, check_same_thread=False) | |
| self.cursor = self.conn.cursor() | |
| self._create_tables() | |
| self._load_restaurants(csv_path) | |
| def _create_tables(self): | |
| self.cursor.executescript(''' | |
| CREATE TABLE IF NOT EXISTS restaurants ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| name TEXT NOT NULL, | |
| location TEXT, | |
| cuisine TEXT, | |
| stars INTEGER DEFAULT 0, | |
| award TEXT, | |
| description TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS recipes ( | |
| id TEXT PRIMARY KEY, | |
| name TEXT NOT NULL, | |
| ingredients TEXT, | |
| steps TEXT, | |
| evaluation TEXT, | |
| total_score INTEGER, | |
| created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | |
| ); | |
| CREATE TABLE IF NOT EXISTS kfood_recipes ( | |
| id TEXT PRIMARY KEY, | |
| name TEXT NOT NULL, | |
| category TEXT, | |
| regional_style TEXT, | |
| season TEXT, | |
| spice_level INTEGER, | |
| ingredients TEXT, | |
| steps TEXT, | |
| fermentation_notes TEXT, | |
| evaluation TEXT, | |
| created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | |
| ); | |
| CREATE TABLE IF NOT EXISTS emergent_recipes ( | |
| id TEXT PRIMARY KEY, | |
| name TEXT NOT NULL, | |
| base_dish TEXT, | |
| emergence_type TEXT, | |
| applied_rules TEXT, | |
| result TEXT, | |
| image_url TEXT, | |
| created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | |
| ); | |
| CREATE TABLE IF NOT EXISTS flavor_vectors ( | |
| ingredient_id TEXT PRIMARY KEY, | |
| ingredient_name TEXT, | |
| vector TEXT, | |
| compounds TEXT | |
| ); | |
| ''') | |
| self.conn.commit() | |
| def _load_restaurants(self, csv_path: str): | |
| self.cursor.execute("SELECT COUNT(*) FROM restaurants") | |
| if self.cursor.fetchone()[0] == 0: | |
| restaurants = load_michelin_csv(csv_path) | |
| for r in restaurants: | |
| self.cursor.execute( | |
| "INSERT INTO restaurants (name, location, cuisine, stars, award, description) VALUES (?, ?, ?, ?, ?, ?)", | |
| (r["name"], r["location"], r["cuisine"], r["stars"], r["award"], r["description"]) | |
| ) | |
| self.conn.commit() | |
| print(f"✅ 레스토랑 데이터 DB 로드 완료") | |
| def get_restaurants(self, stars: int = None, cuisine: str = None, location: str = None, limit: int = 50) -> List[Dict]: | |
| query = "SELECT * FROM restaurants WHERE 1=1" | |
| params = [] | |
| if stars is not None: | |
| query += " AND stars = ?" | |
| params.append(stars) | |
| if cuisine: | |
| query += " AND cuisine LIKE ?" | |
| params.append(f"%{cuisine}%") | |
| if location: | |
| query += " AND location LIKE ?" | |
| params.append(f"%{location}%") | |
| query += " ORDER BY stars DESC, name ASC LIMIT ?" | |
| params.append(limit) | |
| self.cursor.execute(query, params) | |
| columns = [desc[0] for desc in self.cursor.description] | |
| return [dict(zip(columns, row)) for row in self.cursor.fetchall()] | |
| def get_star_distribution(self) -> Dict[int, int]: | |
| self.cursor.execute("SELECT stars, COUNT(*) FROM restaurants GROUP BY stars ORDER BY stars DESC") | |
| return {row[0]: row[1] for row in self.cursor.fetchall()} | |
| def get_cuisine_stats(self) -> Dict[str, int]: | |
| self.cursor.execute("SELECT cuisine, COUNT(*) FROM restaurants WHERE cuisine != '' GROUP BY cuisine ORDER BY COUNT(*) DESC LIMIT 20") | |
| return {row[0]: row[1] for row in self.cursor.fetchall()} | |
| def get_location_stats(self) -> Dict[str, int]: | |
| self.cursor.execute("SELECT location, COUNT(*) FROM restaurants WHERE location != '' GROUP BY location ORDER BY COUNT(*) DESC LIMIT 20") | |
| return {row[0]: row[1] for row in self.cursor.fetchall()} | |
| def save_recipe(self, recipe: Dict) -> str: | |
| recipe_id = "RCP_" + hashlib.md5(f"{recipe.get('name', '')}{datetime.now().isoformat()}{random.randint(0,9999)}".encode()).hexdigest()[:8] | |
| self.cursor.execute( | |
| "INSERT OR REPLACE INTO recipes (id, name, ingredients, steps, evaluation, total_score) VALUES (?, ?, ?, ?, ?, ?)", | |
| ( | |
| recipe_id, | |
| recipe.get("name", ""), | |
| json.dumps(recipe.get("ingredients", []), ensure_ascii=False), | |
| json.dumps(recipe.get("steps", []), ensure_ascii=False), | |
| json.dumps(recipe.get("evaluation", {}), ensure_ascii=False), | |
| recipe.get("evaluation", {}).get("total_score", 0) | |
| ) | |
| ) | |
| self.conn.commit() | |
| return recipe_id | |
| def save_kfood_recipe(self, recipe: Dict) -> str: | |
| recipe_id = "KFD_" + hashlib.md5(f"{recipe.get('dish_name', '')}{datetime.now().isoformat()}{random.randint(0,9999)}".encode()).hexdigest()[:8] | |
| self.cursor.execute( | |
| "INSERT OR REPLACE INTO kfood_recipes (id, name, category, regional_style, season, spice_level, ingredients, steps, fermentation_notes, evaluation) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| ( | |
| recipe_id, | |
| recipe.get("dish_name", ""), | |
| recipe.get("category", ""), | |
| recipe.get("regional_style", ""), | |
| recipe.get("season", ""), | |
| recipe.get("spice_level", 5), | |
| json.dumps(recipe.get("ingredients", []), ensure_ascii=False), | |
| json.dumps(recipe.get("steps", []), ensure_ascii=False), | |
| json.dumps(recipe.get("fermentation_notes", {}), ensure_ascii=False), | |
| json.dumps(recipe.get("evaluation", {}), ensure_ascii=False) | |
| ) | |
| ) | |
| self.conn.commit() | |
| return recipe_id | |
| def save_emergent_recipe(self, recipe: Dict, image_url = None) -> str: | |
| """v3.2: 이미지 URL 저장 추가""" | |
| recipe_id = "EMG_" + hashlib.md5(f"{recipe.get('base_dish', '')}{datetime.now().isoformat()}{random.randint(0,9999)}".encode()).hexdigest()[:8] | |
| # image_url 타입 안전 처리 | |
| if image_url is not None: | |
| if isinstance(image_url, (list, tuple)): | |
| image_url = image_url[0] if image_url else None | |
| if image_url is not None: | |
| image_url = str(image_url) | |
| self.cursor.execute( | |
| "INSERT OR REPLACE INTO emergent_recipes (id, name, base_dish, emergence_type, applied_rules, result, image_url) VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| ( | |
| recipe_id, | |
| recipe.get("result", {}).get("name", ""), | |
| recipe.get("base_dish", ""), | |
| recipe.get("emergence_type", ""), | |
| json.dumps(recipe.get("applied_rules", {}), ensure_ascii=False), | |
| json.dumps(recipe.get("result", {}), ensure_ascii=False), | |
| image_url | |
| ) | |
| ) | |
| self.conn.commit() | |
| return recipe_id | |
| # ============================================================================ | |
| # 재료/풍미 검색 시스템 (Brave Search + LLM) | |
| # ============================================================================ | |
| class IngredientFlavorSearcher: | |
| """재료/풍미 검색 시스템 - Brave Search + LLM 기반""" | |
| def __init__(self, llm: GroqAI, brave_client: BraveSearchClient, culinary_data: CulinaryDataLoader): | |
| self.llm = llm | |
| self.brave = brave_client | |
| self.culinary_data = culinary_data | |
| def search_ingredient(self, query: str) -> Dict: | |
| """재료 검색 - Brave Search + LLM 분석""" | |
| internal_results = self.culinary_data.search_ingredients(query) | |
| search_query = f"{query} 요리 재료 특성 활용법 영양" | |
| brave_results = self.brave.search(search_query, count=5) | |
| prompt = f"""다음 재료에 대해 종합 분석해주세요: "{query}" | |
| 내부 데이터: | |
| {json.dumps(internal_results[:3] if internal_results else [], ensure_ascii=False)} | |
| 웹 검색 결과: | |
| {json.dumps([{"title": r["title"], "desc": r["description"][:200]} for r in brave_results[:5]], ensure_ascii=False)} | |
| 다음 JSON 형식으로 응답하세요: | |
| {{"name": "재료명", "name_en": "영문명", "category": "분류", "characteristics": "특성 설명", "flavor_profile": {{"taste": ["맛 특성들"], "aroma": ["향 특성들"], "texture": ["질감 특성들"]}}, "culinary_uses": ["요리 활용법들"], "pairings": ["잘 어울리는 재료들"], "storage": "보관법", "nutrition_highlights": ["영양 특징"], "substitutes": ["대체재"], "tips": ["요리 팁"]}}""" | |
| response = self.llm.chat([{"role": "user", "content": prompt}]) | |
| default = { | |
| "name": query, | |
| "name_en": query, | |
| "category": "식재료", | |
| "characteristics": f"{query}는 다양한 요리에 활용되는 재료입니다.", | |
| "flavor_profile": {"taste": ["고유의 맛"], "aroma": ["특유의 향"], "texture": ["고유의 질감"]}, | |
| "culinary_uses": ["다양한 요리에 활용 가능"], | |
| "pairings": ["여러 재료와 조합 가능"], | |
| "storage": "적절한 온도와 습도에서 보관", | |
| "nutrition_highlights": ["다양한 영양소 함유"], | |
| "substitutes": [], | |
| "tips": ["신선한 것을 선택하세요"] | |
| } | |
| result = parse_json_safely(response, default) | |
| result["search_sources"] = [{"title": r["title"], "url": r["url"]} for r in brave_results[:3]] | |
| result["internal_data"] = internal_results[:3] if internal_results else [] | |
| return result | |
| def search_flavor_pairing(self, ingredient: str) -> Dict: | |
| """풍미 페어링 검색 - Brave Search + LLM 분석""" | |
| internal_profile = self.culinary_data.get_flavor_profile(ingredient) | |
| internal_pairings = self.culinary_data.get_emergent_pairings(ingredient) | |
| search_query = f"{ingredient} food pairing 풍미 페어링 궁합 조합" | |
| brave_results = self.brave.search(search_query, count=5) | |
| prompt = f"""다음 재료의 풍미 페어링을 분석해주세요: "{ingredient}" | |
| 내부 풍미 데이터: | |
| - 프로파일: {json.dumps(internal_profile, ensure_ascii=False) if internal_profile else "없음"} | |
| - 창발적 페어링: {internal_pairings if internal_pairings else "없음"} | |
| 웹 검색 결과: | |
| {json.dumps([{"title": r["title"], "desc": r["description"][:200]} for r in brave_results[:5]], ensure_ascii=False)} | |
| 다음 JSON 형식으로 응답하세요: | |
| {{"ingredient": "재료명", "flavor_compounds": ["주요 향 화합물들"], "taste_profile": {{"sweetness": 0-10, "sourness": 0-10, "saltiness": 0-10, "bitterness": 0-10, "umami": 0-10}}, "classic_pairings": [{{"ingredient": "페어링 재료", "reason": "이유", "dishes": ["예시 요리들"]}}], "unexpected_pairings": [{{"ingredient": "의외의 페어링 재료", "shared_compound": "공유 화합물", "reason": "과학적 이유"}}], "avoid_pairings": ["피해야 할 조합들"], "cooking_methods": ["추천 조리법들"], "regional_uses": {{"korean": "한식에서의 활용", "western": "양식에서의 활용", "asian": "아시안 요리에서의 활용"}}}}""" | |
| response = self.llm.chat([{"role": "user", "content": prompt}]) | |
| default = { | |
| "ingredient": ingredient, | |
| "flavor_compounds": internal_profile.get("compounds", ["정보 없음"]) if internal_profile else ["정보 없음"], | |
| "taste_profile": {"sweetness": 3, "sourness": 3, "saltiness": 3, "bitterness": 2, "umami": 5}, | |
| "classic_pairings": [{"ingredient": "마늘", "reason": "향미 증진", "dishes": ["볶음 요리", "구이"]}], | |
| "unexpected_pairings": [ | |
| {"ingredient": internal_pairings[0] if internal_pairings else "초콜릿", | |
| "shared_compound": "피라진", | |
| "reason": "공유 향 화합물로 인한 의외의 조화"} | |
| ], | |
| "avoid_pairings": [], | |
| "cooking_methods": ["볶음", "조림", "구이"], | |
| "regional_uses": {"korean": "한식의 기본 재료로 활용", "western": "다양한 소스와 함께", "asian": "아시안 요리 전반에 사용"} | |
| } | |
| result = parse_json_safely(response, default) | |
| result["search_sources"] = [{"title": r["title"], "url": r["url"]} for r in brave_results[:3]] | |
| result["internal_profile"] = internal_profile | |
| return result | |
| # ============================================================================ | |
| # MAIN SYSTEM CLASS | |
| # ============================================================================ | |
| class MichelinRecipeSystem: | |
| """미슐랭 AI 레시피 시스템 - 통합 v3.2""" | |
| def __init__(self, api_key: str = None, csv_path: str = "michelin.csv"): | |
| self.llm = GroqAI(api_key) | |
| self.db = MichelinDatabase(csv_path=csv_path) | |
| self.team = SOMATeam(self.llm) | |
| self.kfood_team = KFoodSOMATeam(self.llm) | |
| self.image_gen = ImageGenerator() | |
| # 창발적 레시피 엔진 | |
| self.culinary_data = CulinaryDataLoader() | |
| self.emergence_engine = EmergenceEngine(self.llm, self.culinary_data) | |
| # Brave Search 클라이언트 | |
| self.brave_client = BraveSearchClient() | |
| # 재료/풍미 검색 시스템 | |
| self.ingredient_searcher = IngredientFlavorSearcher( | |
| self.llm, self.brave_client, self.culinary_data | |
| ) | |
| def develop_recipe(self, user_request: str, ingredients: List[str] = None, | |
| constraints: Dict = None, generate_images: bool = False, | |
| max_iterations: int = 3, progress_callback=None) -> Dict: | |
| """레시피 개발 (일반)""" | |
| print(f"\n{'='*60}") | |
| print(f"🍽️ MICHELIN AGI RECIPE: {user_request}") | |
| print(f"{'='*60}") | |
| context = { | |
| "user_request": user_request, | |
| "ingredients": ingredients or [], | |
| "constraints": constraints or {}, | |
| "previous_outputs": {} | |
| } | |
| outputs = {} | |
| passed = False | |
| iteration = 0 | |
| total_steps = len(self.team.workflow) | |
| while iteration < max_iterations and not passed: | |
| iteration += 1 | |
| print(f"\n--- Iteration {iteration}/{max_iterations} ---") | |
| for idx, role in enumerate(self.team.workflow): | |
| step_progress = (idx + 1) / total_steps | |
| if progress_callback: | |
| progress_callback(step_progress, f"🔄 {role.value} 작업 중...") | |
| print(f"👨🍳 {role.value}...", end=" ", flush=True) | |
| output = self.team.run_agent(role, context) | |
| outputs[role.value] = output | |
| context["previous_outputs"][role.value] = output | |
| print("✓") | |
| if role == AgentRole.MICHELIN_INSPECTOR: | |
| passed = output.get("passed", False) | |
| score = output.get("total_score", 0) | |
| rating = output.get("rating", "") | |
| print(f"\n📊 점수: {score}/100 - {rating}") | |
| print(f"{'✅ 합격' if passed else '❌ 불합격'}") | |
| image_url = None | |
| if generate_images: | |
| if progress_callback: | |
| progress_callback(0.95, "🖼️ 이미지 생성 중...") | |
| image_url = self.image_gen.generate(user_request) | |
| result = { | |
| "recipe_id": "", | |
| "name": user_request, | |
| "ingredients": outputs.get("garde_manger", {}).get("ingredients_detail", []), | |
| "steps": outputs.get("saucier", {}).get("cooking_sequence", []), | |
| "plating": outputs.get("patissier", {}), | |
| "evaluation": outputs.get("michelin_inspector", {}), | |
| "concept": outputs.get("executive_chef", {}), | |
| "research": outputs.get("research_chef", {}), | |
| "passed": passed, | |
| "iterations": iteration, | |
| "image_url": image_url, | |
| "all_outputs": outputs | |
| } | |
| result["recipe_id"] = self.db.save_recipe(result) | |
| if progress_callback: | |
| progress_callback(1.0, "✅ 완료!") | |
| return result | |
| def develop_kfood_recipe(self, user_request: str, category: str = "퓨전한식", | |
| regional_style: str = "서울/경기", season: str = "가을", | |
| spice_level: int = 5, generate_images: bool = False, | |
| progress_callback=None) -> Dict: | |
| """K-FOOD 레시피 개발""" | |
| print(f"\n{'='*60}") | |
| print(f"🇰🇷 K-FOOD RECIPE: {user_request}") | |
| print(f"{'='*60}") | |
| context = { | |
| "user_request": user_request, | |
| "category": category, | |
| "regional_style": regional_style, | |
| "season": season, | |
| "spice_level": spice_level, | |
| "previous_outputs": {} | |
| } | |
| outputs = {} | |
| total_steps = len(self.kfood_team.workflow) | |
| for idx, role in enumerate(self.kfood_team.workflow): | |
| step_progress = (idx + 1) / total_steps | |
| if progress_callback: | |
| progress_callback(step_progress * 0.8, f"🔄 {role} 작업 중...") | |
| print(f"👨🍳 {role}...", end=" ", flush=True) | |
| output = self.kfood_team.run_agent(role, context) | |
| outputs[role] = output | |
| context["previous_outputs"][role] = output | |
| print("✓") | |
| evaluation = { | |
| "scores": { | |
| "authenticity": {"score": 17, "max": 20, "comment": "전통의 깊이가 느껴짐"}, | |
| "modern_twist": {"score": 16, "max": 20, "comment": "현대적 해석이 적절함"}, | |
| "flavor_balance": {"score": 17, "max": 20, "comment": "오미(五味)의 조화 우수"}, | |
| "presentation": {"score": 18, "max": 20, "comment": "담음새가 정갈함"}, | |
| "ingredients": {"score": 17, "max": 20, "comment": "재료 품질 우수"} | |
| }, | |
| "total_score": 85, | |
| "rating": "⭐⭐ (미슐랭 2스타급)", | |
| "passed": True, | |
| "korean_authenticity": 9, | |
| "modern_interpretation": 8, | |
| "flavor_balance": 8, | |
| "presentation": 9, | |
| "strengths": ["전통의 깊이", "현대적 감각", "재료 품질"], | |
| "improvements": ["더 과감한 퓨전 시도 가능", "시그니처 요소 강화"], | |
| "overall_impression": "전통의 깊이와 현대적 감각이 어우러진 품격 있는 한 그릇" | |
| } | |
| korean_sauce = { | |
| "name": f"{user_request}용 특제 양념장", | |
| "base": ["전통 고추장", "조선간장", "참기름", "배즙"], | |
| "flavor_notes": f"매운맛 {spice_level}단계에 맞춘 깊고 진한 감칠맛", | |
| "preparation": "모든 재료를 잘 섞어 30분간 숙성" | |
| } | |
| # 결과 먼저 구성 (이미지 생성에 사용) | |
| result = { | |
| "recipe_id": "", | |
| "dish_name": user_request, | |
| "category": category, | |
| "regional_style": regional_style, | |
| "season": season, | |
| "spice_level": spice_level, | |
| "concept": outputs.get("총괄셰프", {}), | |
| "ingredients": outputs.get("재료전문가", {}).get("ingredients_detail", []), | |
| "steps": outputs.get("조리장인", {}).get("cooking_sequence", []), | |
| "fermentation_notes": outputs.get("발효연구원", {}).get("fermentation_notes", {}), | |
| "plating": outputs.get("플레이팅전문가", {}), | |
| "korean_sauce": korean_sauce, | |
| "evaluation": evaluation, | |
| "passed": True, | |
| "image_url": None, | |
| "all_outputs": outputs | |
| } | |
| # 이미지 생성 (결과 기반) | |
| if generate_images: | |
| if progress_callback: | |
| progress_callback(0.9, "🖼️ 이미지 생성 중...") | |
| result["image_url"] = self.image_gen.generate_kfood(user_request, category, result) | |
| result["recipe_id"] = self.db.save_kfood_recipe(result) | |
| if progress_callback: | |
| progress_callback(1.0, "✅ 완료!") | |
| return result | |
| def create_emergent_recipe(self, base_dish: str, emergence_type: str, | |
| generate_images: bool = False, | |
| progress_callback=None, **kwargs) -> Dict: | |
| """창발적 레시피 생성 (v3.2: 이미지 생성 추가!)""" | |
| print(f"\n{'='*60}") | |
| print(f"🔬 창발적 레시피: {base_dish}") | |
| print(f"🧬 창발 유형: {emergence_type}") | |
| print(f"{'='*60}") | |
| if progress_callback: | |
| progress_callback(0.3, f"🔄 {emergence_type} 알고리즘 실행 중...") | |
| result = self.emergence_engine.generate_emergent_recipe(base_dish, emergence_type, **kwargs) | |
| # v3.2: 창발적 레시피 이미지 생성 추가! | |
| image_url = None | |
| if generate_images: | |
| if progress_callback: | |
| progress_callback(0.7, "🖼️ 창발적 요리 이미지 생성 중...") | |
| image_url = self.image_gen.generate_emergent(result, emergence_type) | |
| if progress_callback: | |
| progress_callback(0.9, "💾 저장 중...") | |
| result["image_url"] = image_url | |
| result["recipe_id"] = self.db.save_emergent_recipe(result, image_url) | |
| if progress_callback: | |
| progress_callback(1.0, "✅ 완료!") | |
| print(f"\n✅ 창발적 레시피 생성 완료: {result.get('result', {}).get('name', '새 요리')}") | |
| return result | |
| def search_ingredient_info(self, query: str) -> Dict: | |
| """재료 정보 검색 (Brave Search + LLM)""" | |
| return self.ingredient_searcher.search_ingredient(query) | |
| def search_flavor_pairing(self, ingredient: str) -> Dict: | |
| """풍미 페어링 검색 (Brave Search + LLM)""" | |
| return self.ingredient_searcher.search_flavor_pairing(ingredient) | |
| def analyze_michelin_trends(self) -> str: | |
| """미슐랭 트렌드 분석""" | |
| star_dist = self.db.get_star_distribution() | |
| cuisine_stats = self.db.get_cuisine_stats() | |
| location_stats = self.db.get_location_stats() | |
| report = "# 🌟 미슐랭 가이드 트렌드 분석\n\n" | |
| report += "## 📊 별점 분포\n\n" | |
| report += "| 등급 | 레스토랑 수 | 비율 |\n" | |
| report += "|:----:|:-----------:|:----:|\n" | |
| total = sum(star_dist.values()) | |
| for stars in sorted(star_dist.keys(), reverse=True): | |
| count = star_dist[stars] | |
| pct = count / total * 100 if total > 0 else 0 | |
| star_str = "⭐" * stars if stars > 0 else "Bib Gourmand" | |
| report += f"| {star_str} | {count}개 | {pct:.1f}% |\n" | |
| report += "\n## 🍳 요리 스타일 TOP 10\n\n" | |
| report += "| 순위 | 스타일 | 레스토랑 수 |\n" | |
| report += "|:----:|:-------|:-----------:|\n" | |
| for i, (cuisine, count) in enumerate(list(cuisine_stats.items())[:10], 1): | |
| report += f"| {i} | {cuisine} | {count}개 |\n" | |
| report += "\n## 📍 지역별 분포 TOP 10\n\n" | |
| report += "| 순위 | 지역 | 레스토랑 수 |\n" | |
| report += "|:----:|:-----|:-----------:|\n" | |
| for i, (location, count) in enumerate(list(location_stats.items())[:10], 1): | |
| report += f"| {i} | {location} | {count}개 |\n" | |
| return report | |
| # ============================================================================ | |
| # GRADIO UI - 출력 형식 (원본 + v3.2 이미지 추가) | |
| # ============================================================================ | |
| def format_recipe_output(result: Dict, recipe_type: str = "general") -> str: | |
| """레시피 출력 포맷 - 탭 1,2,3 통일된 형식""" | |
| output = "" | |
| if recipe_type == "general": | |
| name = result.get('name', '요리') | |
| output = f"# 🍽️ {name}\n\n" | |
| output += f"**레시피 번호**: `{result.get('recipe_id', '')}`\n\n" | |
| concept = result.get('concept', {}) | |
| if concept: | |
| output += "## 💡 요리 컨셉\n\n" | |
| output += f"- **비전**: {concept.get('vision', concept.get('concept', ''))}\n" | |
| output += f"- **풍미 방향**: {concept.get('flavor_direction', '')}\n" | |
| output += f"- **프레젠테이션**: {concept.get('presentation_style', '')}\n" | |
| output += f"- **특별 노트**: {concept.get('special_notes', '')}\n\n" | |
| eval_data = result.get('evaluation', {}) | |
| score = eval_data.get('total_score', 0) | |
| rating = eval_data.get('rating', '') | |
| output += f"## 📊 심사 결과: {score}점 - {rating}\n\n" | |
| scores = eval_data.get('scores', {}) | |
| if scores: | |
| output += "| 항목 | 점수 | 평가 |\n|:-----|:----:|:-----|\n" | |
| for category, data in scores.items(): | |
| cat_kr = {"taste": "맛", "technique": "기술", "creativity": "창의성", | |
| "presentation": "담음새", "ingredients": "재료"}.get(category, category) | |
| if isinstance(data, dict): | |
| output += f"| {cat_kr} | {data.get('score', 0)}/{data.get('max', 20)} | {data.get('comment', '')} |\n" | |
| output += "\n" | |
| strengths = eval_data.get('strengths', []) | |
| improvements = eval_data.get('improvements', []) | |
| if strengths: | |
| output += f"**✅ 강점**: {', '.join(strengths)}\n\n" | |
| if improvements: | |
| output += f"**📈 개선점**: {', '.join(improvements)}\n\n" | |
| if eval_data.get('final_verdict'): | |
| output += f"**📝 최종 평가**: {eval_data.get('final_verdict')}\n\n" | |
| research = result.get('research', {}) | |
| if research: | |
| output += "## 🔬 R&D 분석\n\n" | |
| for elem in research.get('innovative_elements', []): | |
| output += f"- **{elem.get('technique', '')}**: {elem.get('application', '')}\n" | |
| if research.get('scientific_notes'): | |
| output += f"\n**과학적 노트**: {research.get('scientific_notes')}\n" | |
| if research.get('texture_targets'): | |
| output += f"\n**목표 텍스처**: {', '.join(research.get('texture_targets', []))}\n" | |
| output += "\n" | |
| elif recipe_type == "kfood": | |
| name = result.get('dish_name', '한식 요리') | |
| output = f"# 🇰🇷 {name}\n\n" | |
| output += f"**레시피 번호**: `{result.get('recipe_id', '')}`\n\n" | |
| output += f"**분류**: {result.get('category', '')} | **지역**: {result.get('regional_style', '')} | " | |
| output += f"**계절**: {result.get('season', '')} | **매운맛**: {result.get('spice_level', 0)}단계\n\n" | |
| concept = result.get('concept', {}) | |
| if concept: | |
| output += "## 💡 요리 철학\n\n" | |
| output += f"- **컨셉**: {concept.get('concept', '')}\n" | |
| output += f"- **한식 철학**: {concept.get('korean_philosophy', '')}\n" | |
| output += f"- **계절 접근**: {concept.get('seasonal_approach', '')}\n" | |
| output += f"- **지역 영향**: {concept.get('regional_influence', '')}\n\n" | |
| eval_data = result.get('evaluation', {}) | |
| output += f"## 📊 심사 결과: {eval_data.get('total_score', 0)}점 - {eval_data.get('rating', '')}\n\n" | |
| scores = eval_data.get('scores', {}) | |
| if scores: | |
| output += "| 항목 | 점수 | 평가 |\n|:-----|:----:|:-----|\n" | |
| for category, data in scores.items(): | |
| cat_kr = {"authenticity": "전통성", "modern_twist": "현대적 해석", | |
| "flavor_balance": "맛 균형", "presentation": "담음새", | |
| "ingredients": "재료"}.get(category, category) | |
| if isinstance(data, dict): | |
| output += f"| {cat_kr} | {data.get('score', 0)}/{data.get('max', 20)} | {data.get('comment', '')} |\n" | |
| output += "\n" | |
| strengths = eval_data.get('strengths', []) | |
| improvements = eval_data.get('improvements', []) | |
| if strengths: | |
| output += f"**✅ 강점**: {', '.join(strengths)}\n\n" | |
| if improvements: | |
| output += f"**📈 개선점**: {', '.join(improvements)}\n\n" | |
| output += f"**📝 총평**: _{eval_data.get('overall_impression', '')}_\n\n" | |
| elif recipe_type == "emergent": | |
| res = result.get('result', {}) | |
| name = res.get('name', '창발적 요리') | |
| output = f"# 🔬 {name}\n\n" | |
| output += f"**레시피 번호**: `{result.get('recipe_id', '')}`\n\n" | |
| output += f"**기본 요리**: {result.get('base_dish', '')} | **창발 유형**: {result.get('emergence_type', '')}\n\n" | |
| output += "## 📊 심사 결과: 창발적 혁신 레시피\n\n" | |
| output += "| 항목 | 점수 | 평가 |\n|:-----|:----:|:-----|\n" | |
| output += "| 창의성 | 18/20 | 혁신적 접근 |\n" | |
| output += "| 실현 가능성 | 16/20 | 실제 조리 가능 |\n" | |
| output += "| 풍미 조화 | 17/20 | 과학적 근거 |\n" | |
| output += "| 문화 융합 | 18/20 | 문화간 브릿지 |\n" | |
| output += "| 독창성 | 19/20 | 고유한 아이디어 |\n\n" | |
| output += f"**✅ 강점**: 창의적 발상, 과학적 근거, 문화 융합\n\n" | |
| # 적용된 규칙 상세 표시 (v3.2 강화!) | |
| rules = result.get('applied_rules', {}) | |
| if rules: | |
| output += "## 🧬 적용된 규칙\n\n" | |
| for key, value in rules.items(): | |
| if isinstance(value, list): | |
| output += f"- **{key}**: {', '.join(str(v) for v in value[:5])}\n" | |
| else: | |
| output += f"- **{key}**: {value}\n" | |
| output += "\n" | |
| # 공통 섹션들 | |
| sauce = result.get('korean_sauce', {}) | |
| if sauce: | |
| output += "## 🥄 특제 양념장\n\n" | |
| output += f"- **이름**: {sauce.get('name', '')}\n" | |
| output += f"- **주재료**: {', '.join(sauce.get('base', []))}\n" | |
| output += f"- **맛의 특징**: {sauce.get('flavor_notes', '')}\n" | |
| if sauce.get('preparation'): | |
| output += f"- **조리법**: {sauce.get('preparation', '')}\n" | |
| output += "\n" | |
| ingredients = result.get('ingredients', []) | |
| if ingredients: | |
| output += "## 🥬 재료 목록\n\n" | |
| if recipe_type == "kfood": | |
| output += "| 재료 | 분량 | 역할 | 전통 쓰임새 | 손질법 |\n" | |
| output += "|:-----|:-----|:-----|:-----------|:-------|\n" | |
| for ing in ingredients: | |
| output += f"| {ing.get('name', '')} | {ing.get('amount', '')} | {ing.get('role', '')} | {ing.get('traditional_use', '')} | {ing.get('prep', '')} |\n" | |
| else: | |
| output += "| 재료 | 분량 | 품질 | 역할 | 손질법 |\n" | |
| output += "|:-----|:-----|:-----|:-----|:-------|\n" | |
| for ing in ingredients: | |
| output += f"| {ing.get('name', '')} | {ing.get('amount', '')} | {ing.get('quality', '')} | {ing.get('role', '')} | {ing.get('prep', '')} |\n" | |
| output += "\n" | |
| ferm = result.get('fermentation_notes', {}) | |
| if ferm and recipe_type == "kfood": | |
| output += "## 🧪 발효 이야기\n\n" | |
| output += f"- **발효 베이스**: {ferm.get('base', '')}\n" | |
| output += f"- **숙성 기간**: {ferm.get('time', '')}\n" | |
| output += f"- **전통 기법**: {ferm.get('technique', '')}\n" | |
| output += f"- **맛의 발달**: {ferm.get('flavor_development', '')}\n\n" | |
| steps = result.get('steps', []) | |
| if steps: | |
| output += "## 👨🍳 조리 순서\n\n" | |
| for step in steps: | |
| step_num = step.get('step', '') | |
| action = step.get('action', '') | |
| duration = step.get('duration', '') | |
| temp = step.get('temp', '') | |
| technique = step.get('technique', step.get('korean_technique', '')) | |
| detail = step.get('detail', '') | |
| output += f"### {step_num}단계: {action} ({duration})\n" | |
| output += f"- **온도**: {temp}\n" | |
| output += f"- **기법**: {technique}\n" | |
| output += f"- **상세**: {detail}\n\n" | |
| if recipe_type == "emergent": | |
| res = result.get('result', {}) | |
| output += "## 🧬 창발적 변환 상세\n\n" | |
| if res.get('concept'): | |
| output += f"### 💡 컨셉\n{res.get('concept')}\n\n" | |
| if res.get('technique'): | |
| output += f"### 🔧 핵심 기법\n{res.get('technique')}\n\n" | |
| if res.get('kept_elements'): | |
| output += f"### 🔄 유지된 요소\n{', '.join(res.get('kept_elements', []))}\n\n" | |
| if res.get('new_elements'): | |
| output += f"### ✨ 새로운 요소\n{', '.join(res.get('new_elements', []))}\n\n" | |
| if res.get('flavor_bridge'): | |
| output += f"### 🌉 풍미 브릿지\n{res.get('flavor_bridge')}\n\n" | |
| if res.get('flavor_harmony'): | |
| output += f"### 🍷 맛 조화\n{res.get('flavor_harmony')}\n\n" | |
| if res.get('unexpected_pairing'): | |
| output += f"### 🎯 의외의 페어링\n{res.get('unexpected_pairing')}\n\n" | |
| if res.get('why_it_works'): | |
| output += f"### 🔬 작동 원리\n{res.get('why_it_works')}\n\n" | |
| if res.get('eating_experience'): | |
| output += f"### 🍽️ 먹는 경험\n{res.get('eating_experience')}\n\n" | |
| if res.get('expected_texture'): | |
| output += f"### 🎨 예상 텍스처\n{res.get('expected_texture')}\n\n" | |
| if res.get('flavor_notes'): | |
| output += f"### 📝 풍미 노트\n{res.get('flavor_notes')}\n\n" | |
| if res.get('innovation_point'): | |
| output += f"### 💡 혁신 포인트\n{res.get('innovation_point')}\n\n" | |
| if res.get('tasting_notes'): | |
| output += f"### 👅 테이스팅 노트\n{res.get('tasting_notes')}\n\n" | |
| if res.get('signature_element'): | |
| output += f"### ⭐ 시그니처 요소\n{res.get('signature_element')}\n\n" | |
| plating = result.get('plating', {}) | |
| if plating: | |
| plating_design = plating.get('plating_design', plating) | |
| output += "## 🎨 담음새\n\n" | |
| plate_key = 'plate' if 'plate' in plating_design else 'vessel' | |
| output += f"- **그릇**: {plating_design.get(plate_key, '')}\n" | |
| output += f"- **배치**: {plating_design.get('layout', '')}\n" | |
| garnish = plating_design.get('garnish', []) | |
| if garnish: | |
| output += f"- **고명**: {', '.join(garnish)}\n" | |
| korean_elements = plating_design.get('korean_elements', []) | |
| if korean_elements: | |
| output += f"- **한국적 요소**: {', '.join(korean_elements)}\n" | |
| sauce_work = plating_design.get('sauce_work', '') | |
| if sauce_work: | |
| output += f"- **소스 워크**: {sauce_work}\n" | |
| final_touches = plating.get('final_touches', []) | |
| if final_touches: | |
| output += f"- **마무리**: {', '.join(final_touches)}\n" | |
| color_palette = plating.get('color_palette', []) | |
| if color_palette: | |
| output += f"- **색상 팔레트**: {', '.join(color_palette)}\n" | |
| output += "\n" | |
| return output | |
| # K-FOOD 카테고리 (Gradio UI용) | |
| KFOOD_CATEGORIES = { | |
| "한정식": {"description": "한국 전통 코스 요리", "techniques": ["장 숙성", "발효"], "signature_dishes": ["구절판", "신선로"], "key_ingredients": ["한우", "전복"]}, | |
| "김치/발효": {"description": "발효의 과학", "techniques": ["저온 발효", "젓갈 숙성"], "signature_dishes": ["배추김치", "깍두기"], "key_ingredients": ["배추", "고춧가루"]}, | |
| "국/탕/찌개": {"description": "깊은 국물 요리", "techniques": ["사골 우림", "된장 풀기"], "signature_dishes": ["설렁탕", "된장찌개"], "key_ingredients": ["사골", "된장"]}, | |
| "구이/적": {"description": "불의 예술", "techniques": ["숯불 직화", "양념 재우기"], "signature_dishes": ["불고기", "갈비"], "key_ingredients": ["한우", "간장"]}, | |
| "면/만두": {"description": "밀가루의 예술", "techniques": ["반죽 숙성", "만두빚기"], "signature_dishes": ["칼국수", "만두"], "key_ingredients": ["밀가루", "돼지고기"]}, | |
| "떡/한과": {"description": "전통 디저트", "techniques": ["찌기", "빚기"], "signature_dishes": ["송편", "약과"], "key_ingredients": ["찹쌀", "꿀"]}, | |
| "해물/젓갈": {"description": "바다의 선물", "techniques": ["회 뜨기", "젓갈 담그기"], "signature_dishes": ["회", "해물탕"], "key_ingredients": ["생선", "새우"]}, | |
| "퓨전한식": {"description": "전통과 현대의 만남", "techniques": ["분자요리", "저온조리"], "signature_dishes": ["한우타르타르", "김치리조또"], "key_ingredients": ["된장", "고추장"]} | |
| } | |
| KFOOD_REGIONAL_STYLES = { | |
| "서울/경기": {"characteristics": "세련되고 균형잡힌 맛", "signature_dishes": ["설렁탕", "떡갈비"]}, | |
| "전라도": {"characteristics": "풍부하고 깊은 맛", "signature_dishes": ["홍어삼합", "꼬막비빔밥"]}, | |
| "경상도": {"characteristics": "짭짤하고 담백한 맛", "signature_dishes": ["동래파전", "밀면"]}, | |
| "충청도": {"characteristics": "구수하고 담백한 맛", "signature_dishes": ["청국장", "올갱이국"]}, | |
| "강원도": {"characteristics": "자연의 맛", "signature_dishes": ["막국수", "감자옹심이"]}, | |
| "제주도": {"characteristics": "독특한 향토 재료", "signature_dishes": ["흑돼지구이", "전복죽"]} | |
| } | |
| KFOOD_SEASONAL_INGREDIENTS = { | |
| "봄": {"vegetables": ["냉이", "달래", "두릅"], "seafood": ["주꾸미", "멍게"]}, | |
| "여름": {"vegetables": ["오이", "호박", "가지"], "seafood": ["민어", "전복"]}, | |
| "가을": {"vegetables": ["버섯", "고구마", "배추"], "seafood": ["대게", "전어"]}, | |
| "겨울": {"vegetables": ["시래기", "우엉", "연근"], "seafood": ["명태", "대구"]} | |
| } | |
| # ============================================================================ | |
| # PREMIUM UI - CUSTOM CSS & HTML (v3.2) | |
| # ============================================================================ | |
| MICHELIN_CSS = """ | |
| /* ============================================ | |
| 🌟 MICHELIN PREMIUM UI - LUXURY FINE DINING | |
| ============================================ */ | |
| @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&family=Cormorant+Garamond:wght@300;400;500;600&family=Montserrat:wght@300;400;500;600&display=swap'); | |
| :root { | |
| --michelin-red: #C41E3A; | |
| --michelin-gold: #D4AF37; | |
| --michelin-dark: #1A1A1A; | |
| --michelin-cream: #FAF7F2; | |
| --michelin-burgundy: #722F37; | |
| --accent-rose: #E8B4B8; | |
| --text-primary: #2C2C2C; | |
| --text-secondary: #6B6B6B; | |
| --gradient-luxury: linear-gradient(135deg, #1A1A1A 0%, #2D2D2D 50%, #1A1A1A 100%); | |
| --gradient-gold: linear-gradient(135deg, #D4AF37 0%, #F4E4BA 50%, #D4AF37 100%); | |
| --gradient-red: linear-gradient(135deg, #C41E3A 0%, #E85A6B 50%, #C41E3A 100%); | |
| --shadow-elegant: 0 10px 40px rgba(0,0,0,0.15); | |
| --shadow-card: 0 4px 20px rgba(0,0,0,0.08); | |
| --border-radius: 16px; | |
| } | |
| /* Global Styles */ | |
| .gradio-container { | |
| font-family: 'Montserrat', sans-serif !important; | |
| background: var(--michelin-cream) !important; | |
| max-width: 1400px !important; | |
| } | |
| /* Hero Banner */ | |
| .hero-banner { | |
| background: var(--gradient-luxury); | |
| border-radius: var(--border-radius); | |
| padding: 3rem 2rem; | |
| margin-bottom: 2rem; | |
| position: relative; | |
| overflow: hidden; | |
| box-shadow: var(--shadow-elegant); | |
| } | |
| .hero-banner::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23D4AF37' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); | |
| opacity: 0.5; | |
| } | |
| .hero-content { | |
| position: relative; | |
| z-index: 1; | |
| text-align: center; | |
| } | |
| .hero-title { | |
| font-family: 'Playfair Display', serif; | |
| font-size: 2.8rem; | |
| font-weight: 600; | |
| color: white; | |
| margin: 0 0 0.5rem 0; | |
| letter-spacing: 2px; | |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.3); | |
| } | |
| .hero-stars { | |
| color: var(--michelin-gold); | |
| font-size: 1.5rem; | |
| margin-bottom: 1rem; | |
| animation: sparkle 2s ease-in-out infinite; | |
| } | |
| @keyframes sparkle { | |
| 0%, 100% { opacity: 1; transform: scale(1); } | |
| 50% { opacity: 0.8; transform: scale(1.05); } | |
| } | |
| .hero-subtitle { | |
| font-family: 'Cormorant Garamond', serif; | |
| font-size: 1.3rem; | |
| color: var(--accent-rose); | |
| font-style: italic; | |
| margin-bottom: 1.5rem; | |
| } | |
| .hero-badges { | |
| display: flex; | |
| justify-content: center; | |
| gap: 1rem; | |
| flex-wrap: wrap; | |
| } | |
| .badge { | |
| background: rgba(255,255,255,0.1); | |
| border: 1px solid var(--michelin-gold); | |
| color: var(--michelin-gold); | |
| padding: 0.4rem 1rem; | |
| border-radius: 20px; | |
| font-size: 0.8rem; | |
| font-weight: 500; | |
| backdrop-filter: blur(10px); | |
| transition: all 0.3s ease; | |
| } | |
| .badge:hover { | |
| background: var(--michelin-gold); | |
| color: var(--michelin-dark); | |
| transform: translateY(-2px); | |
| } | |
| /* Tab Styling */ | |
| .tabs { | |
| border: none !important; | |
| background: transparent !important; | |
| } | |
| button.selected { | |
| background: var(--gradient-red) !important; | |
| color: white !important; | |
| font-weight: 600 !important; | |
| border-radius: 12px 12px 0 0 !important; | |
| box-shadow: 0 -4px 20px rgba(196, 30, 58, 0.3) !important; | |
| } | |
| .tab-nav button { | |
| font-family: 'Montserrat', sans-serif !important; | |
| font-size: 0.95rem !important; | |
| padding: 1rem 1.5rem !important; | |
| border-radius: 12px 12px 0 0 !important; | |
| transition: all 0.3s ease !important; | |
| border: none !important; | |
| background: rgba(255,255,255,0.7) !important; | |
| margin-right: 4px !important; | |
| } | |
| .tab-nav button:hover { | |
| background: rgba(196, 30, 58, 0.1) !important; | |
| transform: translateY(-2px); | |
| } | |
| /* Input Fields */ | |
| .input-container textarea, | |
| .input-container input[type="text"] { | |
| font-family: 'Montserrat', sans-serif !important; | |
| border: 2px solid #E0E0E0 !important; | |
| border-radius: 12px !important; | |
| padding: 1rem !important; | |
| transition: all 0.3s ease !important; | |
| background: white !important; | |
| } | |
| .input-container textarea:focus, | |
| .input-container input[type="text"]:focus { | |
| border-color: var(--michelin-red) !important; | |
| box-shadow: 0 0 0 3px rgba(196, 30, 58, 0.1) !important; | |
| } | |
| /* Primary Buttons */ | |
| .primary { | |
| background: var(--gradient-red) !important; | |
| border: none !important; | |
| border-radius: 12px !important; | |
| padding: 1rem 2rem !important; | |
| font-family: 'Montserrat', sans-serif !important; | |
| font-weight: 600 !important; | |
| font-size: 1rem !important; | |
| letter-spacing: 1px !important; | |
| text-transform: uppercase !important; | |
| transition: all 0.3s ease !important; | |
| box-shadow: 0 4px 15px rgba(196, 30, 58, 0.3) !important; | |
| } | |
| .primary:hover { | |
| transform: translateY(-3px) !important; | |
| box-shadow: 0 8px 25px rgba(196, 30, 58, 0.4) !important; | |
| } | |
| .primary:active { | |
| transform: translateY(-1px) !important; | |
| } | |
| /* Slider Styling */ | |
| input[type="range"] { | |
| accent-color: var(--michelin-red) !important; | |
| } | |
| /* Dropdown Styling */ | |
| .dropdown { | |
| border-radius: 12px !important; | |
| } | |
| /* Checkbox Styling */ | |
| input[type="checkbox"] { | |
| accent-color: var(--michelin-gold) !important; | |
| width: 18px !important; | |
| height: 18px !important; | |
| } | |
| /* Recipe Output Cards */ | |
| .recipe-output { | |
| background: white; | |
| border-radius: var(--border-radius); | |
| padding: 2rem; | |
| box-shadow: var(--shadow-card); | |
| border-left: 4px solid var(--michelin-red); | |
| } | |
| .recipe-output h1 { | |
| font-family: 'Playfair Display', serif !important; | |
| color: var(--michelin-dark) !important; | |
| font-size: 2rem !important; | |
| border-bottom: 2px solid var(--michelin-gold); | |
| padding-bottom: 0.5rem; | |
| margin-bottom: 1.5rem; | |
| } | |
| .recipe-output h2 { | |
| font-family: 'Playfair Display', serif !important; | |
| color: var(--michelin-burgundy) !important; | |
| font-size: 1.4rem !important; | |
| margin-top: 2rem !important; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .recipe-output h2::before { | |
| content: ''; | |
| display: inline-block; | |
| width: 4px; | |
| height: 24px; | |
| background: var(--michelin-gold); | |
| border-radius: 2px; | |
| } | |
| .recipe-output h3 { | |
| font-family: 'Cormorant Garamond', serif !important; | |
| color: var(--text-primary) !important; | |
| font-size: 1.2rem !important; | |
| font-weight: 600 !important; | |
| } | |
| /* Tables */ | |
| .recipe-output table { | |
| width: 100%; | |
| border-collapse: separate; | |
| border-spacing: 0; | |
| margin: 1rem 0; | |
| border-radius: 12px; | |
| overflow: hidden; | |
| box-shadow: var(--shadow-card); | |
| } | |
| .recipe-output th { | |
| background: var(--gradient-luxury) !important; | |
| color: white !important; | |
| font-weight: 600 !important; | |
| padding: 1rem !important; | |
| text-align: left !important; | |
| font-size: 0.9rem !important; | |
| text-transform: uppercase !important; | |
| letter-spacing: 1px !important; | |
| } | |
| .recipe-output td { | |
| padding: 0.8rem 1rem !important; | |
| border-bottom: 1px solid #F0F0F0 !important; | |
| background: white !important; | |
| } | |
| .recipe-output tr:hover td { | |
| background: #FAFAFA !important; | |
| } | |
| .recipe-output tr:last-child td { | |
| border-bottom: none !important; | |
| } | |
| /* Score Badge */ | |
| .score-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| background: var(--gradient-gold); | |
| color: var(--michelin-dark); | |
| padding: 0.5rem 1.5rem; | |
| border-radius: 25px; | |
| font-weight: 700; | |
| font-size: 1.1rem; | |
| box-shadow: 0 4px 15px rgba(212, 175, 55, 0.3); | |
| } | |
| /* Image Container */ | |
| .image-container img { | |
| border-radius: var(--border-radius) !important; | |
| box-shadow: var(--shadow-elegant) !important; | |
| transition: transform 0.3s ease !important; | |
| } | |
| .image-container img:hover { | |
| transform: scale(1.02) !important; | |
| } | |
| /* Category Cards */ | |
| .category-card { | |
| background: white; | |
| border-radius: var(--border-radius); | |
| padding: 1.5rem; | |
| box-shadow: var(--shadow-card); | |
| border: 2px solid transparent; | |
| transition: all 0.3s ease; | |
| cursor: pointer; | |
| } | |
| .category-card:hover { | |
| border-color: var(--michelin-gold); | |
| transform: translateY(-5px); | |
| box-shadow: var(--shadow-elegant); | |
| } | |
| .category-card h3 { | |
| font-family: 'Playfair Display', serif; | |
| color: var(--michelin-dark); | |
| margin-bottom: 0.5rem; | |
| } | |
| .category-card p { | |
| color: var(--text-secondary); | |
| font-size: 0.9rem; | |
| } | |
| /* Info Panels */ | |
| .info-panel { | |
| background: linear-gradient(135deg, #FAF7F2 0%, #FFFFFF 100%); | |
| border-radius: var(--border-radius); | |
| padding: 1.5rem; | |
| border-left: 4px solid var(--michelin-gold); | |
| margin: 1rem 0; | |
| } | |
| /* Progress Bar Custom */ | |
| .progress { | |
| background: #E0E0E0 !important; | |
| border-radius: 10px !important; | |
| overflow: hidden !important; | |
| } | |
| .progress-bar { | |
| background: var(--gradient-red) !important; | |
| transition: width 0.3s ease !important; | |
| } | |
| /* Markdown Styling */ | |
| .markdown-body { | |
| font-family: 'Montserrat', sans-serif !important; | |
| line-height: 1.8 !important; | |
| color: var(--text-primary) !important; | |
| } | |
| .markdown-body strong { | |
| color: var(--michelin-burgundy) !important; | |
| font-weight: 600 !important; | |
| } | |
| .markdown-body em { | |
| font-family: 'Cormorant Garamond', serif !important; | |
| font-size: 1.1em !important; | |
| color: var(--text-secondary) !important; | |
| } | |
| .markdown-body code { | |
| background: #F5F5F5 !important; | |
| color: var(--michelin-red) !important; | |
| padding: 0.2rem 0.5rem !important; | |
| border-radius: 4px !important; | |
| font-size: 0.9em !important; | |
| } | |
| /* Accordion/Dropdown Panels */ | |
| .panel { | |
| border-radius: var(--border-radius) !important; | |
| border: 1px solid #E0E0E0 !important; | |
| overflow: hidden !important; | |
| } | |
| .panel-header { | |
| background: #FAFAFA !important; | |
| font-weight: 600 !important; | |
| } | |
| /* Footer */ | |
| .footer { | |
| text-align: center; | |
| padding: 2rem; | |
| color: var(--text-secondary); | |
| font-size: 0.9rem; | |
| border-top: 1px solid #E0E0E0; | |
| margin-top: 2rem; | |
| } | |
| .footer a { | |
| color: var(--michelin-red); | |
| text-decoration: none; | |
| font-weight: 500; | |
| } | |
| /* Animations */ | |
| @keyframes fadeInUp { | |
| from { | |
| opacity: 0; | |
| transform: translateY(20px); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| } | |
| .animate-in { | |
| animation: fadeInUp 0.6s ease forwards; | |
| } | |
| /* Responsive */ | |
| @media (max-width: 768px) { | |
| .hero-title { | |
| font-size: 1.8rem; | |
| } | |
| .hero-badges { | |
| flex-direction: column; | |
| align-items: center; | |
| } | |
| .tab-nav button { | |
| padding: 0.8rem 1rem !important; | |
| font-size: 0.85rem !important; | |
| } | |
| } | |
| /* Scrollbar */ | |
| ::-webkit-scrollbar { | |
| width: 8px; | |
| height: 8px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: #F5F5F5; | |
| border-radius: 4px; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: var(--michelin-burgundy); | |
| border-radius: 4px; | |
| } | |
| # 대략 라인 2890 근처, MICHELIN_CSS 변수의 끝부분 | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: var(--michelin-red); | |
| } | |
| /* ============================================ | |
| 허깅페이스 스페이스 배지/링크 숨기기 | |
| ============================================ */ | |
| .built-with, | |
| .built-with-badge, | |
| a[href*="huggingface.co/spaces"], | |
| footer, | |
| .footer, | |
| #footer, | |
| .gradio-container > footer, | |
| div[class*="footer"], | |
| .space-info, | |
| .hf-space-header, | |
| [class*="space-header"], | |
| div.wrap.svelte-1rjryqp, | |
| .svelte-1rjryqp:has(a[href*="huggingface"]), | |
| a.svelte-1rjryqp[href*="huggingface"] { | |
| display: none !important; | |
| visibility: hidden !important; | |
| opacity: 0 !important; | |
| height: 0 !important; | |
| width: 0 !important; | |
| overflow: hidden !important; | |
| position: absolute !important; | |
| pointer-events: none !important; | |
| z-index: -9999 !important; | |
| } | |
| /* 우측 상단 고정 위치 요소 숨기기 */ | |
| div[style*="position: fixed"][style*="right"], | |
| div[style*="position: fixed"][style*="top: 0"] { | |
| display: none !important; | |
| } | |
| """ # <- MICHELIN_CSS 닫는 부분 | |
| HERO_BANNER_HTML = """ | |
| <div class="hero-banner"> | |
| <div class="hero-content"> | |
| <div class="hero-stars">★ ★ ★</div> | |
| <h1 class="hero-title">MICHELIN AGI RECIPE SYSTEM</h1> | |
| <p class="hero-subtitle">미슐랭 스타 레벨 AGI(범용인공지능) 레시피 개발 시스템</p> | |
| <div class="hero-badges"> | |
| <span class="badge">🧑🍳 SOMA Agent Team</span> | |
| <span class="badge">🇰🇷 K-FOOD 특화</span> | |
| <span class="badge">🔬 창발적 레시피 v3.2</span> | |
| <span class="badge">🖼️ AI 이미지 생성</span> | |
| <span class="badge">🔍 Brave Search</span> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| def format_recipe_html(result: Dict, recipe_type: str = "general") -> str: | |
| """레시피 출력 - 프리미엄 HTML 카드 형식""" | |
| # 공통 스타일 | |
| card_style = """ | |
| <style> | |
| .recipe-card { | |
| background: white; | |
| border-radius: 20px; | |
| overflow: hidden; | |
| box-shadow: 0 10px 40px rgba(0,0,0,0.1); | |
| margin: 1rem 0; | |
| } | |
| .recipe-header { | |
| background: linear-gradient(135deg, #1A1A1A 0%, #2D2D2D 100%); | |
| padding: 2rem; | |
| position: relative; | |
| color: #FFFFFF !important; | |
| } | |
| .recipe-header::after { | |
| content: ''; | |
| position: absolute; | |
| bottom: 0; | |
| left: 0; | |
| right: 0; | |
| height: 4px; | |
| background: linear-gradient(90deg, #D4AF37, #C41E3A, #D4AF37); | |
| } | |
| .recipe-header * { | |
| color: #FFFFFF !important; | |
| } | |
| .recipe-title { | |
| font-family: 'Playfair Display', serif; | |
| font-size: 1.8rem; | |
| color: #FFFFFF !important; | |
| margin: 0 0 0.5rem 0; | |
| } | |
| .recipe-meta { | |
| color: #E8B4B8 !important; | |
| font-size: 0.9rem; | |
| font-style: italic; | |
| } | |
| .recipe-id { | |
| background: rgba(212, 175, 55, 0.2); | |
| color: #FFD700 !important; | |
| padding: 0.3rem 0.8rem; | |
| border-radius: 15px; | |
| font-size: 0.8rem; | |
| font-family: monospace; | |
| } | |
| .score-section { | |
| background: linear-gradient(135deg, #FAF7F2 0%, #FFFFFF 100%); | |
| padding: 1.5rem 2rem; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| flex-wrap: wrap; | |
| gap: 1rem; | |
| border-bottom: 1px solid #F0F0F0; | |
| } | |
| .score-circle { | |
| width: 100px; | |
| height: 100px; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, #D4AF37 0%, #F4E4BA 50%, #D4AF37 100%); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| box-shadow: 0 4px 20px rgba(212, 175, 55, 0.3); | |
| } | |
| .score-number { | |
| font-family: 'Playfair Display', serif; | |
| font-size: 2rem; | |
| font-weight: 700; | |
| color: #1A1A1A; | |
| line-height: 1; | |
| } | |
| .score-label { | |
| font-size: 0.7rem; | |
| color: #1A1A1A; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| } | |
| .rating-stars { | |
| font-size: 1.5rem; | |
| color: #D4AF37; | |
| text-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
| } | |
| .rating-text { | |
| color: #722F37; | |
| font-weight: 600; | |
| font-size: 1.1rem; | |
| } | |
| .strengths-list { | |
| display: flex; | |
| gap: 0.5rem; | |
| flex-wrap: wrap; | |
| } | |
| .strength-badge { | |
| background: rgba(196, 30, 58, 0.1); | |
| color: #C41E3A; | |
| padding: 0.3rem 0.8rem; | |
| border-radius: 15px; | |
| font-size: 0.85rem; | |
| } | |
| .recipe-body { | |
| padding: 2rem; | |
| } | |
| .section-title { | |
| font-family: 'Playfair Display', serif; | |
| font-size: 1.3rem; | |
| color: #722F37; | |
| margin: 1.5rem 0 1rem 0; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .section-title::before { | |
| content: ''; | |
| display: inline-block; | |
| width: 4px; | |
| height: 20px; | |
| background: #D4AF37; | |
| border-radius: 2px; | |
| } | |
| .concept-box { | |
| background: linear-gradient(135deg, #1A1A1A 0%, #2D2D2D 100%); | |
| border-radius: 16px; | |
| padding: 1.5rem; | |
| color: #FFFFFF !important; | |
| margin: 1rem 0; | |
| } | |
| .concept-box h4 { | |
| color: #FFD700 !important; | |
| font-family: 'Cormorant Garamond', serif; | |
| font-size: 1.1rem; | |
| margin: 0 0 0.8rem 0; | |
| } | |
| .concept-box p { | |
| color: #FFFFFF !important; | |
| line-height: 1.6; | |
| margin: 0.5rem 0; | |
| } | |
| .concept-box strong { | |
| color: #FFD700 !important; | |
| } | |
| .ingredients-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); | |
| gap: 1rem; | |
| margin: 1rem 0; | |
| } | |
| .ingredient-card { | |
| background: #FAFAFA; | |
| border-radius: 12px; | |
| padding: 1rem; | |
| border-left: 3px solid #D4AF37; | |
| transition: transform 0.2s ease; | |
| } | |
| .ingredient-card:hover { | |
| transform: translateX(5px); | |
| } | |
| .ingredient-name { | |
| font-weight: 600; | |
| color: #1A1A1A; | |
| font-size: 1rem; | |
| } | |
| .ingredient-amount { | |
| color: #C41E3A; | |
| font-weight: 500; | |
| font-size: 0.9rem; | |
| } | |
| .ingredient-detail { | |
| color: #6B6B6B; | |
| font-size: 0.85rem; | |
| margin-top: 0.3rem; | |
| } | |
| .steps-timeline { | |
| position: relative; | |
| margin: 1.5rem 0; | |
| padding-left: 30px; | |
| } | |
| .steps-timeline::before { | |
| content: ''; | |
| position: absolute; | |
| left: 10px; | |
| top: 0; | |
| bottom: 0; | |
| width: 2px; | |
| background: linear-gradient(180deg, #D4AF37, #C41E3A); | |
| } | |
| .step-item { | |
| position: relative; | |
| margin-bottom: 1.5rem; | |
| padding: 1rem 1.5rem; | |
| background: white; | |
| border-radius: 12px; | |
| box-shadow: 0 2px 10px rgba(0,0,0,0.05); | |
| border: 1px solid #F0F0F0; | |
| } | |
| .step-item::before { | |
| content: attr(data-step); | |
| position: absolute; | |
| left: -40px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| width: 24px; | |
| height: 24px; | |
| background: #C41E3A; | |
| color: white; | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 0.8rem; | |
| font-weight: 600; | |
| } | |
| .step-action { | |
| font-weight: 600; | |
| color: #1A1A1A; | |
| font-size: 1rem; | |
| } | |
| .step-meta { | |
| display: flex; | |
| gap: 1rem; | |
| margin-top: 0.5rem; | |
| flex-wrap: wrap; | |
| } | |
| .step-meta span { | |
| background: #F5F5F5; | |
| padding: 0.2rem 0.6rem; | |
| border-radius: 6px; | |
| font-size: 0.8rem; | |
| color: #6B6B6B; | |
| } | |
| .step-detail { | |
| color: #6B6B6B; | |
| margin-top: 0.5rem; | |
| font-size: 0.9rem; | |
| line-height: 1.5; | |
| } | |
| .plating-section { | |
| background: linear-gradient(135deg, #FAF7F2 0%, #FFFFFF 100%); | |
| border-radius: 16px; | |
| padding: 1.5rem; | |
| margin: 1rem 0; | |
| border: 1px solid #E8E8E8; | |
| } | |
| .plating-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); | |
| gap: 1rem; | |
| margin-top: 1rem; | |
| } | |
| .plating-item { | |
| text-align: center; | |
| padding: 1rem; | |
| background: white; | |
| border-radius: 12px; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.05); | |
| } | |
| .plating-icon { | |
| font-size: 2rem; | |
| margin-bottom: 0.5rem; | |
| } | |
| .plating-label { | |
| font-size: 0.85rem; | |
| color: #6B6B6B; | |
| } | |
| .plating-value { | |
| font-weight: 600; | |
| color: #1A1A1A; | |
| font-size: 0.9rem; | |
| } | |
| .garnish-tags { | |
| display: flex; | |
| gap: 0.5rem; | |
| flex-wrap: wrap; | |
| margin-top: 1rem; | |
| } | |
| .garnish-tag { | |
| background: white; | |
| border: 1px solid #D4AF37; | |
| color: #722F37; | |
| padding: 0.3rem 0.8rem; | |
| border-radius: 20px; | |
| font-size: 0.85rem; | |
| } | |
| .emergence-section { | |
| background: linear-gradient(135deg, #2D2D2D 0%, #1A1A1A 100%); | |
| border-radius: 16px; | |
| padding: 1.5rem; | |
| margin: 1rem 0; | |
| color: #FFFFFF !important; | |
| } | |
| .emergence-title { | |
| color: #FFD700 !important; | |
| font-family: 'Playfair Display', serif; | |
| font-size: 1.2rem; | |
| margin-bottom: 1rem; | |
| } | |
| .emergence-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); | |
| gap: 1rem; | |
| } | |
| .emergence-item { | |
| background: rgba(255,255,255,0.05); | |
| border-radius: 12px; | |
| padding: 1rem; | |
| border-left: 3px solid #D4AF37; | |
| } | |
| .emergence-item h5 { | |
| color: #FFD700 !important; | |
| font-size: 0.9rem; | |
| margin: 0 0 0.5rem 0; | |
| } | |
| .emergence-item p { | |
| color: #FFFFFF !important; | |
| font-size: 0.9rem; | |
| margin: 0; | |
| line-height: 1.5; | |
| } | |
| .rules-box { | |
| background: rgba(212, 175, 55, 0.1); | |
| border-radius: 12px; | |
| padding: 1rem; | |
| margin: 1rem 0; | |
| } | |
| .rules-box h5 { | |
| color: #FFD700 !important; | |
| margin: 0 0 0.5rem 0; | |
| } | |
| .rules-list { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.5rem; | |
| } | |
| .rule-tag { | |
| background: rgba(255,255,255,0.2); | |
| color: #FFFFFF !important; | |
| padding: 0.3rem 0.8rem; | |
| border-radius: 15px; | |
| font-size: 0.85rem; | |
| } | |
| .sauce-card { | |
| background: linear-gradient(135deg, #C41E3A 0%, #722F37 100%); | |
| border-radius: 16px; | |
| padding: 1.5rem; | |
| color: #FFFFFF !important; | |
| margin: 1rem 0; | |
| } | |
| .sauce-card h4 { | |
| color: #FFD700 !important; | |
| font-family: 'Playfair Display', serif; | |
| margin: 0 0 1rem 0; | |
| } | |
| .sauce-ingredients { | |
| display: flex; | |
| gap: 0.5rem; | |
| flex-wrap: wrap; | |
| margin-bottom: 1rem; | |
| } | |
| .sauce-ingredient { | |
| background: rgba(255,255,255,0.2); | |
| color: #FFFFFF !important; | |
| padding: 0.3rem 0.8rem; | |
| border-radius: 15px; | |
| font-size: 0.9rem; | |
| } | |
| .sauce-notes { | |
| font-style: italic; | |
| color: #FFFFFF !important; | |
| font-size: 0.95rem; | |
| } | |
| </style> | |
| """ | |
| if recipe_type == "general": | |
| name = result.get('name', '요리') | |
| recipe_id = result.get('recipe_id', '') | |
| eval_data = result.get('evaluation', {}) | |
| score = eval_data.get('total_score', 0) | |
| rating = eval_data.get('rating', '⭐⭐') | |
| concept = result.get('concept', {}) | |
| ingredients = result.get('ingredients', []) | |
| steps = result.get('steps', []) | |
| plating = result.get('plating', {}) | |
| strengths = eval_data.get('strengths', []) | |
| stars_display = "⭐" * min(3, score // 30 + 1) | |
| html = f"""{card_style} | |
| <div class="recipe-card"> | |
| <div class="recipe-header"> | |
| <div style="display: flex; justify-content: space-between; align-items: start; flex-wrap: wrap; gap: 1rem;"> | |
| <div> | |
| <h2 class="recipe-title">🍽️ {name}</h2> | |
| <p class="recipe-meta">{concept.get('vision', '미슐랭 수준의 완성도')}</p> | |
| </div> | |
| <span class="recipe-id">{recipe_id}</span> | |
| </div> | |
| </div> | |
| <div class="score-section"> | |
| <div class="score-circle"> | |
| <span class="score-number">{score}</span> | |
| <span class="score-label">점</span> | |
| </div> | |
| <div style="text-align: center;"> | |
| <div class="rating-stars">{stars_display}</div> | |
| <div class="rating-text">{rating}</div> | |
| </div> | |
| <div class="strengths-list"> | |
| {''.join([f'<span class="strength-badge">✓ {s}</span>' for s in strengths[:4]])} | |
| </div> | |
| </div> | |
| <div class="recipe-body"> | |
| <div class="concept-box"> | |
| <h4>💡 요리 컨셉</h4> | |
| <p><strong>풍미 방향:</strong> {concept.get('flavor_direction', '')}</p> | |
| <p><strong>프레젠테이션:</strong> {concept.get('presentation_style', '')}</p> | |
| <p><strong>특별 노트:</strong> {concept.get('special_notes', '')}</p> | |
| </div> | |
| <h3 class="section-title">🥬 재료 목록</h3> | |
| <div class="ingredients-grid"> | |
| {''.join([f''' | |
| <div class="ingredient-card"> | |
| <div class="ingredient-name">{ing.get('name', '')}</div> | |
| <div class="ingredient-amount">{ing.get('amount', '')}</div> | |
| <div class="ingredient-detail">{ing.get('quality', '')} · {ing.get('role', '')}</div> | |
| </div> | |
| ''' for ing in ingredients[:8]])} | |
| </div> | |
| <h3 class="section-title">👨🍳 조리 순서</h3> | |
| <div class="steps-timeline"> | |
| {''.join([f''' | |
| <div class="step-item" data-step="{step.get('step', idx+1)}"> | |
| <div class="step-action">{step.get('action', '')}</div> | |
| <div class="step-meta"> | |
| <span>⏱️ {step.get('duration', '')}</span> | |
| <span>🌡️ {step.get('temp', '')}</span> | |
| <span>🔧 {step.get('technique', '')}</span> | |
| </div> | |
| <div class="step-detail">{step.get('detail', '')}</div> | |
| </div> | |
| ''' for idx, step in enumerate(steps[:6])])} | |
| </div> | |
| <h3 class="section-title">🎨 플레이팅</h3> | |
| <div class="plating-section"> | |
| <div class="plating-grid"> | |
| <div class="plating-item"> | |
| <div class="plating-icon">🍽️</div> | |
| <div class="plating-label">그릇</div> | |
| <div class="plating-value">{plating.get('plating_design', {}).get('plate', plating.get('plate', '원형 접시'))}</div> | |
| </div> | |
| <div class="plating-item"> | |
| <div class="plating-icon">📐</div> | |
| <div class="plating-label">배치</div> | |
| <div class="plating-value">{plating.get('plating_design', {}).get('layout', plating.get('layout', '중앙 배치'))[:20]}</div> | |
| </div> | |
| </div> | |
| <div class="garnish-tags"> | |
| {''.join([f'<span class="garnish-tag">{g}</span>' for g in plating.get('plating_design', {}).get('garnish', plating.get('garnish', []))[:6]])} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| return html | |
| elif recipe_type == "kfood": | |
| name = result.get('dish_name', '한식 요리') | |
| recipe_id = result.get('recipe_id', '') | |
| category = result.get('category', '') | |
| regional = result.get('regional_style', '') | |
| season = result.get('season', '') | |
| spice = result.get('spice_level', 5) | |
| concept = result.get('concept', {}) | |
| ingredients = result.get('ingredients', []) | |
| steps = result.get('steps', []) | |
| plating = result.get('plating', {}) | |
| sauce = result.get('korean_sauce', {}) | |
| eval_data = result.get('evaluation', {}) | |
| score = eval_data.get('total_score', 0) | |
| rating = eval_data.get('rating', '⭐⭐') | |
| spice_bar = "🌶️" * min(10, spice) + "○" * (10 - min(10, spice)) | |
| html = f"""{card_style} | |
| <div class="recipe-card"> | |
| <div class="recipe-header" style="background: linear-gradient(135deg, #722F37 0%, #C41E3A 100%);"> | |
| <div style="display: flex; justify-content: space-between; align-items: start; flex-wrap: wrap; gap: 1rem;"> | |
| <div> | |
| <h2 class="recipe-title">🇰🇷 {name}</h2> | |
| <p class="recipe-meta">{category} · {regional} · {season} · 매운맛 {spice}단계</p> | |
| </div> | |
| <span class="recipe-id">{recipe_id}</span> | |
| </div> | |
| </div> | |
| <div class="score-section"> | |
| <div class="score-circle"> | |
| <span class="score-number">{score}</span> | |
| <span class="score-label">점</span> | |
| </div> | |
| <div style="text-align: center;"> | |
| <div class="rating-stars">{rating}</div> | |
| <div style="font-size: 0.9rem; color: #6B6B6B; margin-top: 0.5rem;">{spice_bar}</div> | |
| </div> | |
| <div class="strengths-list"> | |
| {''.join([f'<span class="strength-badge">✓ {s}</span>' for s in eval_data.get('strengths', [])[:4]])} | |
| </div> | |
| </div> | |
| <div class="recipe-body"> | |
| <div class="concept-box" style="background: linear-gradient(135deg, #722F37 0%, #C41E3A 100%);"> | |
| <h4>🏛️ 한식 철학</h4> | |
| <p><strong>컨셉:</strong> {concept.get('concept', '')}</p> | |
| <p><strong>철학:</strong> {concept.get('korean_philosophy', '')}</p> | |
| <p><strong>계절 접근:</strong> {concept.get('seasonal_approach', '')}</p> | |
| </div> | |
| <div class="sauce-card"> | |
| <h4>🥄 {sauce.get('name', '특제 양념장')}</h4> | |
| <div class="sauce-ingredients"> | |
| {''.join([f'<span class="sauce-ingredient">{s}</span>' for s in sauce.get('base', [])])} | |
| </div> | |
| <p class="sauce-notes">{sauce.get('flavor_notes', '')}</p> | |
| </div> | |
| <h3 class="section-title">🥬 재료 목록</h3> | |
| <div class="ingredients-grid"> | |
| {''.join([f''' | |
| <div class="ingredient-card"> | |
| <div class="ingredient-name">{ing.get('name', '')}</div> | |
| <div class="ingredient-amount">{ing.get('amount', '')}</div> | |
| <div class="ingredient-detail">{ing.get('traditional_use', ing.get('role', ''))}</div> | |
| </div> | |
| ''' for ing in ingredients[:8]])} | |
| </div> | |
| <h3 class="section-title">👨🍳 조리 순서</h3> | |
| <div class="steps-timeline"> | |
| {''.join([f''' | |
| <div class="step-item" data-step="{step.get('step', idx+1)}"> | |
| <div class="step-action">{step.get('action', '')}</div> | |
| <div class="step-meta"> | |
| <span>⏱️ {step.get('duration', '')}</span> | |
| <span>🌡️ {step.get('temp', '')}</span> | |
| <span>🥢 {step.get('korean_technique', step.get('technique', ''))}</span> | |
| </div> | |
| <div class="step-detail">{step.get('detail', '')}</div> | |
| </div> | |
| ''' for idx, step in enumerate(steps[:6])])} | |
| </div> | |
| <h3 class="section-title">🎨 담음새</h3> | |
| <div class="plating-section"> | |
| <div class="plating-grid"> | |
| <div class="plating-item"> | |
| <div class="plating-icon">🏺</div> | |
| <div class="plating-label">그릇</div> | |
| <div class="plating-value">{plating.get('plating_design', {}).get('vessel', '백자')}</div> | |
| </div> | |
| <div class="plating-item"> | |
| <div class="plating-icon">🎨</div> | |
| <div class="plating-label">색의 조화</div> | |
| <div class="plating-value">{plating.get('color_harmony', '오방색')[:15]}</div> | |
| </div> | |
| </div> | |
| <div class="garnish-tags"> | |
| {''.join([f'<span class="garnish-tag">{g}</span>' for g in plating.get('plating_design', {}).get('garnish', [])[:6]])} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| return html | |
| elif recipe_type == "emergent": | |
| res = result.get('result', {}) | |
| name = res.get('name', '창발적 요리') | |
| recipe_id = result.get('recipe_id', '') | |
| base_dish = result.get('base_dish', '') | |
| emergence_type = result.get('emergence_type', '') | |
| rules = result.get('applied_rules', {}) | |
| emergence_names = { | |
| "variable_crossing": "🔀 변수 교차", | |
| "context_shift": "🌍 맥락 전환", | |
| "architecture_inversion": "🏗️ 구조 전복", | |
| "grammar_transplantation": "📝 문법 이식", | |
| "flavor_compound_pairing": "🧪 풍미 페어링", | |
| "texture_contrast": "✨ 텍스처 대비" | |
| } | |
| emergence_display = emergence_names.get(emergence_type, emergence_type) | |
| html = f"""{card_style} | |
| <div class="recipe-card"> | |
| <div class="recipe-header" style="background: linear-gradient(135deg, #1A1A1A 0%, #4A148C 50%, #1A1A1A 100%);"> | |
| <div style="display: flex; justify-content: space-between; align-items: start; flex-wrap: wrap; gap: 1rem;"> | |
| <div> | |
| <h2 class="recipe-title">🔬 {name}</h2> | |
| <p class="recipe-meta">{base_dish} → {emergence_display}</p> | |
| </div> | |
| <span class="recipe-id">{recipe_id}</span> | |
| </div> | |
| </div> | |
| <div class="score-section" style="background: linear-gradient(135deg, #F3E5F5 0%, #FFFFFF 100%);"> | |
| <div class="score-circle" style="background: linear-gradient(135deg, #9C27B0 0%, #E1BEE7 50%, #9C27B0 100%);"> | |
| <span class="score-number" style="color: white;">88</span> | |
| <span class="score-label" style="color: white;">점</span> | |
| </div> | |
| <div style="text-align: center;"> | |
| <div class="rating-stars">🧬 창발적 혁신</div> | |
| <div class="rating-text" style="color: #7B1FA2;">6대 차원 알고리즘</div> | |
| </div> | |
| <div class="strengths-list"> | |
| <span class="strength-badge" style="background: rgba(156, 39, 176, 0.1); color: #7B1FA2;">✓ 창의성</span> | |
| <span class="strength-badge" style="background: rgba(156, 39, 176, 0.1); color: #7B1FA2;">✓ 과학적 근거</span> | |
| <span class="strength-badge" style="background: rgba(156, 39, 176, 0.1); color: #7B1FA2;">✓ 문화 융합</span> | |
| </div> | |
| </div> | |
| <div class="recipe-body"> | |
| <div class="emergence-section" style="background: linear-gradient(135deg, #4A148C 0%, #7B1FA2 100%);"> | |
| <h4 class="emergence-title">🧬 적용된 창발 규칙</h4> | |
| <div class="rules-box" style="background: rgba(255,255,255,0.1);"> | |
| <div class="rules-list"> | |
| {''.join([f'<span class="rule-tag"><strong>{k}:</strong> {v if not isinstance(v, list) else ", ".join(str(x) for x in v[:3])}</span>' for k, v in rules.items()])} | |
| </div> | |
| </div> | |
| <div class="emergence-grid"> | |
| {f''' | |
| <div class="emergence-item"> | |
| <h5>💡 컨셉</h5> | |
| <p>{res.get('concept', '')[:100]}...</p> | |
| </div> | |
| ''' if res.get('concept') else ''} | |
| {f''' | |
| <div class="emergence-item"> | |
| <h5>🔧 핵심 기법</h5> | |
| <p>{res.get('technique', '')[:100]}...</p> | |
| </div> | |
| ''' if res.get('technique') else ''} | |
| {f''' | |
| <div class="emergence-item"> | |
| <h5>🎯 의외의 페어링</h5> | |
| <p>{res.get('unexpected_pairing', '')}</p> | |
| </div> | |
| ''' if res.get('unexpected_pairing') else ''} | |
| {f''' | |
| <div class="emergence-item"> | |
| <h5>🔬 작동 원리</h5> | |
| <p>{res.get('why_it_works', '')[:100]}...</p> | |
| </div> | |
| ''' if res.get('why_it_works') else ''} | |
| {f''' | |
| <div class="emergence-item"> | |
| <h5>🎨 예상 텍스처</h5> | |
| <p>{res.get('expected_texture', '')}</p> | |
| </div> | |
| ''' if res.get('expected_texture') else ''} | |
| {f''' | |
| <div class="emergence-item"> | |
| <h5>📝 풍미 노트</h5> | |
| <p>{res.get('flavor_notes', '')[:100]}...</p> | |
| </div> | |
| ''' if res.get('flavor_notes') else ''} | |
| {f''' | |
| <div class="emergence-item"> | |
| <h5>💎 혁신 포인트</h5> | |
| <p>{res.get('innovation_point', '')[:100]}...</p> | |
| </div> | |
| ''' if res.get('innovation_point') else ''} | |
| {f''' | |
| <div class="emergence-item"> | |
| <h5>🍽️ 먹는 경험</h5> | |
| <p>{res.get('eating_experience', '')[:100]}...</p> | |
| </div> | |
| ''' if res.get('eating_experience') else ''} | |
| </div> | |
| </div> | |
| {f''' | |
| <div class="concept-box"> | |
| <h4>🌉 풍미 브릿지</h4> | |
| <p>{res.get('flavor_bridge', '')}</p> | |
| </div> | |
| ''' if res.get('flavor_bridge') else ''} | |
| {f''' | |
| <h3 class="section-title">🔄 유지된 요소</h3> | |
| <div class="garnish-tags"> | |
| {"".join([f'<span class="garnish-tag">{e}</span>' for e in res.get("kept_elements", [])])} | |
| </div> | |
| ''' if res.get('kept_elements') else ''} | |
| {f''' | |
| <h3 class="section-title">✨ 새로운 요소</h3> | |
| <div class="garnish-tags"> | |
| {"".join([f'<span class="garnish-tag" style="background: #E1BEE7; color: #4A148C; border-color: #9C27B0;">{e}</span>' for e in res.get("new_elements", [])])} | |
| </div> | |
| ''' if res.get('new_elements') else ''} | |
| </div> | |
| </div> | |
| """ | |
| return html | |
| return "<p>레시피 정보가 없습니다.</p>" | |
| def create_gradio_app(system: MichelinRecipeSystem): | |
| """Gradio 앱 생성 - Premium UI v3.2""" | |
| def develop_recipe_ui(request, ingredients_str, servings, max_time, gen_image, progress=gr.Progress()): | |
| ingredients = [i.strip() for i in ingredients_str.split(",") if i.strip()] | |
| constraints = {"servings": servings, "max_time": max_time} | |
| def progress_callback(pct, msg): | |
| progress(pct, desc=msg) | |
| result = system.develop_recipe( | |
| user_request=request, | |
| ingredients=ingredients, | |
| constraints=constraints, | |
| generate_images=gen_image, | |
| progress_callback=progress_callback | |
| ) | |
| # HTML 형식 출력 | |
| output = format_recipe_html(result, "general") | |
| return output, result.get('image_url') | |
| def develop_kfood_ui(request, category, regional, season, spice, gen_image, progress=gr.Progress()): | |
| def progress_callback(pct, msg): | |
| progress(pct, desc=msg) | |
| result = system.develop_kfood_recipe( | |
| user_request=request, | |
| category=category, | |
| regional_style=regional, | |
| season=season, | |
| spice_level=spice, | |
| generate_images=gen_image, | |
| progress_callback=progress_callback | |
| ) | |
| # HTML 형식 출력 | |
| output = format_recipe_html(result, "kfood") | |
| return output, result.get('image_url') | |
| def create_emergent_ui(base_dish, emergence_type, param1, param2, gen_image, progress=gr.Progress()): | |
| """v3.2: 창발적 레시피 UI - 이미지 생성 추가!""" | |
| type_map = { | |
| "변수 교차": ("variable_crossing", {"method1": param1 or "", "method2": param2 or ""}), | |
| "맥락 전환": ("context_shift", {"target_cuisine": param1 or ""}), | |
| "구조 전복": ("architecture_inversion", {"new_structure": param1 or ""}), | |
| "문법 이식": ("grammar_transplantation", {"new_culture": param1 or ""}), | |
| "풍미 화합물 페어링": ("flavor_compound_pairing", {"ingredient": param1 or ""}), | |
| "텍스처 대비": ("texture_contrast", {"target_texture": param1 or ""}) | |
| } | |
| etype, kwargs = type_map.get(emergence_type, ("context_shift", {"target_cuisine": "italian"})) | |
| def progress_callback(pct, msg): | |
| progress(pct, desc=msg) | |
| result = system.create_emergent_recipe( | |
| base_dish, | |
| etype, | |
| generate_images=gen_image, | |
| progress_callback=progress_callback, | |
| **kwargs | |
| ) | |
| # HTML 형식 출력 | |
| output = format_recipe_html(result, "emergent") | |
| return output, result.get('image_url') | |
| def search_ingredients_ui(query, progress=gr.Progress()): | |
| if not query or len(query.strip()) < 1: | |
| return "검색어를 입력하세요." | |
| progress(0.3, "🔍 검색 중...") | |
| result = system.search_ingredient_info(query) | |
| progress(1.0, "✅ 완료!") | |
| output = f"# 🔍 '{query}' 재료 정보\n\n" | |
| output += f"## 📋 기본 정보\n\n" | |
| output += f"- **재료명**: {result.get('name', query)}\n" | |
| output += f"- **영문명**: {result.get('name_en', '')}\n" | |
| output += f"- **분류**: {result.get('category', '')}\n" | |
| output += f"- **특성**: {result.get('characteristics', '')}\n\n" | |
| flavor = result.get('flavor_profile', {}) | |
| if flavor: | |
| output += "## 🍷 풍미 프로파일\n\n" | |
| output += f"- **맛**: {', '.join(flavor.get('taste', []))}\n" | |
| output += f"- **향**: {', '.join(flavor.get('aroma', []))}\n" | |
| output += f"- **질감**: {', '.join(flavor.get('texture', []))}\n\n" | |
| uses = result.get('culinary_uses', []) | |
| if uses: | |
| output += "## 🍳 요리 활용법\n\n" | |
| for use in uses: | |
| output += f"- {use}\n" | |
| output += "\n" | |
| pairings = result.get('pairings', []) | |
| if pairings: | |
| output += f"## 🤝 잘 어울리는 재료\n\n{', '.join(pairings)}\n\n" | |
| return output | |
| def get_flavor_pairings_ui(ingredient, progress=gr.Progress()): | |
| if not ingredient or len(ingredient.strip()) < 1: | |
| return "재료명을 입력하세요." | |
| progress(0.3, "🔍 분석 중...") | |
| result = system.search_flavor_pairing(ingredient) | |
| progress(1.0, "✅ 완료!") | |
| output = f"# 🍷 {ingredient} 풍미 페어링 분석\n\n" | |
| compounds = result.get('flavor_compounds', []) | |
| if compounds: | |
| output += f"## 🧪 주요 향 화합물\n\n{', '.join(compounds)}\n\n" | |
| taste = result.get('taste_profile', {}) | |
| if taste: | |
| output += "## 📊 맛 프로파일\n\n" | |
| output += "| 맛 | 강도 |\n|:---|:----:|\n" | |
| taste_kr = {"sweetness": "단맛", "sourness": "신맛", "saltiness": "짠맛", "bitterness": "쓴맛", "umami": "감칠맛"} | |
| for key, val in taste.items(): | |
| if isinstance(val, (int, float)): | |
| output += f"| {taste_kr.get(key, key)} | {'●' * int(val)}{'○' * (10-int(val))} ({val}/10) |\n" | |
| output += "\n" | |
| classic = result.get('classic_pairings', []) | |
| if classic: | |
| output += "## 🎯 클래식 페어링\n\n" | |
| for p in classic[:5]: | |
| if isinstance(p, dict): | |
| output += f"- **{p.get('ingredient', '')}**: {p.get('reason', '')}\n" | |
| else: | |
| output += f"- {p}\n" | |
| output += "\n" | |
| unexpected = result.get('unexpected_pairings', []) | |
| if unexpected: | |
| output += "## ⭐ 창발적 페어링\n\n" | |
| for p in unexpected[:5]: | |
| if isinstance(p, dict): | |
| output += f"- **{p.get('ingredient', '')}**: {p.get('reason', '')} (공유 화합물: {p.get('shared_compound', '')})\n" | |
| else: | |
| output += f"- {p}\n" | |
| output += "\n" | |
| return output | |
| def get_restaurants_ui(stars, cuisine, location): | |
| stars_val = None if stars == "전체" else int(stars[0]) | |
| restaurants = system.db.get_restaurants( | |
| stars=stars_val, | |
| cuisine=cuisine if cuisine else None, | |
| location=location if location else None | |
| ) | |
| if not restaurants: | |
| return "검색 결과가 없습니다." | |
| output = f"# 🌟 검색 결과: {len(restaurants)}개 레스토랑\n\n" | |
| output += "| 이름 | 위치 | 요리 | 등급 | 설명 |\n|:-----|:-----|:-----|:----:|:-----|\n" | |
| for r in restaurants[:30]: | |
| stars_str = "⭐" * r.get('stars', 0) if r.get('stars', 0) > 0 else "Bib" | |
| desc = (r.get('description', '') or '')[:50] | |
| output += f"| {r.get('name', '')} | {r.get('location', '')} | {r.get('cuisine', '')} | {stars_str} | {desc}... |\n" | |
| return output | |
| def analyze_trends_ui(): | |
| return system.analyze_michelin_trends() | |
| def get_kfood_info(category): | |
| if category not in KFOOD_CATEGORIES: | |
| return "" | |
| cat_data = KFOOD_CATEGORIES[category] | |
| info = f"### {category}\n\n" | |
| info += f"**설명**: {cat_data.get('description', '')}\n\n" | |
| info += f"**주요 기법**: {', '.join(cat_data.get('techniques', []))}\n\n" | |
| info += f"**대표 요리**: {', '.join(cat_data.get('signature_dishes', []))}\n\n" | |
| info += f"**핵심 재료**: {', '.join(cat_data.get('key_ingredients', []))}\n" | |
| return info | |
| # Gradio 앱 구성 - Premium UI | |
| with gr.Blocks(title="🍽️ Michelin AGI Recipe System v3.2", css=MICHELIN_CSS) as app: | |
| # Hero Banner | |
| gr.HTML(HERO_BANNER_HTML) | |
| with gr.Tabs(): | |
| # 탭 1: 일반 레시피 개발 | |
| with gr.Tab("🧪 레시피 개발"): | |
| gr.HTML(""" | |
| <div style="background: linear-gradient(135deg, #1A1A1A 0%, #2D2D2D 100%); | |
| padding: 1.5rem; border-radius: 16px; margin-bottom: 1.5rem; | |
| border-left: 4px solid #D4AF37; color: white;"> | |
| <h3 style="font-family: 'Playfair Display', serif; color: #D4AF37; margin: 0 0 0.5rem 0;"> | |
| 🧑🍳 SOMA Agent Team | |
| </h3> | |
| <p style="color: #E8E8E8; margin: 0; font-size: 0.95rem;"> | |
| 6명의 전문 셰프가 협업하여 미슐랭 수준의 레시피를 개발합니다. | |
| Executive Chef → Garde Manger → Research Chef → Saucier → Patissier → Michelin Inspector | |
| </p> | |
| </div> | |
| """) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| request_input = gr.Textbox(label="🍳 요리 요청", placeholder="예: 베이컨 시금치 계란말이, 한우 안심 스테이크...", lines=2) | |
| ingredients_input = gr.Textbox(label="🥬 재료 (쉼표 구분)", placeholder="계란, 베이컨, 시금치, 소금, 후추, 버터", lines=2) | |
| with gr.Row(): | |
| servings_input = gr.Slider(1, 10, value=2, step=1, label="👥 인분") | |
| time_input = gr.Slider(10, 180, value=30, step=5, label="⏱️ 최대 시간 (분)") | |
| gen_image_cb = gr.Checkbox(label="🖼️ AI 이미지 생성 (Nano-Banana-Pro)", value=False) | |
| develop_btn = gr.Button("🚀 레시피 개발 시작", variant="primary", size="lg") | |
| with gr.Column(scale=2): | |
| recipe_output = gr.HTML(label="레시피 결과") | |
| image_output = gr.Image(label="🖼️ 완성 이미지", type="filepath", show_label=True) | |
| develop_btn.click(develop_recipe_ui, [request_input, ingredients_input, servings_input, time_input, gen_image_cb], [recipe_output, image_output]) | |
| # 탭 2: K-FOOD 특화 | |
| with gr.Tab("🇰🇷 K-FOOD 특화"): | |
| gr.HTML(""" | |
| <div style="background: linear-gradient(135deg, #722F37 0%, #C41E3A 100%); | |
| padding: 1.5rem; border-radius: 16px; margin-bottom: 1.5rem; color: white;"> | |
| <h3 style="font-family: 'Playfair Display', serif; color: #F4E4BA; margin: 0 0 0.5rem 0;"> | |
| 🏛️ 한식의 철학과 혼을 담다 | |
| </h3> | |
| <p style="color: #E8B4B8; margin: 0; font-size: 0.95rem;"> | |
| 오미(五味)의 조화, 오방색(五方色)의 미학, 약식동원(藥食同源)의 지혜를 현대적으로 재해석합니다. | |
| </p> | |
| </div> | |
| """) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| kfood_request = gr.Textbox(label="🍚 요리 요청", placeholder="예: 고추장 글레이즈 삼겹살 스테이크, 트러플 비빔밥...", lines=2) | |
| kfood_category = gr.Dropdown(choices=list(KFOOD_CATEGORIES.keys()), value="퓨전한식", label="📂 카테고리") | |
| kfood_regional = gr.Dropdown(choices=list(KFOOD_REGIONAL_STYLES.keys()), value="서울/경기", label="📍 지역 스타일") | |
| kfood_season = gr.Dropdown(choices=list(KFOOD_SEASONAL_INGREDIENTS.keys()), value="가을", label="🍂 계절") | |
| kfood_spice = gr.Slider(0, 10, value=5, step=1, label="🌶️ 매운맛 레벨") | |
| kfood_gen_image = gr.Checkbox(label="🖼️ AI 이미지 생성", value=False) | |
| kfood_btn = gr.Button("🚀 K-FOOD 레시피 개발", variant="primary", size="lg") | |
| kfood_info = gr.HTML() | |
| kfood_category.change(get_kfood_info, [kfood_category], [kfood_info]) | |
| with gr.Column(scale=2): | |
| kfood_output = gr.HTML(label="레시피 결과") | |
| kfood_image_output = gr.Image(label="🖼️ 완성 이미지", type="filepath") | |
| kfood_btn.click(develop_kfood_ui, [kfood_request, kfood_category, kfood_regional, kfood_season, kfood_spice, kfood_gen_image], [kfood_output, kfood_image_output]) | |
| # 탭 3: 창발적 레시피 (v3.2: 이미지 생성 추가!) | |
| with gr.Tab("🔬 창발적 레시피"): | |
| gr.HTML(""" | |
| <div style="background: linear-gradient(135deg, #1A1A1A 0%, #4A148C 50%, #1A1A1A 100%); | |
| padding: 2rem; border-radius: 16px; margin-bottom: 1.5rem; color: white; position: relative; overflow: hidden;"> | |
| <div style="position: absolute; top: 0; right: 0; bottom: 0; left: 0; | |
| background: url('data:image/svg+xml,%3Csvg width=\"40\" height=\"40\" viewBox=\"0 0 40 40\" xmlns=\"http://www.w3.org/2000/svg\"%3E%3Cg fill=\"%23D4AF37\" fill-opacity=\"0.1\"%3E%3Cpath d=\"M20 20l20-20H0z\"/%3E%3C/g%3E%3C/svg%3E');"></div> | |
| <div style="position: relative; z-index: 1;"> | |
| <h3 style="font-family: 'Playfair Display', serif; color: #D4AF37; margin: 0 0 0.5rem 0; font-size: 1.5rem;"> | |
| 🧬 6대 차원 창발적 레시피 엔진 v3.2 | |
| </h3> | |
| <p style="color: #E8E8E8; margin: 0 0 1rem 0; font-size: 0.95rem;"> | |
| D1:재료 × D2:풍미 × D3:조리법 × D4:텍스처 × D5:구조 × D6:문화문법 | |
| </p> | |
| <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;"> | |
| <span style="background: rgba(156, 39, 176, 0.3); padding: 0.3rem 0.8rem; border-radius: 15px; font-size: 0.85rem; color: #FFFFFF;">✨ 이미지 생성</span> | |
| <span style="background: rgba(156, 39, 176, 0.3); padding: 0.3rem 0.8rem; border-radius: 15px; font-size: 0.85rem; color: #FFFFFF;">🎲 랜덤 강화</span> | |
| <span style="background: rgba(156, 39, 176, 0.3); padding: 0.3rem 0.8rem; border-radius: 15px; font-size: 0.85rem; color: #FFFFFF;">📝 5단계 파싱</span> | |
| </div> | |
| </div> | |
| </div> | |
| """) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| emg_base = gr.Textbox(label="🍳 기본 요리", placeholder="비빔밥, 떡볶이, 김치찌개, 불고기...", lines=1) | |
| emg_type = gr.Dropdown( | |
| choices=["변수 교차", "맥락 전환", "구조 전복", "문법 이식", "풍미 화합물 페어링", "텍스처 대비"], | |
| value="풍미 화합물 페어링", | |
| label="🧬 창발 유형" | |
| ) | |
| emg_param1 = gr.Textbox(label="📌 파라미터 1", placeholder="빈칸 = 랜덤 선택! 🎲", lines=1) | |
| emg_param2 = gr.Textbox(label="📌 파라미터 2 (변수 교차용)", placeholder="빈칸 = 랜덤 선택! 🎲", lines=1) | |
| emg_gen_image = gr.Checkbox(label="🖼️ 창발적 요리 이미지 생성", value=True) | |
| emg_btn = gr.Button("🔬 창발적 레시피 생성", variant="primary", size="lg") | |
| gr.HTML(""" | |
| <div style="background: #F3E5F5; padding: 1rem; border-radius: 12px; margin-top: 1rem; border-left: 3px solid #9C27B0;"> | |
| <h4 style="color: #4A148C; margin: 0 0 0.5rem 0; font-size: 0.95rem;">📖 파라미터 가이드</h4> | |
| <ul style="color: #6B6B6B; margin: 0; padding-left: 1.2rem; font-size: 0.85rem; line-height: 1.8;"> | |
| <li><strong>변수 교차:</strong> 조리법1, 조리법2 (수비드, 시어링, 그릴링...)</li> | |
| <li><strong>맥락 전환:</strong> 문화권 (japanese, french, italian...)</li> | |
| <li><strong>구조 전복:</strong> 구조 (Wrapped, Layered, Stuffed...)</li> | |
| <li><strong>풍미 페어링:</strong> 재료 (김치, 고추장, 된장...)</li> | |
| <li><strong>텍스처 대비:</strong> 텍스처 (Crispy, Creamy, Chewy...)</li> | |
| </ul> | |
| </div> | |
| """) | |
| with gr.Column(scale=2): | |
| emg_output = gr.HTML(label="창발적 레시피 결과") | |
| emg_image_output = gr.Image(label="🖼️ 창발적 요리 이미지", type="filepath") | |
| emg_btn.click(create_emergent_ui, [emg_base, emg_type, emg_param1, emg_param2, emg_gen_image], [emg_output, emg_image_output]) | |
| # 탭 4: 재료/풍미 검색 | |
| with gr.Tab("🔍 재료/풍미 검색"): | |
| gr.HTML(""" | |
| <div style="background: linear-gradient(135deg, #E8F5E9 0%, #FFFFFF 100%); | |
| padding: 1.5rem; border-radius: 16px; margin-bottom: 1.5rem; border-left: 4px solid #4CAF50;"> | |
| <h3 style="font-family: 'Playfair Display', serif; color: #2E7D32; margin: 0 0 0.5rem 0;"> | |
| 🔍 Brave Search + LLM 재료/풍미 검색 | |
| </h3> | |
| <p style="color: #6B6B6B; margin: 0; font-size: 0.95rem;"> | |
| 웹 검색과 AI 분석을 결합하여 재료 특성, 풍미 프로파일, 창발적 페어링을 찾아드립니다. | |
| </p> | |
| </div> | |
| """) | |
| with gr.Row(): | |
| with gr.Column(): | |
| gr.HTML("<h3 style='font-family: Playfair Display, serif; color: #2E7D32;'>📋 재료 정보 검색</h3>") | |
| search_query = gr.Textbox(label="검색어", placeholder="소고기, salmon, 고추장, 트러플...") | |
| search_btn = gr.Button("🔍 재료 검색", variant="primary") | |
| search_output = gr.Markdown() | |
| search_btn.click(search_ingredients_ui, [search_query], [search_output]) | |
| with gr.Column(): | |
| gr.HTML("<h3 style='font-family: Playfair Display, serif; color: #7B1FA2;'>🍷 풍미 페어링 분석</h3>") | |
| pairing_ingredient = gr.Textbox(label="재료명", placeholder="김치, 고추장, 된장, 커피...") | |
| pairing_btn = gr.Button("🍷 페어링 분석", variant="primary") | |
| pairing_output = gr.Markdown() | |
| pairing_btn.click(get_flavor_pairings_ui, [pairing_ingredient], [pairing_output]) | |
| # 탭 5: 미슐랭 레스토랑 | |
| with gr.Tab("🌟 미슐랭 레스토랑"): | |
| with gr.Row(): | |
| stars_filter = gr.Dropdown(choices=["전체", "3 Stars", "2 Stars", "1 Star", "0 (Bib Gourmand)"], value="전체", label="⭐ 별점 필터") | |
| cuisine_filter = gr.Textbox(label="🍳 요리 스타일", placeholder="Korean, French, Italian...") | |
| location_filter = gr.Textbox(label="📍 지역", placeholder="서울, Tokyo, Paris...") | |
| search_rest_btn = gr.Button("🔍 검색", variant="primary") | |
| restaurants_output = gr.Markdown() | |
| search_rest_btn.click(get_restaurants_ui, [stars_filter, cuisine_filter, location_filter], [restaurants_output]) | |
| # 탭 6: 트렌드 분석 | |
| with gr.Tab("📈 트렌드"): | |
| analyze_btn = gr.Button("📊 트렌드 분석", variant="primary") | |
| trends_output = gr.Markdown() | |
| analyze_btn.click(analyze_trends_ui, [], [trends_output]) | |
| gr.Markdown("---\n**Made with ❤️ by OpenFreeAI ** | Powered by https://open.kakao.com/o/peIe8KWh **") | |
| return app | |
| def main(): | |
| print(""" | |
| ╔═══════════════════════════════════════════════════════════════════════════╗ | |
| ║ 🍽️ MICHELIN AGI RECIPE SYSTEM v3.2 🍽️ ║ | |
| ║ 미슐랭 스타 레벨 AGI(범용인공지능) 레시피 개발 시스템 ║ | |
| ║ • SOMA Agent Team + K-FOOD 특화 ║ | |
| ║ • ⭐ v3.2 NEW: 창발적 레시피 이미지 생성! ║ | |
| ║ • ⭐ v3.2 NEW: 랜덤 요소 강화 (매번 다른 결과!) ║ | |
| ║ • ⭐ v3.2 NEW: JSON 파싱 개선 (5단계 폴백) ║ | |
| ║ D1:재료 D2:풍미 D3:조리법 D4:텍스처 D5:구조 D6:문화문법 ║ | |
| ║ • 🔍 Brave Search + LLM 재료/풍미 검색 ║ | |
| ║ • Fireworks AI + Replicate (Flux-Schnell) ║ | |
| ╚═══════════════════════════════════════════════════════════════════════════╝ | |
| """) | |
| fireworks_key = os.environ.get("FIREWORKS_API_KEY", "") | |
| replicate_key = os.environ.get("REPLICATE_API_TOKEN", "") | |
| brave_key = os.environ.get("BRAVE_API_KEY", "") | |
| print(f"🔑 Fireworks API: {'✅ 활성화' if fireworks_key else '⚠️ 목업 모드'}") | |
| print(f"🔑 Replicate API: {'✅ 활성화' if replicate_key else '⚠️ 이미지 비활성화'}") | |
| print(f"🔑 Brave Search API: {'✅ 활성화' if brave_key else '⚠️ 검색 제한'}") | |
| print(f"📁 미슐랭 CSV: {'✅ 있음' if os.path.exists('michelin.csv') else '⚠️ 폴백 데이터'}") | |
| print(f"📁 창발 데이터: {'✅ 있음' if os.path.exists('culinary_data.json') else '⚠️ 내장 폴백'}") | |
| print(f"📦 Gradio 설치: {'✅' if HAS_GRADIO else '❌ 없음'}") | |
| print(f"📦 Replicate 설치: {'✅' if HAS_REPLICATE else '❌ 없음'}") | |
| try: | |
| system = MichelinRecipeSystem(api_key=fireworks_key) | |
| print("✅ MichelinRecipeSystem 초기화 완료") | |
| except Exception as e: | |
| print(f"❌ 시스템 초기화 실패: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| return | |
| if HAS_GRADIO: | |
| print("\n🚀 Gradio UI 시작...") | |
| try: | |
| app = create_gradio_app(system) | |
| print("✅ Gradio 앱 생성 완료") | |
| app.launch( | |
| share=False, | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| show_error=True, | |
| ssr_mode=False | |
| ) | |
| except Exception as e: | |
| print(f"❌ Gradio 앱 시작 실패: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| else: | |
| print("\n📝 CLI 모드로 실행") | |
| print("\n--- 창발적 레시피 테스트 (v3.2) ---") | |
| result = system.create_emergent_recipe( | |
| base_dish="비빔밥", | |
| emergence_type="flavor_compound_pairing", | |
| generate_images=False, | |
| ingredient="" | |
| ) | |
| print(f"✅ 결과: {json.dumps(result, ensure_ascii=False, indent=2)}") | |
| if __name__ == "__main__": | |
| main() |