Corin1998's picture
Update services/planner.py
69abb02 verified
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"
# ---------------- Util ----------------
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: '&copy; OpenStreetMap' }}).addTo(m);
{' '.join(markers_js)}
{poly_js}
</script>
"""
# ---------------- Category helpers ----------------
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)
# ---------------- Candidate selection ----------------
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
# ---------------- Distance matrix & TSP ----------------
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
# ---------------- Main (Web-first) ----------------
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": "周辺で公開データの候補を取得できませんでした。都市名や半径を広げて再試行してください。"}
# 1日の現実的上限(滞在+移動)→ 長時間日は上限を緩和(〜10件)
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}