| from __future__ import annotations |
| from typing import List, Dict, Tuple |
| from datetime import datetime, timedelta |
| import json |
|
|
| from haversine import haversine |
|
|
| from .osrm import distance_matrix, route_polyline |
| from .optimizer import solve_tsp |
| from .rag_chain import generate_structured_summary, postprocess_text_ja |
| from .costs import estimate_leg_cost |
| from .providers.navitia import get_journey |
|
|
| TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" |
|
|
| |
| |
| |
|
|
| def score_poi(item: dict, interests: List[str], weather: dict) -> float: |
| s = 0.0 |
| meta = item.get("meta", {}) |
| tags_raw = meta.get("tags") or [] |
| if isinstance(tags_raw, str): |
| tags = set([t.strip().lower() for t in tags_raw.split(",") if t.strip()]) |
| else: |
| tags = set([str(t).strip().lower() for t in tags_raw]) |
|
|
| for it in interests or []: |
| if str(it).lower() in tags: |
| s += 1.0 |
|
|
| cond = (weather or {}).get("condition", "unknown") |
| if "indoor" in tags and cond in ["rainy", "snowy"]: |
| s += 0.6 |
| if "outdoor" in tags and cond in ["sunny", "cloudy"]: |
| s += 0.6 |
|
|
| s += float(item.get("score", 0.0)) * 0.1 |
| return s |
|
|
|
|
| def pick_candidates(rag, center: Tuple[float, float], query: str, |
| interests: List[str], weather: dict, k: int = 10, |
| radius_km: float = 20.0) -> List[dict]: |
| hits = rag.search(query, k=50) or [] |
| pts = [] |
| lat0, lon0 = center |
| for h in hits: |
| meta = h.get("meta", {}) |
| lat = meta.get("lat") |
| lon = meta.get("lon") |
| try: |
| lat = float(lat) if lat is not None else None |
| lon = float(lon) if lon is not None else None |
| except Exception: |
| lat = lon = None |
| if lat is None or lon is None: |
| continue |
| if radius_km and haversine((lat0, lon0), (lat, lon)) > radius_km: |
| continue |
| pts.append(h) |
| pts.sort(key=lambda x: score_poi(x, interests, weather), reverse=True) |
| return pts[:k] |
|
|
| |
| |
| |
|
|
| def make_itinerary(center: Tuple[float, float], |
| pois: List[dict], |
| hours: float, |
| start_time: str = "09:00", |
| profile: str = "driving") -> Tuple[List[Dict], str, List[int], List[List[float]], List[List[float]]]: |
| lat0, lon0 = center |
| nodes = [{ |
| "title": "Start", |
| "lat": lat0, |
| "lon": lon0, |
| "notes": "Start/End", |
| }] |
| for p in pois: |
| meta = p.get("meta", {}) |
| title = meta.get("title") or meta.get("type") or p.get("id") |
| try: |
| lat = float(meta.get("lat")) |
| lon = float(meta.get("lon")) |
| except Exception: |
| continue |
| nodes.append({ |
| "title": str(title), |
| "lat": lat, |
| "lon": lon, |
| "notes": (p.get("text") or "")[:140], |
| }) |
|
|
| if len(nodes) == 1: |
| return [], _make_leaflet(nodes, None), [0], [[0]], [[0]] |
|
|
| coords = [(n["lon"], n["lat"]) for n in nodes] |
| try: |
| durations, distances = distance_matrix(coords, profile=profile) |
| except Exception: |
| n = len(nodes) |
| durations = [[0 if i == j else 600 for j in range(n)] for i in range(n)] |
| distances = [[0 if i == j else 1000 for j in range(n)] for i in range(n)] |
|
|
| order = solve_tsp(durations) or list(range(len(nodes))) |
|
|
| try: |
| start_dt = datetime.strptime(start_time, "%H:%M") |
| except Exception: |
| start_dt = datetime.strptime("09:00", "%H:%M") |
|
|
| total_minutes = max(60, int(hours * 60)) |
| dwell = 50 |
| t = start_dt |
| used = 0 |
| schedule: List[Dict] = [] |
|
|
| for idx in order[1:]: |
| if idx == 0: |
| continue |
| if used + dwell > total_minutes: |
| break |
| n = nodes[idx] |
| t_end = t + timedelta(minutes=dwell) |
| schedule.append({ |
| "Time": f"{t.strftime('%H:%M')}–{t_end.strftime('%H:%M')}", |
| "Place": n["title"], |
| "Notes": n["notes"], |
| "lat": n["lat"], |
| "lon": n["lon"], |
| }) |
| t = t_end |
| used += dwell |
|
|
| try: |
| route = route_polyline([(nodes[i]["lon"], nodes[i]["lat"]) for i in order], profile=profile) |
| except Exception: |
| route = None |
| map_html = _make_leaflet(nodes, route) |
| return schedule, map_html, order, durations, distances |
|
|
|
|
| def _make_leaflet(nodes: List[dict], route: dict | None) -> str: |
| center_lat = nodes[0]["lat"] |
| center_lon = nodes[0]["lon"] |
| markers_js = [] |
| for n in nodes: |
| title_js = json.dumps(n["title"]) |
| markers_js.append(f"L.marker([{n['lat']},{n['lon']}]).addTo(m).bindPopup({title_js});") |
| markers_js_str = "\n ".join(markers_js) |
|
|
| poly_js = "" |
| if route and route.get("geometry", {}).get("coordinates"): |
| coords = route["geometry"]["coordinates"] |
| latlngs = [[c[1], c[0]] for c in coords] |
| poly_js = f"L.polyline({json.dumps(latlngs)}, {{\"weight\": 5}}).addTo(m);" |
|
|
| return f""" |
| <div id="map" style="height:480px;"></div> |
| <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/> |
| <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> |
| <script> |
| var m = L.map('map').setView([{center_lat}, {center_lon}], 12); |
| L.tileLayer('{TILE_URL}', {{ maxZoom: 19, attribution: '© OpenStreetMap' }}).addTo(m); |
| {markers_js_str} |
| {poly_js} |
| </script> |
| """ |
|
|
| def _auto_mode_by_weather(weather: dict) -> str: |
| cond = (weather or {}).get("condition", "cloudy") |
| if cond in ["rainy", "snowy"]: |
| return "Transit" |
| return "Walk" |
|
|
| |
| |
| |
|
|
| def generate_plan(rag, |
| center: Tuple[float, float], |
| hours: float, |
| traveler: str, |
| interests: List[str], |
| budget: str, |
| pace: str, |
| weather: dict, |
| date: str, |
| mode_pref: str = "Auto (by weather)", |
| include_costs: bool = True) -> Dict[str, object]: |
| |
| if mode_pref == "Auto (by weather)": |
| chosen_mode = _auto_mode_by_weather(weather) |
| elif mode_pref in ["Walk", "Transit", "Drive/Taxi", "Mixed"]: |
| chosen_mode = mode_pref |
| else: |
| chosen_mode = "Walk" |
| profile = "foot" if chosen_mode == "Walk" else "driving" |
|
|
| |
| query = f"best places for {traveler} with interests {interests} budget {budget} pace {pace}" |
| pois = pick_candidates(rag, center, query, interests, weather, k=8, radius_km=25.0) |
|
|
| |
| sched, map_html, order, durations, distances = make_itinerary(center, pois, hours, profile=profile) |
|
|
| |
| total_cost = 0.0 |
| total_dist_km = 0.0 |
| total_dur_min = 0.0 |
| leg_rows: List[str] = [] |
|
|
| nodes = [{"title": "Start", "lat": center[0], "lon": center[1]}] |
| for p in pois: |
| meta = p.get("meta", {}) |
| nodes.append({"title": meta.get("title") or meta.get("type") or p.get("id"), |
| "lat": float(meta.get("lat")), "lon": float(meta.get("lon"))}) |
|
|
| for a, b in zip(order[:-1], order[1:]): |
| if a == b: |
| continue |
| dist_m = float(distances[a][b]) if distances and distances[a][b] is not None else 0.0 |
| dur_s = float(durations[a][b]) if durations and durations[a][b] is not None else 0.0 |
| d_km = max(0.0, dist_m / 1000.0) |
| d_min = max(0.0, dur_s / 60.0) |
|
|
| fare_hint = None |
| if chosen_mode == "Transit": |
| j = get_journey(nodes[a]["lat"], nodes[a]["lon"], nodes[b]["lat"], nodes[b]["lon"], when_iso=f"{date}T09:00:00") |
| if j: |
| d_min = max(d_min, float(j.get("duration_s", 0.0) / 60.0)) |
| fare_hint = j.get("fare") |
|
|
| mode_for_cost = "Walk" if chosen_mode == "Walk" else ("Transit" if chosen_mode == "Transit" else "Drive/Taxi") |
| leg_cost = estimate_leg_cost(mode=mode_for_cost, distance_km=d_km, duration_min=d_min, fare_hint=fare_hint) |
|
|
| total_cost += leg_cost |
| total_dist_km += d_km |
| total_dur_min += d_min |
| leg_rows.append(f"- {nodes[a]['title']} → {nodes[b]['title']}: {d_km:.1f} km / 約{d_min:.0f} 分 / ¥{leg_cost:.0f}") |
|
|
| |
| sched_lines = [f"- {s['Time']}|{s['Place']} — {s['Notes']}" for s in sched] |
|
|
| |
| prefs = { |
| "traveler": traveler, "interests": interests, "budget": budget, |
| "pace": pace, "date": date, "mode": chosen_mode, |
| "total_distance_km": round(total_dist_km, 1) |
| } |
| summary = generate_structured_summary(query, pois, weather, prefs) |
|
|
| |
| cost_md = "" |
| if include_costs: |
| leg_section = "\n".join(leg_rows) if leg_rows else "(移動区間なし)" |
| cost_md = ( |
| f"\n\n### 移動手段と費用の目安\n" |
| f"- モード: **{chosen_mode}**\n" |
| f"- 総移動距離: **{total_dist_km:.1f} km** / 所要: **約{total_dur_min:.0f} 分**\n" |
| f"- 推定交通費: **¥{total_cost:.0f}**\n\n" |
| f"#### 区間内訳\n{leg_section}\n\n" |
| f"> 注: 公共交通の運賃はNavitia取得がある場合に優先、無い場合は概算です。実際の価格は変動します。\n" |
| ) |
|
|
| |
| narrative = ( |
| f"# {summary.get('title','観光プラン')}\n\n" |
| f"## ハイライト\n" + |
| ("\n".join([f"- {h}" for h in summary.get("highlights", [])]) or "- 見どころを効率よく巡るプラン") + |
| f"\n\n## スケジュール(テキスト)\n" + |
| ("\n".join(sched_lines) if sched_lines else "- (該当なし)") + |
| f"\n\n## 実用メモ\n" + |
| ("\n".join([f"- {m}" for m in summary.get("memos", [])]) or "- 天候に合わせて屋内/屋外の比率を調整") + |
| cost_md |
| ) |
|
|
| narrative = postprocess_text_ja(narrative) |
|
|
| |
| table = [[s["Time"], s["Place"], s["Notes"]] for s in sched] |
| return {"map_html": map_html, "table": table, "narrative": narrative} |
|
|