| from __future__ import annotations |
| from typing import List, Dict, Tuple, Set |
| 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 .web_sources import gather_web_docs |
| from .constraints import ( |
| realistic_travel_minutes, |
| dwell_minutes_for_pace, |
| MIN_DWELL_MIN, |
| infer_time_window, |
| ) |
|
|
| TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" |
|
|
| |
| def _minutes(hhmm: str) -> int: |
| h, m = hhmm.split(":") |
| return int(h) * 60 + int(m) |
|
|
| def _leaflet_html(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});") |
| 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); |
| {' '.join(markers_js)} |
| {poly_js} |
| </script> |
| """ |
|
|
| |
| def _category(meta: dict) -> str: |
| tags = [str(t).lower() for t in (meta.get("tags") or [])] |
| name = (meta.get("title") or "").lower() |
| if "restaurant" in tags or "food" in tags: |
| return "food" |
| if "museum" in tags or "gallery" in tags or "aquarium" in tags or "zoo" in tags or "culture" in tags: |
| return "culture" |
| if "park" in tags or "nature" in tags or "outdoor" in tags or "attraction" in tags or "garden" in tags: |
| return "nature" |
| if "temple" in tags or "shrine" in tags or "heritage" in tags or "place_of_worship" in name: |
| return "heritage" |
| if "performing_arts" in tags or "theatre" in tags or "cinema" in tags or "arts_centre" in tags: |
| return "performing_arts" |
| if "shopping" in tags or "marketplace" in tags: |
| return "shopping" |
| return "sightseeing" |
|
|
| def _is_chain_meta(meta: dict) -> bool: |
| from .chain_filters import is_chain |
| name = meta.get("title") |
| chain_tags = {k: meta.get(k) for k in ("brand","operator","network","brand:wikidata","brand:wikipedia") if k in meta} |
| return is_chain(name, chain_tags) |
|
|
| |
| def _score_doc(d: dict, center: Tuple[float, float], interests: List[str], weather: dict) -> float: |
| m = d.get("meta") or {} |
| tags = [str(t).lower() for t in (m.get("tags") or [])] |
| s = 0.0 |
| 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 |
| try: |
| dkm = haversine((float(m["lat"]), float(m["lon"])), (center[0], center[1])) |
| s -= max(0.0, (dkm - 4.0)) * 0.15 |
| except Exception: |
| pass |
| return s |
|
|
| def _preselect_by_quota(docs: List[dict], center: Tuple[float, float], interests: List[str], weather: dict, |
| hard_radius_km: float, quotas: Dict[str, int], cap: int) -> List[dict]: |
| filtered = [] |
| for d in docs: |
| m = d.get("meta") or {} |
| if _is_chain_meta(m): |
| continue |
| try: |
| dkm = haversine((float(m["lat"]), float(m["lon"])), (center[0], center[1])) |
| if dkm > hard_radius_km: |
| continue |
| except Exception: |
| continue |
| filtered.append(d) |
|
|
| sorted_docs = sorted(filtered, key=lambda x: _score_doc(x, center, interests, weather), reverse=True) |
|
|
| counts: Dict[str, int] = {} |
| out: List[dict] = [] |
| for d in sorted_docs: |
| cat = _category(d.get("meta") or {}) |
| if counts.get(cat, 0) >= quotas.get(cat, 999): |
| continue |
| out.append(d) |
| counts[cat] = counts.get(cat, 0) + 1 |
| if len(out) >= cap: |
| break |
| return out |
|
|
| |
| def _build_order_and_matrices(nodes: List[dict], profile: str): |
| 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)] |
| try: |
| order = solve_tsp(durations) or list(range(len(nodes))) |
| except Exception: |
| order = list(range(len(nodes))) |
| try: |
| route = route_polyline([(nodes[i]["lon"], nodes[i]["lat"]) for i in order], profile=profile) |
| except Exception: |
| route = None |
| return order, durations, distances, route |
|
|
| |
| LUNCH_W = (_minutes("11:30"), _minutes("14:00")) |
| DINNER_W = (_minutes("17:30"), _minutes("21:00")) |
|
|
| def _within(me: int, win: Tuple[int,int], slack: int = 30) -> bool: |
| return (win[0]-slack) <= me <= (win[1]+slack) |
|
|
| 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, data_source: str = "Web only", |
| web_radius_km: float = 12.0, top_k: int = 24, start_time: str = "09:00") -> Dict[str, object]: |
|
|
| |
| if mode_pref == "Auto (by weather)": |
| move_mode = "Walk" if (weather or {}).get("condition") not in ["rainy", "snowy"] else "Transit" |
| elif mode_pref in ["Walk", "Transit", "Drive/Taxi", "Mixed"]: |
| move_mode = "Transit" if mode_pref == "Mixed" else mode_pref |
| else: |
| move_mode = "Walk" |
| profile = "foot" if move_mode == "Walk" else "driving" |
|
|
| |
| docs = gather_web_docs(center, interests or [], date, radius_km=web_radius_km) |
| if not docs: |
| return {"map_html": "", "table": [], "narrative": "周辺で公開データの候補を取得できませんでした。都市名や半径を広げて再試行してください。"} |
|
|
| |
| dwell_default = max(MIN_DWELL_MIN, dwell_minutes_for_pace(pace)) |
| avg_leg_min = 18 |
| n_cap = max(4, min(10, int((hours * 60) / (dwell_default + avg_leg_min)))) |
|
|
| |
| quotas = { |
| "food": 2, "culture": 2, "nature": 2, |
| "heritage": 1, "performing_arts": 1, |
| "sightseeing": 2, "shopping": 2 |
| } |
|
|
| picked = _preselect_by_quota( |
| docs, center, interests or [], weather, |
| hard_radius_km=web_radius_km, quotas=quotas, cap=max(top_k, n_cap*3) |
| ) |
|
|
| nodes = [{"title": "Start", "lat": center[0], "lon": center[1], "notes": "Start/End", "meta": {}}] |
| for d in picked: |
| m = d.get("meta") or {} |
| nodes.append({ |
| "title": str(m.get("title") or d.get("id")), |
| "lat": float(m["lat"]), "lon": float(m["lon"]), |
| "notes": (d.get("text") or "")[:160], "meta": m, |
| }) |
| if len(nodes) <= 1: |
| return {"map_html": "", "table": [], "narrative": "候補が不足しました。条件を見直して再試行してください。"} |
|
|
| order, durations, distances, route = _build_order_and_matrices(nodes, profile) |
|
|
| counts: Dict[str, int] = {k:0 for k in quotas} |
| t = _minutes(start_time) |
| session_end = t + max(60, int(hours * 60)) |
| total_dist_km = 0.0 |
| total_dur_min = 0.0 |
| schedule: List[Dict] = [] |
| prev = 0 |
| last_cat = None |
| visited: Set[int] = set([0]) |
|
|
| move_cap_min = min(120, int((hours * 60) * 0.35)) |
|
|
| def _place_visit(idx: int, forced_window: Tuple[int,int] | None = None) -> bool: |
| nonlocal t, prev, total_dist_km, total_dur_min, last_cat |
|
|
| meta = nodes[idx]["meta"]; cat = _category(meta) |
|
|
| if counts.get(cat, 0) >= quotas.get(cat, 999): |
| return False |
| if last_cat == cat and cat in ("performing_arts", "culture"): |
| return False |
|
|
| |
| cur = t |
| in_meal_window = (_within(cur, LUNCH_W, 45) or _within(cur, DINNER_W, 45)) |
| if cat == "food": |
| if forced_window: |
| in_meal_window = _within(cur, forced_window, 45) |
| if not in_meal_window: |
| return False |
|
|
| |
| d_m = float(distances[prev][idx]) if distances and distances[prev][idx] is not None else 0.0 |
| d_s = float(durations[prev][idx]) if durations and durations[prev][idx] is not None else 0.0 |
| d_km = max(0.0, d_m / 1000.0) |
| d_min = realistic_travel_minutes(d_km, d_s / 60.0, move_mode) |
| if total_dur_min + d_min > move_cap_min: |
| return False |
| t += int(round(d_min)); total_dist_km += d_km; total_dur_min += d_min |
|
|
| |
| if forced_window: |
| start_w, end_w = forced_window |
| else: |
| tw = infer_time_window(nodes[idx]["title"], meta) |
| start_w, end_w = tw.start_min, tw.end_min |
| if not (start_w < session_end and end_w > t): |
| return False |
|
|
| if t < start_w: |
| wait = start_w - t |
| if t + wait + MIN_DWELL_MIN > session_end: |
| return False |
| t += wait; total_dur_min += wait |
|
|
| dwell_default_local = max(MIN_DWELL_MIN, dwell_minutes_for_pace(pace)) |
| dwell = int(meta.get("duration_min") or (45 if cat == "food" else dwell_default_local)) |
| dwell = max(MIN_DWELL_MIN, min(120, dwell)) |
| end_cap = min(end_w, session_end) |
| stay = min(dwell, end_cap - t) |
| if stay < MIN_DWELL_MIN: |
| return False |
|
|
| t_end = t + stay |
| schedule.append({ |
| "Time": f"{t//60:02d}:{t%60:02d}–{t_end//60:02d}:{t_end%60:02d}", |
| "Place": nodes[idx]["title"], |
| "Notes": nodes[idx]["notes"], |
| "lat": nodes[idx]["lat"], "lon": nodes[idx]["lon"], |
| }) |
| t = t_end; prev = idx; counts[cat] = counts.get(cat, 0) + 1 |
| if cat == "performing_arts": counts[cat] = quotas[cat] |
| last_cat = cat |
| visited.add(idx) |
| return True |
|
|
| |
| restaurant_idxs: Set[int] = set(i for i in range(1, len(nodes)) if _category(nodes[i]["meta"]) == "food") |
|
|
| |
| i = 1 |
| placed_count = 0 |
| while i < len(order) and placed_count < n_cap: |
| idx = order[i] |
| if idx == 0 or idx in visited: |
| i += 1 |
| continue |
|
|
| |
| cur = t |
| def _nearest_rest(): |
| best, bestd = None, 1e18 |
| for j in (restaurant_idxs - visited): |
| try: |
| dd = float(distances[prev][j] or 1e18) |
| except Exception: |
| dd = 1e18 |
| if dd < bestd: |
| best, bestd = j, dd |
| return best |
| if counts["food"] < quotas["food"]: |
| if _within(cur, LUNCH_W, 45): |
| cand = _nearest_rest() |
| if cand is not None and _place_visit(cand, forced_window=LUNCH_W): |
| placed_count += 1 |
| continue |
| if _within(cur, DINNER_W, 45): |
| cand = _nearest_rest() |
| if cand is not None and _place_visit(cand, forced_window=DINNER_W): |
| placed_count += 1 |
| continue |
|
|
| |
| if _place_visit(idx): |
| placed_count += 1 |
| i += 1 |
| if t >= session_end: |
| break |
|
|
| |
| sched_lines = [f"- {s['Time']}|{s['Place']} — {s['Notes']}" for s in schedule] |
| total_cost = estimate_leg_cost( |
| mode=("Walk" if move_mode == "Walk" else ("Transit" if move_mode == "Transit" else "Drive/Taxi")), |
| distance_km=total_dist_km, duration_min=total_dur_min, fare_hint=None |
| ) |
| prefs = { |
| "traveler": traveler, "interests": interests, "budget": budget, |
| "pace": pace, "date": date, "mode": move_mode, |
| "total_distance_km": round(total_dist_km, 1), "data_source": "Web only" |
| } |
| summary = generate_structured_summary(f"trip for {traveler}", picked, weather, prefs) |
| 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" |
| f"- 飲食は**ランチ/ディナー帯**に限定し、**チェーン店は除外**しています\n" |
| f"- オフィス/行政/コンベンション等は**候補から除外**しています\n" |
| f"- 長時間プランでは**立ち寄り件数の上限を緩和**しています\n" |
| f"\n\n### 移動手段と費用の目安\n- モード: **{move_mode}**\n" |
| f"- 総移動距離: **{total_dist_km:.1f} km** / 所要: **約{total_dur_min:.0f} 分**(移動上限: {move_cap_min} 分)\n" |
| f"- 推定交通費: **¥{total_cost:.0f}**\n" |
| ) |
| narrative = postprocess_text_ja(narrative) |
|
|
| map_html = _leaflet_html(nodes, route) |
| table = [[s["Time"], s["Place"], s["Notes"]] for s in schedule] |
| return {"map_html": map_html, "table": table, "narrative": narrative} |
|
|