Corin1998 commited on
Commit
69abb02
·
verified ·
1 Parent(s): fc2737c

Update services/planner.py

Browse files
Files changed (1) hide show
  1. 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 # 4km超は緩やかに減点
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
- # 候補収集(Web only 推奨)
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(3, min(6, int((hours * 60) / (dwell_default + avg_leg_min))))
179
 
180
- # カテゴリ・クォータ(多様性)
181
  quotas = {
182
- "food": 2, # 昼・夜を想定(時間外は基本入れない)
183
- "culture": 2, # 美術館/博物館は最大2
184
- "nature": 1,
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
- # 1日の移動時間上限(総時間の35%または120分の小さい方)
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
- # 同カテゴリ連続を抑制(foodは別処理、performing_arts と culture は厳しめ)
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
- # 時間窓(推定 or 強制)
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
- # ランチ/ディナー帯なら近場の飲食を優先で1件差し込み(未達成なら)
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"- 美術館・劇場は**件数上限**と**連続禁止**で疲労・移動過多を抑制\n"
 
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"