Update services/planner.py
Browse files- services/planner.py +22 -51
services/planner.py
CHANGED
|
@@ -54,13 +54,13 @@ def _category(meta: dict) -> str:
|
|
| 54 |
return "food"
|
| 55 |
if "museum" in tags or "gallery" in tags or "aquarium" in tags or "zoo" in tags or "culture" in tags:
|
| 56 |
return "culture"
|
| 57 |
-
if "park" in tags or "nature" in tags or "outdoor" in tags or "attraction" in tags:
|
| 58 |
return "nature"
|
| 59 |
if "temple" in tags or "shrine" in tags or "heritage" in tags or "place_of_worship" in name:
|
| 60 |
return "heritage"
|
| 61 |
if "performing_arts" in tags or "theatre" in tags or "cinema" in tags or "arts_centre" in tags:
|
| 62 |
return "performing_arts"
|
| 63 |
-
if "shopping" in tags:
|
| 64 |
return "shopping"
|
| 65 |
return "sightseeing"
|
| 66 |
|
|
@@ -83,18 +83,15 @@ def _score_doc(d: dict, center: Tuple[float, float], interests: List[str], weath
|
|
| 83 |
s += 0.6
|
| 84 |
if "outdoor" in tags and cond in ["sunny", "cloudy"]:
|
| 85 |
s += 0.6
|
| 86 |
-
# 距離ペナルティ(中心から離れすぎる候補を下げる)
|
| 87 |
try:
|
| 88 |
dkm = haversine((float(m["lat"]), float(m["lon"])), (center[0], center[1]))
|
| 89 |
-
s -= max(0.0, (dkm - 4.0)) * 0.15
|
| 90 |
except Exception:
|
| 91 |
pass
|
| 92 |
return s
|
| 93 |
|
| 94 |
def _preselect_by_quota(docs: List[dict], center: Tuple[float, float], interests: List[str], weather: dict,
|
| 95 |
hard_radius_km: float, quotas: Dict[str, int], cap: int) -> List[dict]:
|
| 96 |
-
"""多様性を担保しつつ件数上限まで先に絞る。"""
|
| 97 |
-
# まず距離フィルタ+チェーン保険除外
|
| 98 |
filtered = []
|
| 99 |
for d in docs:
|
| 100 |
m = d.get("meta") or {}
|
|
@@ -108,10 +105,8 @@ def _preselect_by_quota(docs: List[dict], center: Tuple[float, float], interests
|
|
| 108 |
continue
|
| 109 |
filtered.append(d)
|
| 110 |
|
| 111 |
-
# スコアで降順
|
| 112 |
sorted_docs = sorted(filtered, key=lambda x: _score_doc(x, center, interests, weather), reverse=True)
|
| 113 |
|
| 114 |
-
# クォータで選別
|
| 115 |
counts: Dict[str, int] = {}
|
| 116 |
out: List[dict] = []
|
| 117 |
for d in sorted_docs:
|
|
@@ -126,7 +121,6 @@ def _preselect_by_quota(docs: List[dict], center: Tuple[float, float], interests
|
|
| 126 |
|
| 127 |
# ---------------- Distance matrix & TSP ----------------
|
| 128 |
def _build_order_and_matrices(nodes: List[dict], profile: str):
|
| 129 |
-
"""OSRMの行列を取得し、TSP順序と経路ポリラインを構築"""
|
| 130 |
coords = [(n["lon"], n["lat"]) for n in nodes]
|
| 131 |
try:
|
| 132 |
durations, distances = distance_matrix(coords, profile=profile)
|
|
@@ -166,35 +160,28 @@ def generate_plan(rag, center: Tuple[float, float], hours: float, traveler: str,
|
|
| 166 |
move_mode = "Walk"
|
| 167 |
profile = "foot" if move_mode == "Walk" else "driving"
|
| 168 |
|
| 169 |
-
#
|
| 170 |
docs = gather_web_docs(center, interests or [], date, radius_km=web_radius_km)
|
| 171 |
if not docs:
|
| 172 |
return {"map_html": "", "table": [], "narrative": "周辺で公開データの候補を取得できませんでした。都市名や半径を広げて再試行してください。"}
|
| 173 |
|
| 174 |
-
# 1
|
| 175 |
dwell_default = max(MIN_DWELL_MIN, dwell_minutes_for_pace(pace))
|
| 176 |
-
# 1区間の平均移動を ~18分と仮置き
|
| 177 |
avg_leg_min = 18
|
| 178 |
-
n_cap = max(
|
| 179 |
|
| 180 |
-
#
|
| 181 |
quotas = {
|
| 182 |
-
"food": 2,
|
| 183 |
-
"
|
| 184 |
-
"
|
| 185 |
-
"heritage": 1,
|
| 186 |
-
"performing_arts": 1, # 劇場/映画館は1
|
| 187 |
-
"sightseeing": 2,
|
| 188 |
-
"shopping": 1
|
| 189 |
}
|
| 190 |
|
| 191 |
-
# 事前選別(距離上限+スコア+クォータ)
|
| 192 |
picked = _preselect_by_quota(
|
| 193 |
docs, center, interests or [], weather,
|
| 194 |
hard_radius_km=web_radius_km, quotas=quotas, cap=max(top_k, n_cap*3)
|
| 195 |
)
|
| 196 |
|
| 197 |
-
# ノード化 & 行列
|
| 198 |
nodes = [{"title": "Start", "lat": center[0], "lon": center[1], "notes": "Start/End", "meta": {}}]
|
| 199 |
for d in picked:
|
| 200 |
m = d.get("meta") or {}
|
|
@@ -203,13 +190,11 @@ def generate_plan(rag, center: Tuple[float, float], hours: float, traveler: str,
|
|
| 203 |
"lat": float(m["lat"]), "lon": float(m["lon"]),
|
| 204 |
"notes": (d.get("text") or "")[:160], "meta": m,
|
| 205 |
})
|
| 206 |
-
|
| 207 |
if len(nodes) <= 1:
|
| 208 |
return {"map_html": "", "table": [], "narrative": "候補が不足しました。条件を見直して再試行してください。"}
|
| 209 |
|
| 210 |
order, durations, distances, route = _build_order_and_matrices(nodes, profile)
|
| 211 |
|
| 212 |
-
# スケジューリング制約
|
| 213 |
counts: Dict[str, int] = {k:0 for k in quotas}
|
| 214 |
t = _minutes(start_time)
|
| 215 |
session_end = t + max(60, int(hours * 60))
|
|
@@ -220,23 +205,19 @@ def generate_plan(rag, center: Tuple[float, float], hours: float, traveler: str,
|
|
| 220 |
last_cat = None
|
| 221 |
visited: Set[int] = set([0])
|
| 222 |
|
| 223 |
-
#
|
| 224 |
-
move_cap_min = min(120, int((hours * 60) * 0.35))
|
| 225 |
|
| 226 |
def _place_visit(idx: int, forced_window: Tuple[int,int] | None = None) -> bool:
|
| 227 |
nonlocal t, prev, total_dist_km, total_dur_min, last_cat
|
| 228 |
|
| 229 |
meta = nodes[idx]["meta"]; cat = _category(meta)
|
| 230 |
|
| 231 |
-
# クォータ&連続規制
|
| 232 |
if counts.get(cat, 0) >= quotas.get(cat, 999):
|
| 233 |
return False
|
| 234 |
-
if last_cat == cat:
|
| 235 |
-
|
| 236 |
-
if cat in ("performing_arts", "culture"):
|
| 237 |
-
return False
|
| 238 |
|
| 239 |
-
#
|
| 240 |
cur = t
|
| 241 |
in_meal_window = (_within(cur, LUNCH_W, 45) or _within(cur, DINNER_W, 45))
|
| 242 |
if cat == "food":
|
|
@@ -245,37 +226,30 @@ def generate_plan(rag, center: Tuple[float, float], hours: float, traveler: str,
|
|
| 245 |
if not in_meal_window:
|
| 246 |
return False
|
| 247 |
|
| 248 |
-
#
|
| 249 |
d_m = float(distances[prev][idx]) if distances and distances[prev][idx] is not None else 0.0
|
| 250 |
d_s = float(durations[prev][idx]) if durations and durations[prev][idx] is not None else 0.0
|
| 251 |
d_km = max(0.0, d_m / 1000.0)
|
| 252 |
d_min = realistic_travel_minutes(d_km, d_s / 60.0, move_mode)
|
| 253 |
-
|
| 254 |
-
# 移動時間上限チェック(追加すると上限超なら却下)
|
| 255 |
if total_dur_min + d_min > move_cap_min:
|
| 256 |
return False
|
| 257 |
-
|
| 258 |
t += int(round(d_min)); total_dist_km += d_km; total_dur_min += d_min
|
| 259 |
|
| 260 |
-
#
|
| 261 |
if forced_window:
|
| 262 |
start_w, end_w = forced_window
|
| 263 |
else:
|
| 264 |
tw = infer_time_window(nodes[idx]["title"], meta)
|
| 265 |
start_w, end_w = tw.start_min, tw.end_min
|
| 266 |
-
|
| 267 |
-
# セッションとの重なり確認
|
| 268 |
if not (start_w < session_end and end_w > t):
|
| 269 |
return False
|
| 270 |
|
| 271 |
-
# 開始前は待機(残時間に配慮)
|
| 272 |
if t < start_w:
|
| 273 |
wait = start_w - t
|
| 274 |
if t + wait + MIN_DWELL_MIN > session_end:
|
| 275 |
return False
|
| 276 |
t += wait; total_dur_min += wait
|
| 277 |
|
| 278 |
-
# 滞在
|
| 279 |
dwell_default_local = max(MIN_DWELL_MIN, dwell_minutes_for_pace(pace))
|
| 280 |
dwell = int(meta.get("duration_min") or (45 if cat == "food" else dwell_default_local))
|
| 281 |
dwell = max(MIN_DWELL_MIN, min(120, dwell))
|
|
@@ -292,16 +266,15 @@ def generate_plan(rag, center: Tuple[float, float], hours: float, traveler: str,
|
|
| 292 |
"lat": nodes[idx]["lat"], "lon": nodes[idx]["lon"],
|
| 293 |
})
|
| 294 |
t = t_end; prev = idx; counts[cat] = counts.get(cat, 0) + 1
|
| 295 |
-
if cat == "performing_arts": # 単発に抑止
|
| 296 |
-
counts[cat] = quotas[cat]
|
| 297 |
last_cat = cat
|
| 298 |
visited.add(idx)
|
| 299 |
return True
|
| 300 |
|
| 301 |
-
#
|
| 302 |
restaurant_idxs: Set[int] = set(i for i in range(1, len(nodes)) if _category(nodes[i]["meta"]) == "food")
|
| 303 |
|
| 304 |
-
#
|
| 305 |
i = 1
|
| 306 |
placed_count = 0
|
| 307 |
while i < len(order) and placed_count < n_cap:
|
|
@@ -310,9 +283,8 @@ def generate_plan(rag, center: Tuple[float, float], hours: float, traveler: str,
|
|
| 310 |
i += 1
|
| 311 |
continue
|
| 312 |
|
| 313 |
-
#
|
| 314 |
cur = t
|
| 315 |
-
|
| 316 |
def _nearest_rest():
|
| 317 |
best, bestd = None, 1e18
|
| 318 |
for j in (restaurant_idxs - visited):
|
|
@@ -323,7 +295,6 @@ def generate_plan(rag, center: Tuple[float, float], hours: float, traveler: str,
|
|
| 323 |
if dd < bestd:
|
| 324 |
best, bestd = j, dd
|
| 325 |
return best
|
| 326 |
-
|
| 327 |
if counts["food"] < quotas["food"]:
|
| 328 |
if _within(cur, LUNCH_W, 45):
|
| 329 |
cand = _nearest_rest()
|
|
@@ -339,7 +310,6 @@ def generate_plan(rag, center: Tuple[float, float], hours: float, traveler: str,
|
|
| 339 |
# 通常訪問
|
| 340 |
if _place_visit(idx):
|
| 341 |
placed_count += 1
|
| 342 |
-
# 置けなければスキップ
|
| 343 |
i += 1
|
| 344 |
if t >= session_end:
|
| 345 |
break
|
|
@@ -364,7 +334,8 @@ def generate_plan(rag, center: Tuple[float, float], hours: float, traveler: str,
|
|
| 364 |
("\n".join(sched_lines) if sched_lines else "- (該当なし:日時や滞在時間、半径を調整してください)") +
|
| 365 |
f"\n\n## 実用メモ\n"
|
| 366 |
f"- 飲食は**ランチ/ディナー帯**に限定し、**チェーン店は除外**しています\n"
|
| 367 |
-
f"-
|
|
|
|
| 368 |
f"\n\n### 移動手段と費用の目安\n- モード: **{move_mode}**\n"
|
| 369 |
f"- 総移動距離: **{total_dist_km:.1f} km** / 所要: **約{total_dur_min:.0f} 分**(移動上限: {move_cap_min} 分)\n"
|
| 370 |
f"- 推定交通費: **¥{total_cost:.0f}**\n"
|
|
|
|
| 54 |
return "food"
|
| 55 |
if "museum" in tags or "gallery" in tags or "aquarium" in tags or "zoo" in tags or "culture" in tags:
|
| 56 |
return "culture"
|
| 57 |
+
if "park" in tags or "nature" in tags or "outdoor" in tags or "attraction" in tags or "garden" in tags:
|
| 58 |
return "nature"
|
| 59 |
if "temple" in tags or "shrine" in tags or "heritage" in tags or "place_of_worship" in name:
|
| 60 |
return "heritage"
|
| 61 |
if "performing_arts" in tags or "theatre" in tags or "cinema" in tags or "arts_centre" in tags:
|
| 62 |
return "performing_arts"
|
| 63 |
+
if "shopping" in tags or "marketplace" in tags:
|
| 64 |
return "shopping"
|
| 65 |
return "sightseeing"
|
| 66 |
|
|
|
|
| 83 |
s += 0.6
|
| 84 |
if "outdoor" in tags and cond in ["sunny", "cloudy"]:
|
| 85 |
s += 0.6
|
|
|
|
| 86 |
try:
|
| 87 |
dkm = haversine((float(m["lat"]), float(m["lon"])), (center[0], center[1]))
|
| 88 |
+
s -= max(0.0, (dkm - 4.0)) * 0.15
|
| 89 |
except Exception:
|
| 90 |
pass
|
| 91 |
return s
|
| 92 |
|
| 93 |
def _preselect_by_quota(docs: List[dict], center: Tuple[float, float], interests: List[str], weather: dict,
|
| 94 |
hard_radius_km: float, quotas: Dict[str, int], cap: int) -> List[dict]:
|
|
|
|
|
|
|
| 95 |
filtered = []
|
| 96 |
for d in docs:
|
| 97 |
m = d.get("meta") or {}
|
|
|
|
| 105 |
continue
|
| 106 |
filtered.append(d)
|
| 107 |
|
|
|
|
| 108 |
sorted_docs = sorted(filtered, key=lambda x: _score_doc(x, center, interests, weather), reverse=True)
|
| 109 |
|
|
|
|
| 110 |
counts: Dict[str, int] = {}
|
| 111 |
out: List[dict] = []
|
| 112 |
for d in sorted_docs:
|
|
|
|
| 121 |
|
| 122 |
# ---------------- Distance matrix & TSP ----------------
|
| 123 |
def _build_order_and_matrices(nodes: List[dict], profile: str):
|
|
|
|
| 124 |
coords = [(n["lon"], n["lat"]) for n in nodes]
|
| 125 |
try:
|
| 126 |
durations, distances = distance_matrix(coords, profile=profile)
|
|
|
|
| 160 |
move_mode = "Walk"
|
| 161 |
profile = "foot" if move_mode == "Walk" else "driving"
|
| 162 |
|
| 163 |
+
# 候補収集
|
| 164 |
docs = gather_web_docs(center, interests or [], date, radius_km=web_radius_km)
|
| 165 |
if not docs:
|
| 166 |
return {"map_html": "", "table": [], "narrative": "周辺で公開データの候補を取得できませんでした。都市名や半径を広げて再試行してください。"}
|
| 167 |
|
| 168 |
+
# 1日の現実的上限(滞在+移動)→ 長時間日は上限を緩和(〜10件)
|
| 169 |
dwell_default = max(MIN_DWELL_MIN, dwell_minutes_for_pace(pace))
|
|
|
|
| 170 |
avg_leg_min = 18
|
| 171 |
+
n_cap = max(4, min(10, int((hours * 60) / (dwell_default + avg_leg_min))))
|
| 172 |
|
| 173 |
+
# 多様性クォータ
|
| 174 |
quotas = {
|
| 175 |
+
"food": 2, "culture": 2, "nature": 2,
|
| 176 |
+
"heritage": 1, "performing_arts": 1,
|
| 177 |
+
"sightseeing": 2, "shopping": 2
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
}
|
| 179 |
|
|
|
|
| 180 |
picked = _preselect_by_quota(
|
| 181 |
docs, center, interests or [], weather,
|
| 182 |
hard_radius_km=web_radius_km, quotas=quotas, cap=max(top_k, n_cap*3)
|
| 183 |
)
|
| 184 |
|
|
|
|
| 185 |
nodes = [{"title": "Start", "lat": center[0], "lon": center[1], "notes": "Start/End", "meta": {}}]
|
| 186 |
for d in picked:
|
| 187 |
m = d.get("meta") or {}
|
|
|
|
| 190 |
"lat": float(m["lat"]), "lon": float(m["lon"]),
|
| 191 |
"notes": (d.get("text") or "")[:160], "meta": m,
|
| 192 |
})
|
|
|
|
| 193 |
if len(nodes) <= 1:
|
| 194 |
return {"map_html": "", "table": [], "narrative": "候補が不足しました。条件を見直して再試行してください。"}
|
| 195 |
|
| 196 |
order, durations, distances, route = _build_order_and_matrices(nodes, profile)
|
| 197 |
|
|
|
|
| 198 |
counts: Dict[str, int] = {k:0 for k in quotas}
|
| 199 |
t = _minutes(start_time)
|
| 200 |
session_end = t + max(60, int(hours * 60))
|
|
|
|
| 205 |
last_cat = None
|
| 206 |
visited: Set[int] = set([0])
|
| 207 |
|
| 208 |
+
move_cap_min = min(120, int((hours * 60) * 0.35)) # 総移動時間上限
|
|
|
|
| 209 |
|
| 210 |
def _place_visit(idx: int, forced_window: Tuple[int,int] | None = None) -> bool:
|
| 211 |
nonlocal t, prev, total_dist_km, total_dur_min, last_cat
|
| 212 |
|
| 213 |
meta = nodes[idx]["meta"]; cat = _category(meta)
|
| 214 |
|
|
|
|
| 215 |
if counts.get(cat, 0) >= quotas.get(cat, 999):
|
| 216 |
return False
|
| 217 |
+
if last_cat == cat and cat in ("performing_arts", "culture"):
|
| 218 |
+
return False
|
|
|
|
|
|
|
| 219 |
|
| 220 |
+
# 飲食はランチ/ディナーのみ
|
| 221 |
cur = t
|
| 222 |
in_meal_window = (_within(cur, LUNCH_W, 45) or _within(cur, DINNER_W, 45))
|
| 223 |
if cat == "food":
|
|
|
|
| 226 |
if not in_meal_window:
|
| 227 |
return False
|
| 228 |
|
| 229 |
+
# 移動
|
| 230 |
d_m = float(distances[prev][idx]) if distances and distances[prev][idx] is not None else 0.0
|
| 231 |
d_s = float(durations[prev][idx]) if durations and durations[prev][idx] is not None else 0.0
|
| 232 |
d_km = max(0.0, d_m / 1000.0)
|
| 233 |
d_min = realistic_travel_minutes(d_km, d_s / 60.0, move_mode)
|
|
|
|
|
|
|
| 234 |
if total_dur_min + d_min > move_cap_min:
|
| 235 |
return False
|
|
|
|
| 236 |
t += int(round(d_min)); total_dist_km += d_km; total_dur_min += d_min
|
| 237 |
|
| 238 |
+
# 窓
|
| 239 |
if forced_window:
|
| 240 |
start_w, end_w = forced_window
|
| 241 |
else:
|
| 242 |
tw = infer_time_window(nodes[idx]["title"], meta)
|
| 243 |
start_w, end_w = tw.start_min, tw.end_min
|
|
|
|
|
|
|
| 244 |
if not (start_w < session_end and end_w > t):
|
| 245 |
return False
|
| 246 |
|
|
|
|
| 247 |
if t < start_w:
|
| 248 |
wait = start_w - t
|
| 249 |
if t + wait + MIN_DWELL_MIN > session_end:
|
| 250 |
return False
|
| 251 |
t += wait; total_dur_min += wait
|
| 252 |
|
|
|
|
| 253 |
dwell_default_local = max(MIN_DWELL_MIN, dwell_minutes_for_pace(pace))
|
| 254 |
dwell = int(meta.get("duration_min") or (45 if cat == "food" else dwell_default_local))
|
| 255 |
dwell = max(MIN_DWELL_MIN, min(120, dwell))
|
|
|
|
| 266 |
"lat": nodes[idx]["lat"], "lon": nodes[idx]["lon"],
|
| 267 |
})
|
| 268 |
t = t_end; prev = idx; counts[cat] = counts.get(cat, 0) + 1
|
| 269 |
+
if cat == "performing_arts": counts[cat] = quotas[cat] # 単発に抑止
|
|
|
|
| 270 |
last_cat = cat
|
| 271 |
visited.add(idx)
|
| 272 |
return True
|
| 273 |
|
| 274 |
+
# レストラン候補
|
| 275 |
restaurant_idxs: Set[int] = set(i for i in range(1, len(nodes)) if _category(nodes[i]["meta"]) == "food")
|
| 276 |
|
| 277 |
+
# 最大件数まで
|
| 278 |
i = 1
|
| 279 |
placed_count = 0
|
| 280 |
while i < len(order) and placed_count < n_cap:
|
|
|
|
| 283 |
i += 1
|
| 284 |
continue
|
| 285 |
|
| 286 |
+
# ランチ/ディナー帯の自動挿入
|
| 287 |
cur = t
|
|
|
|
| 288 |
def _nearest_rest():
|
| 289 |
best, bestd = None, 1e18
|
| 290 |
for j in (restaurant_idxs - visited):
|
|
|
|
| 295 |
if dd < bestd:
|
| 296 |
best, bestd = j, dd
|
| 297 |
return best
|
|
|
|
| 298 |
if counts["food"] < quotas["food"]:
|
| 299 |
if _within(cur, LUNCH_W, 45):
|
| 300 |
cand = _nearest_rest()
|
|
|
|
| 310 |
# 通常訪問
|
| 311 |
if _place_visit(idx):
|
| 312 |
placed_count += 1
|
|
|
|
| 313 |
i += 1
|
| 314 |
if t >= session_end:
|
| 315 |
break
|
|
|
|
| 334 |
("\n".join(sched_lines) if sched_lines else "- (該当なし:日時や滞在時間、半径を調整してください)") +
|
| 335 |
f"\n\n## 実用メモ\n"
|
| 336 |
f"- 飲食は**ランチ/ディナー帯**に限定し、**チェーン店は除外**しています\n"
|
| 337 |
+
f"- オフィス/行政/コンベンション等は**候補から除外**しています\n"
|
| 338 |
+
f"- 長時間プランでは**立ち寄り件数の上限を緩和**しています\n"
|
| 339 |
f"\n\n### 移動手段と費用の目安\n- モード: **{move_mode}**\n"
|
| 340 |
f"- 総移動距離: **{total_dist_km:.1f} km** / 所要: **約{total_dur_min:.0f} 分**(移動上限: {move_cap_min} 分)\n"
|
| 341 |
f"- 推定交通費: **¥{total_cost:.0f}**\n"
|