samchun-gemini / utils /context_builder.py
JHyeok5's picture
Upload folder using huggingface_hub
241bf2e verified
"""
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
@staticmethod
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