Corin1998's picture
Update services/planner.py
df5774e verified
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"
# -------------------------
# Scoring & Candidate Pick
# -------------------------
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]
# -------------------------
# Itinerary Construction
# -------------------------
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] # (lon,lat)
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 # minutes per POI
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"] # [[lon,lat], ...]
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);
{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"
# -------------------------
# End-to-end Plan(日本語/非重複・構造化)
# -------------------------
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]:
# Mode決定(順序計算のためOSRMプロファイルを選ぶ)
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]
# LLMで「タイトル/ハイライト/実用メモ」だけをJSONで生成 → 整形
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)
# UI用(表・地図・本文)
table = [[s["Time"], s["Place"], s["Notes"]] for s in sched]
return {"map_html": map_html, "table": table, "narrative": narrative}