Spaces:
Running
Running
| """ | |
| Context Package Builder for Unified AI Course Generation (v2) | |
| Supabase story_spots ํ ์ด๋ธ + walking_network.json์ ๊ธฐ๋ฐ์ผ๋ก | |
| Gemini์ ์ ๋ฌํ ์ง์ญ ์ปจํ ์คํธ ํจํค์ง๋ฅผ ์กฐํฉํฉ๋๋ค. | |
| Usage: | |
| from utils.context_builder import ContextBuilder | |
| builder = ContextBuilder() | |
| context = builder.build_zone_context("A", theme="history", max_spots=30) | |
| """ | |
| import json | |
| import os | |
| import logging | |
| import time | |
| from typing import Dict, List, Any, Optional, Tuple | |
| from pathlib import Path | |
| from utils.geo import haversine | |
| from utils.osrm_distance import get_walking_distances | |
| logger = logging.getLogger(__name__) | |
| # ============ Constants ============ | |
| WALKING_SPEED_KMH = 4.0 | |
| DISTANCE_MULTIPLIER = 1.3 | |
| # ์คํ ์ ์ ํ (ํ ํฐ ์์ฐ ๋ด) | |
| MAX_SPOTS_PER_ZONE = 50 | |
| MAX_SPOTS_TOTAL = 30 # AI์๊ฒ ์ ๋ฌํ ์ต๋ ํ๋ณด ์คํ ์ | |
| # ์นดํ ๊ณ ๋ฆฌ ํ๊ธ ๋งคํ | |
| CATEGORY_KR = { | |
| "beach": "ํด๋ณ", "coastline": "ํด์", "harbor": "ํฌ๊ตฌ", "oreum": "์ค๋ฆ", | |
| "forest": "์ฒ", "village": "๋ง์", "shrine": "์ ๋น", "fortress": "์ฑ๊ณฝ", | |
| "beacon": "๋ด์๋", "wetland": "์ต์ง", "traditional": "์ ํต", "ruins": "์ ์ ", | |
| "cafe": "์นดํ", "restaurant": "์์์ ", "market": "์์ฅ", "school": "ํ๊ต", | |
| "community": "๋ง์ํ๊ด", "product": "ํน์ฐ๋ฌผ", | |
| } | |
| # ํ๋์ฑ ๋ฒ์ ๋งค์นญ (light ์ ์ ๋ moderate ์ฐ์ฑ ์ฝ์ค๋ฅผ ๋ณผ ์ ์์ด์ผ ํจ) | |
| ACTIVITY_COMPATIBLE = { | |
| "light": {"light", "moderate"}, | |
| "moderate": {"light", "moderate", "active"}, | |
| "active": {"moderate", "active"}, | |
| } | |
| # ํ ๋ง ๊ต์ฐจ ๋งค์นญ (healing โ nature, photo โ nature) | |
| THEME_COMPATIBLE = { | |
| "healing": {"healing", "nature"}, | |
| "nature": {"nature", "healing"}, | |
| "photo": {"photo", "nature"}, | |
| "history": {"history"}, | |
| "food": {"food"}, | |
| } | |
| # ์ต์ ํ๋ณด ์คํ ์ (์ด ์ดํ๋ฉด ํํฐ ๋จ๊ณ์ ์ํ) | |
| MIN_SPOTS_THRESHOLD = 3 | |
| # DB ์บ์ TTL (์ด) | |
| SPOTS_CACHE_TTL_SECONDS = 300 # 5๋ถ | |
| class ContextBuilder: | |
| """Gemini ์ปจํ ์คํธ ํจํค์ง ๋น๋""" | |
| def __init__(self, data_dir: Optional[str] = None): | |
| if data_dir is None: | |
| # ์ฌ๋ฌ ๊ฒฝ๋ก ํ๋ณด์์ data/ ๋๋ ํ ๋ฆฌ ํ์ | |
| # ์ค์ ํ์ผ(walking_network.json) ์กด์ฌ ์ฌ๋ถ๋ก ํ๋จ | |
| # - HF Space: /app/data/ (deploy workflow๊ฐ ๋ณต์ฌ) | |
| # - ๋ก์ปฌ ๊ฐ๋ฐ: project_root/data/ | |
| # ์ฃผ์: /data๋ HF persistent storage root์ด๋ฏ๋ก ํ์์ | |
| base = Path(__file__).parent.parent # backend/ ๋๋ /app/ | |
| candidates = [ | |
| base / "data", # /app/data/ (HF Space) | |
| base.parent / "data", # project_root/data/ (๋ก์ปฌ ๊ฐ๋ฐ) | |
| ] | |
| for candidate in candidates: | |
| if (candidate / "walking_network.json").exists(): | |
| data_dir = str(candidate) | |
| break | |
| if data_dir is None: | |
| # ํ์ผ ์์ด๋ ๋๋ ํ ๋ฆฌ๋ผ๋ ์๋ ๊ฒฝ๋ก ์ฌ์ฉ | |
| for candidate in candidates: | |
| if candidate.exists(): | |
| data_dir = str(candidate) | |
| break | |
| if data_dir is None: | |
| data_dir = str(candidates[0]) | |
| self._data_dir = data_dir | |
| self._spots: List[Dict] = [] | |
| self._spots_by_id: Dict[str, Dict] = {} | |
| self._network: Dict = {} | |
| self._network_loaded = False | |
| self._spots_loaded = False | |
| self._spots_loaded_at: float = 0.0 # TTL ์บ์์ฉ ํ์์คํฌํ | |
| # ์คํ ๋ฆฌ๋ผ์ธ ์บ์ | |
| self._storylines: List[Dict] = [] | |
| self._storylines_loaded = False | |
| self._storylines_loaded_at: float = 0.0 | |
| def _row_to_spot(row: Dict[str, Any]) -> Dict[str, Any]: | |
| """DB row โ spot dict ๋ณํ (๊ธฐ์กด ์ฝ๋์ ํธํ๋๋ ํฌ๋งท)""" | |
| spot: Dict[str, Any] = { | |
| "id": row["id"], | |
| "name": row["name"], | |
| "name_en": row.get("name_en"), | |
| "name_zh": row.get("name_zh"), | |
| "category": row["category"], | |
| "location": { | |
| "lat": float(row["lat"]), | |
| "lng": float(row["lng"]), | |
| "address": row.get("address", ""), | |
| }, | |
| "story": { | |
| "title": row.get("story_title", ""), | |
| "content": row.get("story_content", ""), | |
| "source": row.get("story_source", ""), | |
| "tips": row.get("tips", ""), | |
| }, | |
| "tags": { | |
| "tier1": row.get("tags_tier1") or {}, | |
| "tier2": row.get("tags_tier2") or [], | |
| }, | |
| "meta": row.get("meta") or {}, | |
| "media": { | |
| "main_image": row.get("main_image_url"), | |
| "thumbnail": row.get("thumbnail_url"), | |
| "generated_image": row.get("generated_image_url"), | |
| }, | |
| "priority_score": row.get("priority_score", 5), | |
| "status": row.get("status", "active"), | |
| } | |
| # zone / cluster_id: DB์ ์์ผ๋ฉด ํฌํจ | |
| if row.get("zone") is not None: | |
| spot["zone"] = row["zone"] | |
| if row.get("cluster_id") is not None: | |
| spot["cluster_id"] = row["cluster_id"] | |
| # ํฅํ ์ง ๋ฉํ๋ฐ์ดํฐ (๋ง์, ์ถ์ฒ, ์๋) | |
| if row.get("village"): | |
| spot["village"] = row["village"] | |
| if row.get("source_book"): | |
| spot["source_book"] = row["source_book"] | |
| if row.get("historical_period"): | |
| spot["historical_period"] = row["historical_period"] | |
| return spot | |
| def _load_spots_from_db(self) -> bool: | |
| """Supabase story_spots ํ ์ด๋ธ์์ active ์คํ ๋ก๋. ์ฑ๊ณต ์ True.""" | |
| try: | |
| from db import get_supabase | |
| supabase = get_supabase() | |
| result = supabase.table("story_spots") \ | |
| .select("*") \ | |
| .eq("status", "active") \ | |
| .execute() | |
| rows = result.data or [] | |
| self._spots = [self._row_to_spot(r) for r in rows] | |
| self._spots_by_id = {s["id"]: s for s in self._spots} | |
| self._spots_loaded = True | |
| self._spots_loaded_at = time.monotonic() | |
| logger.info(f"ContextBuilder loaded {len(self._spots)} spots from Supabase") | |
| return True | |
| except Exception as e: | |
| logger.error(f"Failed to load spots from Supabase: {e}") | |
| return False | |
| def _is_spots_cache_expired(self) -> bool: | |
| """์คํ ์บ์ TTL ๋ง๋ฃ ์ฌ๋ถ""" | |
| if not self._spots_loaded: | |
| return True | |
| elapsed = time.monotonic() - self._spots_loaded_at | |
| return elapsed >= SPOTS_CACHE_TTL_SECONDS | |
| def _is_storylines_cache_expired(self) -> bool: | |
| """์คํ ๋ฆฌ๋ผ์ธ ์บ์ TTL ๋ง๋ฃ ์ฌ๋ถ""" | |
| if not self._storylines_loaded: | |
| return True | |
| elapsed = time.monotonic() - self._storylines_loaded_at | |
| return elapsed >= SPOTS_CACHE_TTL_SECONDS | |
| def _load_storylines_from_db(self) -> bool: | |
| """Supabase storylines + storyline_spots ํ ์ด๋ธ์์ ์คํ ๋ฆฌ๋ผ์ธ ๋ก๋""" | |
| try: | |
| from db import get_supabase | |
| supabase = get_supabase() | |
| # ๋ชจ๋ active ์คํ ๋ฆฌ๋ผ์ธ ๋ก๋ | |
| sl_result = supabase.table("storylines") \ | |
| .select("*") \ | |
| .eq("status", "active") \ | |
| .execute() | |
| sl_rows = sl_result.data or [] | |
| if not sl_rows: | |
| self._storylines = [] | |
| self._storylines_loaded = True | |
| self._storylines_loaded_at = time.monotonic() | |
| return True | |
| storylines_map = {sl["id"]: {**sl, "spots": []} for sl in sl_rows} | |
| # ๋ชจ๋ storyline_spots๋ฅผ ํ ๋ฒ์ ๋ก๋ | |
| ss_result = supabase.table("storyline_spots") \ | |
| .select("*") \ | |
| .in_("storyline_id", list(storylines_map.keys())) \ | |
| .order("spot_order") \ | |
| .execute() | |
| for ss in (ss_result.data or []): | |
| sl_id = ss["storyline_id"] | |
| if sl_id in storylines_map: | |
| storylines_map[sl_id]["spots"].append(ss) | |
| self._storylines = list(storylines_map.values()) | |
| self._storylines_loaded = True | |
| self._storylines_loaded_at = time.monotonic() | |
| logger.info(f"ContextBuilder loaded {len(self._storylines)} storylines") | |
| return True | |
| except Exception as e: | |
| logger.error(f"Failed to load storylines from DB: {e}") | |
| return False | |
| def find_matching_storylines( | |
| self, | |
| lat: float, | |
| lng: float, | |
| theme: Optional[str] = None, | |
| duration_minutes: int = 60, | |
| max_results: int = 3, | |
| ) -> List[Dict]: | |
| """ | |
| ์ฌ์ฉ์ ์์น + ์ทจํฅ์ ๋ง๋ ์คํ ๋ฆฌ๋ผ์ธ ๋งค์นญ | |
| ์ค์ฝ์ด ๊ณ์ฐ: | |
| - ์์น ๊ทผ์ ๋: ์ต๋ 50์ (๋ฐ๊ฒฝ ๋ด 50, ๋ฐ๊ฒฝ 1.5x ๋ด 25, ์ด์ 0) | |
| - ํ ๋ง ๋งค์นญ: ์ต๋ 30์ (์ ํ 30, ํธํ 15, ๋ฏธ์ง์ 10) | |
| - ์๊ฐ ์ ํฉ๋: ์ต๋ 20์ (20% ์ด๋ด 20, 50% ์ด๋ด 10) | |
| Returns: [{storyline: {...}, score: int, distance_km: float}] | |
| """ | |
| self._ensure_loaded() | |
| if not self._storylines: | |
| return [] | |
| scored = [] | |
| for sl in self._storylines: | |
| score = 0 | |
| # 1. ์์น ๊ทผ์ ๋ (max 50) | |
| sl_lat = float(sl.get("center_lat") or 0) | |
| sl_lng = float(sl.get("center_lng") or 0) | |
| sl_radius = float(sl.get("radius_km") or 2.0) | |
| if not sl_lat or not sl_lng: | |
| continue | |
| dist = haversine(lat, lng, sl_lat, sl_lng) | |
| if dist > sl_radius * 2: | |
| continue # ๋ฐ๊ฒฝ 2๋ฐฐ ์ด๊ณผ โ ์คํต | |
| if dist <= sl_radius: | |
| score += 50 | |
| elif dist <= sl_radius * 1.5: | |
| score += 25 | |
| # 2. ํ ๋ง ๋งค์นญ (max 30) | |
| sl_theme = sl.get("theme") | |
| if theme and sl_theme: | |
| if theme == sl_theme: | |
| score += 30 | |
| elif sl_theme in THEME_COMPATIBLE.get(theme, set()): | |
| score += 15 | |
| # ํ ๋ง ๋ถ์ผ์น = 0์ | |
| elif not theme: | |
| score += 10 # ํ ๋ง ๋ฏธ์ง์ โ ์ฝ๊ฐ์ ๋ณด๋์ค | |
| # 3. ์๊ฐ ์ ํฉ๋ (max 20) | |
| sl_minutes = sl.get("estimated_minutes") or 60 | |
| diff_ratio = abs(duration_minutes - sl_minutes) / max(duration_minutes, 1) | |
| if diff_ratio <= 0.2: | |
| score += 20 | |
| elif diff_ratio <= 0.5: | |
| score += 10 | |
| # 4. ์คํ ์ ํจ์ฑ ๊ฒ์ฆ: ๋ชจ๋ ์คํ์ด ์ค์ ๋ก ์กด์ฌํ๋์ง ํ์ธ | |
| sl_spots = sl.get("spots", []) | |
| all_valid = all( | |
| ss.get("spot_id") in self._spots_by_id | |
| for ss in sl_spots | |
| ) | |
| if not all_valid or not sl_spots: | |
| logger.warning(f"[storyline] Skipping {sl['id']}: missing spots") | |
| continue | |
| scored.append({ | |
| "storyline": sl, | |
| "score": score, | |
| "distance_km": round(dist, 3), | |
| }) | |
| scored.sort(key=lambda x: -x["score"]) | |
| return scored[:max_results] | |
| def _ensure_loaded(self): | |
| """๋ฐ์ดํฐ lazy loading (์คํ: DB + TTL ์บ์, ๋คํธ์ํฌ: ํ์ผ, ์คํ ๋ฆฌ๋ผ์ธ: DB + TTL ์บ์)""" | |
| # ๋คํธ์ํฌ ๋ฐ์ดํฐ๋ ์ ์ ํ์ผ์์ ํ ๋ฒ๋ง ๋ก๋ | |
| if not self._network_loaded: | |
| network_path = os.path.join(self._data_dir, "walking_network.json") | |
| try: | |
| with open(network_path, "r", encoding="utf-8") as f: | |
| self._network = json.load(f) | |
| logger.info(f"ContextBuilder loaded network: " | |
| f"{len(self._network.get('layer1_clusters', {}))} clusters") | |
| except FileNotFoundError as e: | |
| logger.error(f"Network file not found: {e}") | |
| except json.JSONDecodeError as e: | |
| logger.error(f"Network JSON parse error: {e}") | |
| self._network_loaded = True | |
| # ์คํ ๋ฐ์ดํฐ: DB์์ ๋ก๋ + TTL ์บ์ (๋ง๋ฃ ์ ์ฌ๋ก๋) | |
| if self._is_spots_cache_expired(): | |
| if not self._load_spots_from_db(): | |
| # DB ์คํจ ์: ์ด์ ์บ์ ๋ฐ์ดํฐ ์ ์ง, TTL ๋ฆฌ์ | |
| if self._spots: | |
| self._spots_loaded_at = time.monotonic() | |
| logger.warning(f"DB reload failed, keeping {len(self._spots)} cached spots") | |
| else: | |
| logger.error("DB load failed and no cached spots available") | |
| # ์คํ ๋ฆฌ๋ผ์ธ: DB์์ ๋ก๋ + TTL ์บ์ (์คํจํด๋ ๊ธฐ์กด ํ๋ฆ์ ์ํฅ ์์) | |
| if self._is_storylines_cache_expired(): | |
| if not self._load_storylines_from_db(): | |
| if self._storylines: | |
| self._storylines_loaded_at = time.monotonic() | |
| logger.warning(f"Storyline reload failed, keeping {len(self._storylines)} cached") | |
| else: | |
| self._storylines_loaded = True # ๋น ์ํ๋ก ๋งํนํ์ฌ ๋ฐ๋ณต ์๋ ๋ฐฉ์ง | |
| def get_zone_for_location(self, lat: float, lng: float) -> str: | |
| """์ขํ์ ๊ฐ์ฅ ๊ฐ๊น์ด ์กด ๋ฐํ""" | |
| self._ensure_loaded() | |
| zones = self._network.get("zones", {}) | |
| best_zone = "C" | |
| best_dist = float('inf') | |
| for zone_id, zone in zones.items(): | |
| lat_range = zone.get("lat_range", (0, 0)) | |
| lng_range = zone.get("lng_range", (0, 0)) | |
| center_lat = (lat_range[0] + lat_range[1]) / 2 | |
| center_lng = (lng_range[0] + lng_range[1]) / 2 | |
| dist = haversine(lat, lng, center_lat, center_lng) | |
| if dist < best_dist: | |
| best_dist = dist | |
| best_zone = zone_id | |
| return best_zone | |
| def get_nearby_zones(self, zone_id: str) -> List[str]: | |
| """์ธ์ ์กด ๋ชฉ๋ก""" | |
| self._ensure_loaded() | |
| adj = self._network.get("zone_adjacency", {}) | |
| return adj.get(zone_id, []) | |
| def _get_radius_candidates( | |
| self, | |
| lat: float, | |
| lng: float, | |
| radius_km: float, | |
| ) -> List[Dict]: | |
| """๋ฐ๊ฒฝ ๋ด ํ์ฑ ์คํ + ๊ฑฐ๋ฆฌ ๊ณ์ฐ (ํํฐ ์ ๋จ๊ณ)""" | |
| self._ensure_loaded() | |
| candidates = [] | |
| for spot in self._spots: | |
| if spot.get("status", "active") != "active": | |
| continue | |
| s_lat = spot["location"]["lat"] | |
| s_lng = spot["location"]["lng"] | |
| dist = haversine(lat, lng, s_lat, s_lng) | |
| if dist <= radius_km: | |
| candidates.append({**spot, "_distance_km": round(dist, 3)}) | |
| return candidates | |
| def _apply_filters( | |
| self, | |
| candidates: List[Dict], | |
| theme: Optional[str] = None, | |
| activity_level: Optional[str] = None, | |
| mood: Optional[List[str]] = None, | |
| use_theme_compat: bool = True, | |
| use_activity_range: bool = True, | |
| ) -> List[Dict]: | |
| """์กฐ๊ฑด ํํฐ ์ ์ฉ (๋ฒ์ ๋งค์นญ ์ง์)""" | |
| filtered = [] | |
| # ํ ๋ง ํธํ ์ธํธ | |
| if theme and use_theme_compat: | |
| accepted_themes = THEME_COMPATIBLE.get(theme, {theme}) | |
| elif theme: | |
| accepted_themes = {theme} | |
| else: | |
| accepted_themes = None | |
| # ํ๋์ฑ ํธํ ์ธํธ | |
| if activity_level and use_activity_range: | |
| accepted_activities = ACTIVITY_COMPATIBLE.get(activity_level, {activity_level}) | |
| elif activity_level: | |
| accepted_activities = {activity_level} | |
| else: | |
| accepted_activities = None | |
| for spot in candidates: | |
| tags = spot.get("tags", {}).get("tier1", {}) | |
| # ํ ๋ง ํํฐ | |
| if accepted_themes: | |
| spot_themes = set(tags.get("theme", [])) | |
| if not spot_themes & accepted_themes: | |
| # ์์ ํ ๋ง๋ฉด restaurant/cafe ์นดํ ๊ณ ๋ฆฌ ํ์ฉ | |
| if theme == "food" and spot["category"] in ("restaurant", "cafe"): | |
| pass | |
| else: | |
| continue | |
| # ํ๋์ฑ ํํฐ (๋ฒ์ ๋งค์นญ) | |
| if accepted_activities: | |
| spot_activity = tags.get("activity_level") | |
| if spot_activity and spot_activity not in accepted_activities: | |
| continue | |
| # ๋ถ์๊ธฐ ํํฐ (ํ๋๋ผ๋ ๋งค์นญ) | |
| if mood: | |
| spot_moods = tags.get("mood", []) | |
| if spot_moods and not any(m in spot_moods for m in mood): | |
| continue | |
| filtered.append(spot) | |
| return filtered | |
| def filter_spots( | |
| self, | |
| lat: float, | |
| lng: float, | |
| radius_km: float = 3.0, | |
| theme: Optional[str] = None, | |
| activity_level: Optional[str] = None, | |
| mood: Optional[List[str]] = None, | |
| max_spots: int = MAX_SPOTS_TOTAL, | |
| ) -> List[Dict]: | |
| """ | |
| ์กฐ๊ฑด์ ๋ง๋ ์คํ ํํฐ๋ง + ๊ฑฐ๋ฆฌ์ ์ ๋ ฌ | |
| ์ต์ ์คํ ๋ณด์ฅ์ ์ํด ๋จ๊ณ์ ํํฐ ์ํ ์ ์ฉ | |
| Returns: [{...spot_data, _distance_km: float}] | |
| """ | |
| # ๋ฐ๊ฒฝ ๋ด ํ๋ณด (ํํฐ ์ ) | |
| candidates = self._get_radius_candidates(lat, lng, radius_km) | |
| # 1๋จ๊ณ: ์ ์ฒด ํํฐ ์ ์ฉ (ํ ๋ง ํธํ + ํ๋์ฑ ๋ฒ์ ๋งค์นญ) | |
| result = self._apply_filters(candidates, theme, activity_level, mood) | |
| # 2๋จ๊ณ: ์คํ ๋ถ์กฑ ์ ๋จ๊ณ์ ํํฐ ์ํ | |
| if len(result) < MIN_SPOTS_THRESHOLD: | |
| # 2-1: mood ํํฐ ์ ๊ฑฐ | |
| result = self._apply_filters(candidates, theme, activity_level, mood=None) | |
| if len(result) >= MIN_SPOTS_THRESHOLD: | |
| logger.info(f"[filter] Relaxed mood filter: {len(result)} spots") | |
| if len(result) < MIN_SPOTS_THRESHOLD: | |
| # 2-2: activity_level + mood ํํฐ ์ ๊ฑฐ (ํ ๋ง๋ง ์ ์ง) | |
| result = self._apply_filters(candidates, theme, activity_level=None, mood=None) | |
| if len(result) >= MIN_SPOTS_THRESHOLD: | |
| logger.info(f"[filter] Relaxed activity filter: {len(result)} spots") | |
| if len(result) < MIN_SPOTS_THRESHOLD: | |
| # 2-3: ๋ฐ๊ฒฝ 2๋ฐฐ ํ์ฅ + ํ ๋ง๋ง | |
| expanded = self._get_radius_candidates(lat, lng, radius_km * 2) | |
| result = self._apply_filters(expanded, theme, activity_level=None, mood=None) | |
| if len(result) >= MIN_SPOTS_THRESHOLD: | |
| logger.info(f"[filter] Expanded radius to {radius_km * 2}km: {len(result)} spots") | |
| if len(result) < MIN_SPOTS_THRESHOLD: | |
| # 2-4: ์ตํ ์๋จ - ํ ๋ง๋ ํด์ , ๋ฐ๊ฒฝ 2๋ฐฐ ๋ด ๋ชจ๋ ์คํ | |
| result = self._get_radius_candidates(lat, lng, radius_km * 2) | |
| logger.warning(f"[filter] All filters dropped, using {len(result)} spots in {radius_km * 2}km") | |
| # ๊ด๋ จ์ฑ ์ค์ฝ์ด ๊ณ์ฐ ํ ์ ๋ ฌ (ํ ๋ง/๋ฌด๋ ๋งค์นญ โ priority_score โ ๊ฑฐ๋ฆฌ) | |
| for spot in result: | |
| score = spot.get("priority_score", 5) * 10 # ๊ธฐ๋ณธ 0-100 | |
| tags = spot.get("tags", {}).get("tier1", {}) | |
| # ํ ๋ง ์ ํ ๋งค์นญ ๋ณด๋์ค | |
| if theme: | |
| spot_themes = set(tags.get("theme", [])) | |
| if theme in spot_themes: | |
| score += 30 # ์ ํ ๋งค์นญ | |
| elif spot_themes & THEME_COMPATIBLE.get(theme, set()): | |
| score += 15 # ํธํ ๋งค์นญ | |
| # ๋ฌด๋ ๋งค์นญ ๋ณด๋์ค | |
| if mood: | |
| spot_moods = set(tags.get("mood", [])) | |
| matched = len(spot_moods & set(mood)) | |
| score += matched * 10 # ๋ฌด๋๋น 10์ | |
| # ํ๋์ฑ ๋งค์นญ ๋ณด๋์ค | |
| if activity_level: | |
| spot_activity = tags.get("activity_level") | |
| if spot_activity == activity_level: | |
| score += 10 | |
| spot["_relevance_score"] = score | |
| result.sort(key=lambda s: (-s["_relevance_score"], s["_distance_km"])) | |
| # ๊ฑฐ๋ฆฌ ๋ค์์ฑ ๋ณด์ฅ: ๊ฐ๊น์ด ์คํ๋ง ๋ฐ์ง๋์ง ์๋๋ก ๋ฐด๋๋ณ ๋ถ๋ฐฐ | |
| # (๊ด๋ จ์ฑ ๋์ ์คํ ์ฐ์ + ๋ค์ํ ๊ฑฐ๋ฆฌ ๋์ญ์์ ๊ณ ๋ฅด๊ฒ ์ ํ) | |
| if len(result) > max_spots and radius_km > 0: | |
| result = self._ensure_distance_diversity(result, max_spots, radius_km) | |
| else: | |
| result = result[:max_spots] | |
| return result | |
| def _ensure_distance_diversity( | |
| self, | |
| spots: List[Dict], | |
| max_spots: int, | |
| radius_km: float, | |
| ) -> List[Dict]: | |
| """ | |
| ๊ฑฐ๋ฆฌ ๋์ญ๋ณ ๋ถ๋ฐฐ๋ก ํ๋ณด ์คํ์ ๊ณต๊ฐ์ ๋ค์์ฑ ๋ณด์ฅ. | |
| ๊ฐ๊น์ด ๊ณณ๋ง ๋ฐ์ง๋๋ฉด AI๊ฐ 0.2km ์ฝ์ค๋ฅผ ๋ง๋๋ ๋ฌธ์ ๋ฐฉ์ง. | |
| 3๊ฐ ๋ฐด๋๋ก ๋๋์ด ๊ฐ ๋ฐด๋์์ ์ต์ ๋น์จ์ ํ๋ณด: | |
| - ๊ทผ๊ฑฐ๋ฆฌ (0 ~ 33%): ํ๋ณด์ 50% | |
| - ์ค๊ฑฐ๋ฆฌ (33% ~ 66%): ํ๋ณด์ 30% | |
| - ์๊ฑฐ๋ฆฌ (66% ~ 100%): ํ๋ณด์ 20% | |
| """ | |
| band_boundaries = [radius_km * 0.33, radius_km * 0.66, radius_km] | |
| band_quotas = [ | |
| max(5, int(max_spots * 0.50)), # ๊ทผ๊ฑฐ๋ฆฌ: 50% | |
| max(3, int(max_spots * 0.30)), # ์ค๊ฑฐ๋ฆฌ: 30% | |
| max(2, int(max_spots * 0.20)), # ์๊ฑฐ๋ฆฌ: 20% | |
| ] | |
| bands: List[List[Dict]] = [[], [], []] | |
| for spot in spots: | |
| dist = spot.get("_distance_km", 0) | |
| if dist <= band_boundaries[0]: | |
| bands[0].append(spot) | |
| elif dist <= band_boundaries[1]: | |
| bands[1].append(spot) | |
| else: | |
| bands[2].append(spot) | |
| selected: List[Dict] = [] | |
| remaining: List[Dict] = [] | |
| for i, (band, quota) in enumerate(zip(bands, band_quotas)): | |
| selected.extend(band[:quota]) | |
| remaining.extend(band[quota:]) | |
| # ์ฟผํฐ ๋ฏธ๋ฌ ๋ฐด๋๊ฐ ์์ผ๋ฉด ๋๋จธ์ง์์ ์ฑ์ (๊ด๋ จ์ฑ์ ์ ์ง) | |
| if len(selected) < max_spots: | |
| remaining.sort(key=lambda s: (-s.get("_relevance_score", 0), s["_distance_km"])) | |
| selected.extend(remaining[:max_spots - len(selected)]) | |
| # ์ต์ข ์ ๋ ฌ: ๊ด๋ จ์ฑ โ ๊ฑฐ๋ฆฌ | |
| selected.sort(key=lambda s: (-s.get("_relevance_score", 0), s["_distance_km"])) | |
| band_counts = [min(len(b), q) for b, q in zip(bands, band_quotas)] | |
| logger.info(f"[filter] Distance diversity: bands={band_counts}, " | |
| f"total={len(selected)}/{len(spots)} spots") | |
| return selected[:max_spots] | |
| async def build_area_context( | |
| self, | |
| lat: float, | |
| lng: float, | |
| radius_km: float = 3.0, | |
| theme: Optional[str] = None, | |
| activity_level: Optional[str] = None, | |
| mood: Optional[List[str]] = None, | |
| duration_minutes: int = 60, | |
| ) -> Tuple[str, str, List[Dict]]: | |
| """ | |
| AI์ ์ ๋ฌํ ์ง์ญ ์ปจํ ์คํธ ๋น๋ | |
| Returns: | |
| (area_context_text, distance_table_text, filtered_spots) | |
| """ | |
| self._ensure_loaded() | |
| # 1. ์คํ ํํฐ๋ง (๋จ๊ณ์ ํํฐ ์ํ ๋ด์ฅ) | |
| spots = self.filter_spots(lat, lng, radius_km, theme, activity_level, mood) | |
| if not spots: | |
| return ("ํ๋ณด ์คํ์ด ์์ต๋๋ค.", "", []) | |
| # 2. ์ง์ญ ์ปจํ ์คํธ ํ ์คํธ ์กฐํฉ | |
| zone_id = self.get_zone_for_location(lat, lng) | |
| zone_info = self._network.get("zones", {}).get(zone_id, {}) | |
| lines = [] | |
| lines.append(f"# ์ง์ญ: {zone_info.get('name', zone_id)} ({zone_info.get('description', '')})") | |
| lines.append(f"# ๋ฐ๊ฒฝ {radius_km}km ๋ด ํ๋ณด ์คํ {len(spots)}๊ฐ") | |
| lines.append("") | |
| # ์คํ ๋ชฉ๋ก (์์ถ ํ์) | |
| lines.append("## ์คํ ๋ชฉ๋ก") | |
| lines.append("ID|์ด๋ฆ|์นดํ ๊ณ ๋ฆฌ|์ขํ|์ฐ์ ์์|์คํ ๋ฆฌ์์ฝ") | |
| for s in spots: | |
| cat_kr = CATEGORY_KR.get(s["category"], s["category"]) | |
| story = s.get("story", {}) | |
| content = story.get("content", "") | |
| # ์คํ ๋ฆฌ ์์ฝ (80์) | |
| if content and "์นดํ ๊ณ ๋ฆฌ์ ์ํฉ๋๋ค" not in content: | |
| summary = content[:80].replace("\n", " ") | |
| else: | |
| summary = f"{s['name']} - {cat_kr}" | |
| line = ( | |
| f"{s['id']}|{s['name']}|{cat_kr}|" | |
| f"{s['location']['lat']:.4f},{s['location']['lng']:.4f}|" | |
| f"p{s.get('priority_score', 5)}|{summary}" | |
| ) | |
| lines.append(line) | |
| area_context = "\n".join(lines) | |
| # 3. ๊ฑฐ๋ฆฌํ ๋น๋ (OSRM ์ฌ์ฉ) | |
| distance_lines = await self._build_distance_table(spots, duration_minutes) | |
| return (area_context, distance_lines, spots) | |
| async def _build_distance_table(self, spots: List[Dict], duration_minutes: int) -> str: | |
| """์คํ ๊ฐ ๋๋ณด ๊ฑฐ๋ฆฌํ ์์ฑ (OSRM Table API, ํด๋ฐฑ: Haversine ร 1.3)""" | |
| if len(spots) <= 1: | |
| return "" | |
| max_walkable_km = (duration_minutes / 60) * WALKING_SPEED_KMH | |
| # OSRM Table API๋ก ์ค์ ๋๋ณด ๊ฑฐ๋ฆฌ+์๊ฐ ๊ณ์ฐ | |
| spot_distances, _, spot_durations, _ = await get_walking_distances(spots) | |
| lines = [] | |
| max_dist = 0 | |
| pairs = [] | |
| for (id_a, id_b), dist in spot_distances.items(): | |
| walk_min = spot_durations.get((id_a, id_b), max(1, round(dist / WALKING_SPEED_KMH * 60))) | |
| if dist <= max_walkable_km * 1.5: # ๋๋ณด ๊ฐ๋ฅ ๋ฒ์ ๋ด๋ง | |
| pairs.append((id_a, id_b, dist, walk_min)) | |
| max_dist = max(max_dist, dist) | |
| # ๊ฑฐ๋ฆฌ์ ์ ๋ ฌ, ์์ 100๊ฐ๋ง | |
| pairs.sort(key=lambda x: x[2]) | |
| pairs = pairs[:100] | |
| lines.append("## ๋๋ณด ๊ฑฐ๋ฆฌํ (OSRM ์ค์ธก, ์ ์ฃผ ๋ณด์ ์ ์ฉ)") | |
| for a, b, dist, walk_min in pairs: | |
| name_a = self._spots_by_id.get(a, {}).get("name", a) | |
| name_b = self._spots_by_id.get(b, {}).get("name", b) | |
| lines.append(f"{a}({name_a}) โ {b}({name_b}): {dist:.2f}km, {walk_min}๋ถ") | |
| # ํด๋ฌ์คํฐ ๋ฉ๋ชจ | |
| if max_dist > 0 and max_dist < max_walkable_km * 0.5: | |
| lines.append("") | |
| lines.append(f"โ ๏ธ ๋ชจ๋ ์คํ์ด {max_dist:.1f}km ์ด๋ด์ ๋ฐ์ง๋์ด ์์ต๋๋ค.") | |
| lines.append("โ ์ด๋ ์๊ฐ์ด ํฌ๋ง ์๊ฐ๋ณด๋ค ์งง์ ์ ์์ผ๋ฉฐ, ์ด๋ ์ ์์ ๋๋ค.") | |
| return "\n".join(lines) | |
| def build_spot_details_for_stories(self, spot_ids: List[str]) -> str: | |
| """์คํ ๋ฆฌ ์์ฑ์ ์ํ ์คํ ์์ธ ์ ๋ณด""" | |
| self._ensure_loaded() | |
| lines = [] | |
| for i, sid in enumerate(spot_ids): | |
| spot = self._spots_by_id.get(sid) | |
| if not spot: | |
| continue | |
| story = spot.get("story", {}) | |
| original = story.get("content", "") | |
| source = story.get("source", "") | |
| lines.append(f"### ์คํ {i+1}: {spot['name']} ({sid})") | |
| lines.append(f"- ์นดํ ๊ณ ๋ฆฌ: {CATEGORY_KR.get(spot['category'], spot['category'])}") | |
| lines.append(f"- ์์น: {spot['location'].get('address', '')}") | |
| if original and "์นดํ ๊ณ ๋ฆฌ์ ์ํฉ๋๋ค" not in original: | |
| lines.append(f"- ๊ธฐ์กด ์คํ ๋ฆฌ: {original}") | |
| if source and source != "kakao": | |
| lines.append(f"- ์ถ์ฒ: {source}") | |
| lines.append("") | |
| return "\n".join(lines) | |
| def get_spots_count(self) -> int: | |
| """๋ก๋๋ ์คํ ์""" | |
| self._ensure_loaded() | |
| return len(self._spots) | |
| # Singleton instance | |
| _builder: Optional[ContextBuilder] = None | |
| def get_context_builder() -> ContextBuilder: | |
| """์ฑ๊ธํค ContextBuilder ์ธ์คํด์ค ๋ฐํ""" | |
| global _builder | |
| if _builder is None: | |
| _builder = ContextBuilder() | |
| return _builder | |