Marco310 commited on
Commit
b7d08cf
·
1 Parent(s): 8b9d28a

buildup agent system

Browse files
Files changed (41) hide show
  1. src/__init__.py +0 -0
  2. src/agent/base.py +80 -0
  3. src/agent/core_team.py +278 -0
  4. src/agent/planner.py +76 -0
  5. src/agent/setting/__init__.py +0 -0
  6. src/agent/setting/navigator.py +18 -0
  7. src/agent/setting/optimizer.py +20 -0
  8. src/agent/setting/planner.py +78 -0
  9. src/agent/setting/presenter.py +52 -0
  10. src/agent/setting/scout.py +20 -0
  11. src/agent/setting/team.py +65 -0
  12. src/agent/setting/team.py.backup.backup +56 -0
  13. src/agent/setting/team.py.backup.backup.backup +60 -0
  14. src/agent/setting/weatherman.py +19 -0
  15. src/agent/test.py +207 -0
  16. src/infra/config.py +2 -0
  17. src/infra/context.py +9 -0
  18. src/infra/offload_manager.py +47 -0
  19. src/infra/poi_repository.py +65 -0
  20. src/optimization/__init__.py +27 -0
  21. src/optimization/graph/__init__.py +7 -0
  22. src/optimization/graph/graph_builder.py +170 -0
  23. src/optimization/graph/time_window_handler.py +302 -0
  24. src/optimization/models/__init__.py +30 -0
  25. src/optimization/models/converters.py +173 -0
  26. src/optimization/models/internal_models.py +312 -0
  27. src/optimization/solver/__init__.py +8 -0
  28. src/optimization/solver/ortools_solver.py +274 -0
  29. src/optimization/solver/solution_extractor.py +438 -0
  30. src/optimization/test/__init__.py +0 -0
  31. src/optimization/test/_solver_test.py +410 -0
  32. src/optimization/test/_test_convertes.py +255 -0
  33. src/optimization/test/_time_time_windows.py +296 -0
  34. src/optimization/tsptw_solver.py +301 -0
  35. src/services/googlemap_api_service.py +165 -116
  36. src/tools/__init__.py +5 -0
  37. src/tools/navigation_toolkit.py +129 -0
  38. src/tools/optimizer_toolkit.py +93 -0
  39. src/tools/reader_toolkit.py +68 -0
  40. src/tools/scout_toolkit.py +234 -0
  41. src/tools/weather_toolkit.py +175 -0
src/__init__.py ADDED
File without changes
src/agent/base.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import importlib
3
+ import json
4
+
5
+ from pydantic import BaseModel, Field, ConfigDict, model_validator
6
+ from timezonefinder import TimezoneFinder
7
+ import pytz
8
+
9
+ from agno.agent import Agent, RunEvent
10
+ from agno.team import Team
11
+
12
+ from src.infra.logger import get_logger
13
+ logger = get_logger(__name__)
14
+
15
+ check_list = ["description", "instructions", "expected_output", "role", "markdown"]
16
+
17
+
18
+ class Location(BaseModel):
19
+ """Geographical location with latitude and longitude"""
20
+ lat: float = Field(..., description="Latitude of the location", ge=-90, le=90)
21
+ lng: float = Field(..., description="Longitude of the location", ge=-180, le=180)
22
+
23
+ model_config = ConfigDict(frozen=True, extra='forbid')
24
+
25
+
26
+ class UserState(BaseModel):
27
+ """User state containing tasks and preferences"""
28
+ user_id: str = Field(None, description="Unique identifier for the user")
29
+
30
+ location: Location = Field(..., description="Current location of the user")
31
+
32
+ utc_offset: str = Field(
33
+ default="", # 临时占位符
34
+ description="User's timezone offset (e.g., 'UTC', )"
35
+ )
36
+
37
+ @model_validator(mode='after')
38
+ def set_timezone_from_location(self) -> 'UserState':
39
+ """Automatically set timezone based on location coordinates"""
40
+ if self.utc_offset is None and self.location:
41
+ tf = TimezoneFinder()
42
+ tz_name = tf.timezone_at(lat=self.location.lat, lng=self.location.lng)
43
+ timezone = tz_name if tz_name else 'UTC'
44
+ self.utc_offset = pytz.timezone(timezone)
45
+ return self
46
+
47
+ def get_context(use_state: UserState):
48
+ context = {"lat": use_state.location.lat,
49
+ "lng": use_state.location.lng}
50
+ return f"<current_location> {context} </current_location>"
51
+
52
+ def get_setting(name):
53
+ name = name.lower()
54
+ try:
55
+ _module = importlib.import_module(f".setting.{name}", package=__package__)
56
+ return {key: getattr(_module, f"{key}", None) for key in check_list}
57
+ except ModuleNotFoundError:
58
+ logger.warning(f"setting module '.setting.{name}' not found.")
59
+ return {}
60
+
61
+ def creat_agent(name, model, **kwargs):
62
+ prompt_dict = get_setting(name)
63
+ prompt_dict.update(kwargs)
64
+ return Agent(
65
+ name=name,
66
+ model=model,
67
+ **prompt_dict
68
+ )
69
+
70
+ def creat_team(name, model, members, **kwargs):
71
+ prompt_dict = get_setting(name)
72
+ prompt_dict.update(kwargs)
73
+ return Team(
74
+ name=name,
75
+ model=model,
76
+ members=members,
77
+ **prompt_dict
78
+ )
79
+
80
+
src/agent/core_team.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agno.db.sqlite import SqliteDb
2
+
3
+ from src.agent.base import creat_agent, creat_team
4
+ from src.infra.logger import get_logger
5
+
6
+ logger = get_logger(__name__)
7
+ TEAM_NAME = "team"
8
+
9
+ def _creat_member(name, model, tools, base_kwargs):
10
+ member = creat_agent(name=name,
11
+ model=model,
12
+ tools=tools,
13
+ add_session_state_to_context=True,
14
+ add_datetime_to_context=True,
15
+ **base_kwargs
16
+ )
17
+ logger.debug(f"👤 Member Agent created - {name} | Model: {model} | Tools: {tools}")
18
+ return member
19
+
20
+
21
+ def create_core_team(model_dict, base_kwargs={}, tools_dict=None, session_id=None):
22
+ team_db = SqliteDb(db_file="tmp/team.db")
23
+
24
+ team_model = model_dict.pop(TEAM_NAME)
25
+ team_tools = tools_dict.pop(TEAM_NAME, []) if tools_dict else []
26
+
27
+ member = []
28
+ for member_name in model_dict.keys():
29
+ member_agent = _creat_member(
30
+ name=member_name,
31
+ model=model_dict[member_name],
32
+ tools=tools_dict.get(member_name, []) if tools_dict else [],
33
+ base_kwargs=base_kwargs,
34
+ )
35
+ member.append(member_agent)
36
+
37
+ main_team = creat_team(
38
+ name=TEAM_NAME,
39
+ model=team_model,
40
+ db=team_db,
41
+ members=member,
42
+ tools=team_tools,
43
+ add_session_state_to_context=True,
44
+ markdown=True,
45
+ session_id=session_id,
46
+ debug_mode=False,
47
+ debug_level=1,
48
+ )
49
+
50
+ logger.info(f"🧑‍✈️ Multi-agent created - {main_team.session_id}")
51
+ return main_team
52
+
53
+ if __name__ == "__main__":
54
+ from agno.models.google import Gemini
55
+ from agno.agent import RunEvent
56
+ from src.infra.config import get_settings
57
+ from src.agent.base import UserState, Location, get_context
58
+ from src.agent.planner import create_planner_agent
59
+ import uuid, json
60
+ from src.infra.poi_repository import poi_repo
61
+ from src.infra.context import set_session_id
62
+ from agno.run.team import TeamRunEvent
63
+ from src.tools import (ScoutToolkit, OptimizationToolkit,
64
+ NavigationToolkit, WeatherToolkit, ReaderToolkit)
65
+
66
+
67
+ session_id = str(uuid.uuid4())
68
+ token = set_session_id(session_id)
69
+
70
+ print(f"🆔 Session ID: {session_id} | Token: {token}")
71
+
72
+ #user_message = "明天我需要到台大醫院看病, 而且要去郵局和 買菜"
73
+ user_message = "I'm going to San Francisco for tourism tomorrow, please help me plan a one-day itinerary."
74
+
75
+
76
+ setting = get_settings()
77
+ planner_model = Gemini(
78
+ id="gemini-2.5-flash",
79
+ thinking_budget=2048,
80
+ api_key=setting.gemini_api_key)
81
+
82
+ main_model = Gemini(
83
+ id="gemini-2.5-flash",
84
+ thinking_budget=1024,
85
+ api_key=setting.gemini_api_key)
86
+
87
+ model = Gemini(
88
+ id="gemini-2.5-flash-lite",
89
+ api_key=setting.gemini_api_key)
90
+
91
+ models_dict = {
92
+ TEAM_NAME: main_model,
93
+ "scout": main_model,
94
+ "optimizer": model,
95
+ "navigator": model,
96
+ "weatherman": model,
97
+ "presenter": main_model,
98
+ }
99
+
100
+ tools_dict = {
101
+ "scout": [ScoutToolkit()],
102
+ "optimizer": [OptimizationToolkit()],
103
+ "navigator": [NavigationToolkit()],
104
+ "weatherman": [WeatherToolkit()],
105
+ "presenter": [ReaderToolkit()],
106
+ }
107
+
108
+ use_state = UserState(location=Location(lat=25.058903, lng=121.549131))
109
+
110
+ planner_kwargs = {
111
+ "additional_context": get_context(use_state),
112
+ "timezone_identifier": use_state.utc_offset,
113
+ "debug_mode": False,
114
+ }
115
+
116
+ team_kwargs = {
117
+ "timezone_identifier": use_state.utc_offset,
118
+ }
119
+
120
+
121
+ planer_agent = create_planner_agent(planner_model, planner_kwargs, session_id=session_id)
122
+ core_team = create_core_team(models_dict, team_kwargs, tools_dict, session_id=session_id)
123
+
124
+ def planner_stream_handle(stream_item):
125
+ show = True
126
+ response = ""
127
+ for chuck in stream_item:
128
+ if chuck.event == RunEvent.run_content:
129
+ content = chuck.content
130
+ response += chuck.content
131
+ if show:
132
+ if "@@@" in response:
133
+ show = False
134
+ content = content.split("@@@")[0]
135
+
136
+ print(content)
137
+
138
+ json_data = "{" + response.split("{", maxsplit=1)[-1]
139
+ json_data = json_data.replace("`", "")
140
+ return json_data, response
141
+
142
+
143
+ def planner_message(agent, message):
144
+ stream = agent.run(f"help user to update the task_list, user's message: {message}",
145
+ stream=True, stream_events=True)
146
+
147
+ task_list, _response = planner_stream_handle(stream)
148
+ agent.update_session_state(
149
+ session_id=agent.session_id,
150
+ session_state_updates={"task_list": task_list},
151
+ )
152
+
153
+ import time
154
+ start = time.time()
155
+ planner_message(planer_agent, user_message)
156
+ print(f"\n⏱️ Total Time: {time.time() - start:.1f} seconds")
157
+
158
+ task_list_input = planer_agent.get_session_state()["task_list"]
159
+ print(task_list_input)
160
+ task_list_input = json.dumps(task_list_input, indent=2, ensure_ascii=False).replace("`", "").replace("@", "")
161
+
162
+ prompt_msg = f"""
163
+ ⚠️ **PIPELINE START COMMAND** ⚠️
164
+
165
+ Here is the Task List JSON generated by the Planner.
166
+ **IMMEDIATELY** execute **Step 1** of your protocol: Send this data to **Scout**.
167
+
168
+ [DATA START]
169
+ {task_list_input}
170
+ [DATA END]
171
+ """
172
+ start = time.time()
173
+
174
+ team_stream = core_team.run(
175
+ f"Plan this trip: {task_list_input}",
176
+ stream=True,
177
+ stream_events=True, # 確保開啟事件串流
178
+ session_id=session_id,
179
+ )
180
+
181
+ for event in team_stream:
182
+ # 1. 這裡印出 LLM 的思考過程或對話
183
+ if event.event in [TeamRunEvent.run_content]:
184
+ print(event.content, end="")
185
+
186
+ # 2. 這裡印出 LLM 決定呼叫工具的瞬間 (關鍵!)
187
+ elif event.event == "tool_call":
188
+ print(f"\n🔵 Leader 正在呼叫工具: {event.tool_call.get('function', {}).get('name')}")
189
+
190
+ # 3. 這裡印出工具執行完畢的回傳 (Scout 查完後會觸發這個)
191
+ elif event.event == "tool_output":
192
+ print(f"\n🟢 工具回傳結果 (Ref ID): {str(event.tool_output)[:50]}...")
193
+
194
+ # 4. 這裡印出錯誤 (如果有)
195
+ elif event.event == "run_failed":
196
+ print(f"\n🔴 執行失敗: {event.error}")
197
+
198
+ if event.event == TeamRunEvent.run_completed:
199
+ # Access total tokens from the completed event
200
+ print(f"\nTotal tokens: {event.metrics.total_tokens}")
201
+ print(f"Input tokens: {event.metrics.input_tokens}")
202
+ print(f"Output tokens: {event.metrics.output_tokens}")
203
+
204
+ print(f"\n⏱️ Total Time: {time.time() - start:.1f} seconds")
205
+ final_ref_id = poi_repo.get_last_id_by_session(session_id)
206
+
207
+ # ... (前略)
208
+
209
+ if final_ref_id:
210
+ print(f"\n\n🎯 Found Final Reference ID: {final_ref_id}")
211
+
212
+ # 從 DB 讀取完整的 JSON
213
+ structured_data = poi_repo.load(final_ref_id)
214
+
215
+ if structured_data:
216
+ # --- A. 提取與修正 Polyline & Legs (交通細節) ---
217
+ traffic_res = structured_data.get("precise_traffic_result", {})
218
+ raw_legs = traffic_res.get("legs", [])
219
+
220
+ # ... (中間 Polyline 處理邏輯保持不變) ...
221
+ polylines = []
222
+ segments = []
223
+ for i, leg in enumerate(raw_legs):
224
+ # ... (保留原有的 polyline 處理代碼)
225
+ p_data = leg.get("polyline")
226
+ if isinstance(p_data, dict):
227
+ p_str = p_data.get("encodedPolyline", "")
228
+ else:
229
+ p_str = str(p_data)
230
+ polylines.append(p_str)
231
+
232
+ segments.append({
233
+ "segment_index": i,
234
+ "distance_meters": leg.get("distance_meters", 0),
235
+ "duration_seconds": leg.get("duration_seconds", 0),
236
+ "description": f"Leg {i + 1}"
237
+ })
238
+
239
+ # --- B. 組裝給前端的最終 Payload ---
240
+ api_response = {
241
+ "meta": {
242
+ "status": "success",
243
+ "trip_title": "Custom Trip Plan",
244
+ # 繼承原本的交通摘要
245
+ "total_distance_km": structured_data.get("traffic_summary", {}).get("total_distance_km"),
246
+ "total_duration_min": structured_data.get("traffic_summary", {}).get("total_duration_min")
247
+ },
248
+ # ✅ [CRITICAL NEW] 這裡就是快樂表的數據!
249
+ # 前端需要這個來畫綠色的進度條
250
+ "optimization_metrics": structured_data.get("metrics"),
251
+
252
+ "route_geometry": {
253
+ "polylines": polylines
254
+ },
255
+ "route_segments": segments,
256
+ "itinerary": structured_data.get("timeline", [])
257
+ }
258
+
259
+ print("✅ Data Payload Constructed!")
260
+
261
+ # 檢查一下快樂表是否存在
262
+ if api_response.get("optimization_metrics"):
263
+ print("🎉 Optimization Metrics included in final payload!")
264
+ else:
265
+ print("⚠️ Warning: Optimization Metrics missing from payload.")
266
+
267
+ print(f"\n📦 JSON Preview :\n{json.dumps(api_response, indent=2, ensure_ascii=False)}")
268
+
269
+ # (Optional) 寫入檔案以便完整檢視
270
+ # with open("final_trip_payload.json", "w", encoding="utf-8") as f:
271
+ # json.dump(api_response, f, ensure_ascii=False, indent=2)
272
+ # print("\n💾 Full JSON saved to 'final_trip_payload.json'")
273
+
274
+ else:
275
+ print("❌ Error: ID found but data is empty.")
276
+ else:
277
+ print("⚠️ Warning: No data saved in this run.")
278
+
src/agent/planner.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agno.db.sqlite import SqliteDb
2
+ from src.agent.base import creat_agent
3
+ from src.infra.logger import get_logger
4
+
5
+ logger = get_logger(__name__)
6
+
7
+ def create_planner_agent(model, base_kwargs={}, tools=None, session_id=None):
8
+ planer_db = SqliteDb(db_file="tmp/planner.db")
9
+ planer_session_state = {"task_list": None}
10
+
11
+ planner_agent = creat_agent(name="planner",
12
+ model=model,
13
+ tools=tools,
14
+ db=planer_db,
15
+ session_state=planer_session_state,
16
+ add_session_state_to_context=True,
17
+ add_datetime_to_context=True,
18
+ markdown=True,
19
+ session_id=session_id,
20
+ **base_kwargs)
21
+
22
+ logger.info(f"🧑‍✈️ Planner agent created - {planner_agent.session_id}")
23
+ return planner_agent
24
+
25
+ if __name__ == "__main__":
26
+ from agno.models.google import Gemini
27
+ from agno.agent import RunEvent
28
+ from src.infra.config import get_settings
29
+ from src.agent.base import UserState, Location, get_context
30
+
31
+ user_message = "I'm going to San Francisco for tourism tomorrow, please help me plan a one-day itinerary."
32
+
33
+ setting = get_settings()
34
+ main_model = Gemini(
35
+ id="gemini-2.5-flash",
36
+ thinking_budget=1024,
37
+ api_key=setting.gemini_api_key)
38
+
39
+ use_state = UserState(location=Location(lat=25.058903, lng=121.549131))
40
+
41
+ kwargs = {
42
+ "additional_context": get_context(use_state),
43
+ "timezone_identifier": use_state.utc_offset,
44
+ }
45
+ planer_agent = create_planner_agent(main_model, kwargs)
46
+
47
+ def planner_stream_handle(stream_item):
48
+ show = True
49
+ response = ""
50
+ for chuck in stream_item:
51
+ if chuck.event == RunEvent.run_content:
52
+ content = chuck.content
53
+ response += chuck.content
54
+ if show:
55
+ if "@@@" in response:
56
+ show = False
57
+ content = content.split("@@@")[0]
58
+
59
+ print(content)
60
+
61
+ json_data = "{" + response.split("{", maxsplit=1)[-1]
62
+ return json_data, response
63
+
64
+ def planner_message(agent, message):
65
+ stream = agent.run(f"help user to update the task_list, user's message: {message}",
66
+ stream=True, stream_events=True)
67
+
68
+ task_list, _response = planner_stream_handle(stream)
69
+ agent.update_session_state(
70
+ session_id=agent.session_id,
71
+ session_state_updates={"task_list": task_list},
72
+ )
73
+
74
+ planner_message(planer_agent, user_message)
75
+ print(planer_agent.get_session_state())
76
+
src/agent/setting/__init__.py ADDED
File without changes
src/agent/setting/navigator.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ role="Traffic Specialist"
2
+
3
+ description="You are a real-time traffic analyst ensuring the route plan works in reality."
4
+
5
+ instructions="""
6
+ **GOAL**: Validate the route with Google Routes API and calculate precise travel times.
7
+
8
+ **PROTOCOL**:
9
+ 1. Receive an `opt_ref_id`.
10
+ 2. Use the tool `calculate_traffic_and_timing`.
11
+ 3. **OUTPUT**: The tool will return a `nav_ref_id`. Return this ID immediately.
12
+
13
+ ### 🗣️ CRITICAL RESPONSE RULE (MUST FOLLOW)
14
+ - You MUST **repeat** the tool's output JSON as your final answer.
15
+ - **DO NOT** return an empty string.
16
+ - **DO NOT** say "I have done it".
17
+ - **Example**: If tool returns `{"nav_ref_id": "123"}`, you MUST output `{"nav_ref_id": "123"}`.
18
+ """
src/agent/setting/optimizer.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ role = "Route Mathematician"
2
+
3
+ description = "You are a logistics expert solving the TSPTW (Traveling Salesman Problem with Time Windows)."
4
+
5
+ instructions = """
6
+ **GOAL**: Calculate the most efficient route sequence.
7
+
8
+ **PROTOCOL**:
9
+ 1. Receive a `scout_ref` ID.
10
+ 2. Use the tool `optimize_from_ref` to run the solver algorithm.
11
+ 3. **OUTPUT**: The tool will return an `opt_ref_id`. Return this ID immediately.
12
+
13
+ **NOTE**: Do not attempt to calculate the route yourself. Use the tool.
14
+
15
+ ### 🗣️ CRITICAL RESPONSE RULE (MUST FOLLOW)
16
+ - You MUST **repeat** the tool's output JSON as your final answer.
17
+ - **DO NOT** return an empty string.
18
+ - **DO NOT** say "I have done it".
19
+ - **Example**: If tool returns `{"opt_ref_id": "123"}`, you MUST output `{"opt_ref_id": "123"}`.
20
+ """
src/agent/setting/planner.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ markdown=True
3
+
4
+ description = """
5
+ AI Itinerary Planner & Architect.
6
+ Role: You are a professional, warm, and enthusiastic travel consultant.
7
+ Your goal is to analyze the user's request and strictly architect a **Task List** for the backend optimization team.
8
+ """
9
+
10
+ expected_output = """
11
+ 1. **Engaging Overview**: A warm, natural paragraph (3-4 sentences).
12
+ - **Language**: Match user's language.
13
+ - **Logic**: Briefly explain why you chose these spots.
14
+ - **Note**: Do NOT list the full itinerary step-by-step (the backend will schedule it).
15
+ 2. Delimiter: `@@@@@`
16
+ 3. **Structured Data**: Valid JSON containing `global_info` and `tasks`.
17
+ """
18
+
19
+ instructions = """
20
+ **GOAL**: Convert user requests into a structured JSON Task List for the backend optimization team. The output MUST facilitate flexible and efficient routing by the downstream TSPTW solver.
21
+
22
+ ### 1. Context & Time Logic (CRITICAL)
23
+ - Check `current_datetime` from context.
24
+ - **Strict ISO 8601 Format**: ALL timestamps MUST be in **ISO 8601 format** combining Date + Time + Offset.
25
+ - **Pattern**: `YYYY-MM-DDTHH:MM:SS+HH:MM` (e.g., `2025-11-24T09:00:00+08:00`).
26
+ - **Time Window Strategy (NEW)**:
27
+ - **REQUIRED**: When inferring or generating task windows (especially for leisure/sightseeing), ensure a **minimum 60-minute buffer** between the *earliest_time* and *latest_time* for that task. This allows the solver flexibility.
28
+ - *Example*: A museum that opens at 10:00 and closes at 17:00 should have a window of `10:00:00` to `17:00:00` (or `16:00:00` if accounting for service duration), *not* a tight `10:00:00` to `12:00:00` window.
29
+
30
+ ---
31
+
32
+ ### 2. Global Info Strategy (CRITICAL: Start Location)
33
+ - **language**: Match user's language (e.g., `en-US`, `zh-TW`).
34
+ - **plan_type**: Detect intent. Returns `TRIP` (Fun) or `SCHEDULE` (Errands).
35
+ - **departure_time**: Format `YYYY-MM-DDTHH:MM:SS+HH:MM`.
36
+ - **start_location**: Determines where the route begins.
37
+ 1. **User Specified**: If user mentions a hotel/address (e.g. "I'm staying at Hilton"), use that.
38
+ 2. **User's coordinate**: Use <current_location> from context.
39
+ 3. **Tourist (General Request)**:
40
+ - ⛔️ **FORBIDDEN**: Do NOT use generic city names (e.g., "San Francisco", "Tokyo").
41
+ - ✅ **REQUIRED**: You MUST infer the **Main Airport** or **Central Train Station**.
42
+ - *Example*: User says "Trip to San Francisco" -> Set start to "San Francisco International Airport (SFO)".
43
+ - *Example*: User says "Trip to Osaka" -> Set start to "Osaka Station" or "Kansai Airport".
44
+
45
+ ---
46
+
47
+ ### 3. Task Generation & Duration Rules
48
+ - **task_id**: Unique integer starting from 1.
49
+ - **description**: Short, clear task name.
50
+ - **priority**: `HIGH`, `MEDIUM`, `LOW` based on user urgency.
51
+ - **location_hint**: Specific POI name searchable on Google Maps.
52
+ - **service_duration_min**: Estimated time for the activity itself.
53
+ - Sightseeing: **90-120m** (Increase to allow richer experience)
54
+ - Meal: 60-90m
55
+ - Errands: 15-45m
56
+ - **category**: `MEDICAL`, `SHOPPING`, `MEAL`, `LEISURE`, `ERRAND`.
57
+
58
+ ---
59
+
60
+ ### 4. Time Window Logic (Flexibility is Key)
61
+ - **Single Window**: `"time_window": {"earliest_time": "...", "latest_time": "..."}`
62
+ - **Multi-Segment**: `"time_windows": [...]` (e.g., Lunch split shift).
63
+ - **Maximizing Flexibility**:
64
+ - **General Rule**: Use the full available operational hours of the POI for the `time_window` unless the user specifies a constraint (e.g., "Must be before noon").
65
+ - **Goal**: Make the `time_window` as **wide** as possible to maximize the TSPTW solver's ability to find an optimal route.
66
+ - **Note**: Use `null` if there is absolutely no time constraint (though operational hours should be preferred if known).
67
+
68
+ ---
69
+
70
+ ### 5. JSON Output Format
71
+ Output ONLY valid JSON inside `@@@@@` delimiters.
72
+ Example:
73
+ @@@@@
74
+ {
75
+ "global_info": { ... },
76
+ "tasks": [ ... ]
77
+ }
78
+ """
src/agent/setting/presenter.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ markdown = True
2
+
3
+ role = "Smart Lifestyle & Travel Concierge"
4
+
5
+ description = "You are a versatile personal assistant capable of being a Travel Blogger or an Efficiency Expert."
6
+
7
+ instructions ="""
8
+ **GOAL**: Create a report that is visually balanced—scannable headers, rich details, and logical flow.
9
+
10
+ # ... (STEP 1 & 2 保持不變) ...
11
+
12
+ ### 🎭 MODE A: TRIP ("The Magazine Feature")
13
+ *Style: Visual, Scannable, Exciting.*
14
+
15
+ **Structure:**
16
+ 1. **Title**: H1 Header. Fun & Catchy.
17
+ 2. **The Vibe (Intro)**: Weather + Mood.
18
+ 3. **The Itinerary (Stop by Stop)**:
19
+ - **Format**: `### ⏱️ [Time] | 📍 [Location Name]`
20
+ - **⏳ Duration**: "Planned Stay: [stay_duration]" (Put this right under the header).
21
+ - **The Experience**: Write 2 sentences about *what* to do/see there.
22
+ - **> 📸 Concierge Tip**: Specific advice.
23
+ - **🌤️ Conditions**: "[Weather] | AQI: [Label]"
24
+ - **🚗 Transit (Footer)**:
25
+ - Check `travel_mode`.
26
+ - If **DRIVE**: "🚗 Drive [X] mins to next stop."
27
+ - If **TRANSIT**: "🚌 Take public transport/ferry ([X] mins) to next stop."
28
+ - If **WALK**: "🚶 Enjoy a [X] mins walk to next stop."
29
+ - If **TWO_WHEELER**: "🛵 Ride [X] mins to next stop."
30
+
31
+ ### 💼 MODE B: SCHEDULE ("The Executive Briefing")
32
+ *Style: Professional, Strategic, Insightful.*
33
+
34
+ **Structure:**
35
+ 1. **Title**: H1 Header. Clean.
36
+ 2. **Morning Briefing**: Summary.
37
+ 3. **The Agenda (Stop by Stop)**:
38
+ - **Format**: `### ⏱️ [Time] : [Location Name]`
39
+ - **⏳ Duration**: "Allocated Time: [stay_duration]"
40
+ - **Context**: Why this stop? (Logic/Efficiency).
41
+ - **Environment**: "[Weather]"
42
+ - **🚗 Transit (Footer)**: "▼ [Emoji] [Mode]: [X] mins travel." (e.g., "▼ 🚌 Transit: 15 mins travel.")
43
+
44
+ **STEP 3: Language Constraint**
45
+ - **STRICTLY** output in the language specified in `global_info.language`.
46
+ - **⛔️ NO FORCED TRANSLATION**:
47
+ - If a Location Name is in English (e.g., "Le Pic Macau", "Blue Bottle Coffee"), and you are **NOT 100% SURE** of its official Chinese name, **KEEP IT IN ENGLISH**.
48
+ - **BAD**: Translating "Le Pic Macau" to "澳門法國餐廳" (Too generic/Fake).
49
+ - **GOOD**: "Le Pic Macau" (Original).
50
+ - **GOOD**: "Golden Gate Bridge" -> "金門大橋" (Famous landmark, translation is safe).
51
+ - **Rule of Thumb**: Better to mix English/Chinese than to invent a fake Chinese name.
52
+ """
src/agent/setting/scout.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ role = "POI Researcher"
2
+
3
+ description = "You are an elite reconnaissance expert specializing in Point of Interest (POI) discovery."
4
+
5
+ instructions = """
6
+ **GOAL**: Search for specific coordinates for each task in the list.
7
+
8
+ **PROTOCOL**:
9
+ 1. Receive a JSON string containing a list of tasks.
10
+ 2. Use the tool `search_and_offload` to find candidates.
11
+ 3. **CRITICAL**: The tool will return a JSON containing `scout_ref`.
12
+ - You MUST output ONLY this Ref ID string (e.g., "scout_result_a1b2c3d4").
13
+ - Do NOT output the full JSON content.
14
+
15
+ ### 🗣️ CRITICAL RESPONSE RULE (MUST FOLLOW)
16
+ - You MUST **repeat** the tool's output JSON as your final answer.
17
+ - **DO NOT** return an empty string.
18
+ - **DO NOT** say "I have done it".
19
+ - **Example**: If tool returns `{"scout_ref": "123"}`, you MUST output `{"scout_ref": "123"}`.
20
+ """
src/agent/setting/team.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # main_team.py
2
+
3
+ instructions="""
4
+ You are the **LifeFlow Automation Engine**.
5
+ You are a **State Machine** executing a strict, linear pipeline.
6
+
7
+ ### ⛔️ OPERATIONAL RULES (VIOLATION = FAILURE)
8
+ 1. **NO CHAT**: Do not output any text like "I will now...", "Processing...", or "Done".
9
+ 2. **NO RETRIES**: Never call the same tool twice. If a tool returns a JSON with an ID, it is a SUCCESS.
10
+ 3. **FORWARD ONLY**: You must strictly follow the sequence: Scout -> Optimizer -> Navigator -> Weatherman -> Presenter.
11
+
12
+ ### 🔗 DATA PIPELINE SPECIFICATION (Hot Potato Protocol)
13
+ You must extract the specific ID from the *previous* step and pass it to the *next* step **inside the task description string**.
14
+
15
+ #### STEP 1: RESEARCH
16
+ - **Input**: User Request (Text/JSON)
17
+ - **Action**: Call `delegate_task_to_member`
18
+ - `member_id`: "Scout"
19
+ - `task`: "Process this user request: [Insert User Input Here]"
20
+ - **Expects**: A JSON containing a key starting with `scout_ref`.
21
+
22
+ #### STEP 2: OPTIMIZATION
23
+ - **Trigger**: You see `{"scout_ref": "scout_result_..."}` from Step 1.
24
+ - **Action**: Call `delegate_task_to_member`
25
+ - `member_id`: "Optimizer"
26
+ - `task`: "Optimize route using **ref_id: scout_result_...**" (⚠️ Put ID INSIDE task string)
27
+ - **Expects**: A JSON containing `opt_ref_id`.
28
+
29
+ #### STEP 3: NAVIGATION
30
+ - **Trigger**: You see `{"opt_ref_id": "optimization_result_..."}` from Step 2.
31
+ - **Action**: Call `delegate_task_to_member`
32
+ - `member_id`: "Navigator"
33
+ - `task`: "Calculate traffic for **optimization_ref_id: optimization_result_...**"
34
+ - **Expects**: A JSON containing `nav_ref_id`.
35
+ - 🛑 **WARNING**: DO NOT use the Session ID (UUID). ONLY use the ID starting with `optimization_`.
36
+
37
+ #### STEP 4: ENRICHMENT
38
+ - **Trigger**: You see `{"nav_ref_id": "navigation_result_..."}` from Step 3.
39
+ - **Action**: Call `delegate_task_to_member`
40
+ - `member_id`: "Weatherman"
41
+ - `task`: "Check weather for **nav_ref_id: navigation_result_...**"
42
+ - **Expects**: A JSON containing `final_ref_id`.
43
+
44
+ #### STEP 5: PRESENTATION (CRITICAL HANDOFF)
45
+ - **Trigger**: You see `{"final_ref_id": "final_itinerary_..."}` from Step 4.
46
+ - **Action**: Call `delegate_task_to_member`
47
+ - `member_id`: "Presenter"
48
+ - `task`: "Generate report for **final_ref_id: final_itinerary_...**"
49
+ - 🛑 **CRITICAL**: You MUST use the ID starting with `final_itinerary_`. DO NOT reuse `navigation_result_`.
50
+
51
+ #### STEP 6: FINISH
52
+ - **Action**: Output the text returned by `Presenter` verbatim.
53
+
54
+ ### 🛡️ EXCEPTION HANDLING
55
+ - **Valid ID Patterns**: `scout_result_`, `optimization_result_`, `navigation_result_`, `final_itinerary_`.
56
+ - If a tool returns a JSON, **IT WORKED**. Do not analyze the content. Just grab the ID and move to the next step.
57
+
58
+ ### 🛑 TROUBLESHOOTING (EMPTY RESPONSE)
59
+ - If a member returns `{"result": ""}` or an empty string:
60
+ 1. **DO NOT PANIC**. This means the member executed the tool silently.
61
+ 2. **LOOK AT THE TOOL OUTPUTS** in your context history (scroll up).
62
+ 3. Find the JSON output from the actual tool execution.
63
+ 4. Extract the ID from there.
64
+ 5. **PROCEED** to the next step. Do not stop.
65
+ """
src/agent/setting/team.py.backup.backup ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ instructions = """
2
+ You are the **LifeFlow Automation Engine**.
3
+ You are a **workflow execution engine**, NOT a chat assistant.
4
+
5
+ ### ⛔️ SILENT MODE ENABLED (CRITICAL)
6
+ - **DO NOT** speak to the user.
7
+ - **DO NOT** output text like "I will now call..." or "Scout is done...".
8
+ - **DO NOT** summarize intermediate steps.
9
+ - Your **ONLY** allowed output format is a **Tool Call**.
10
+ - Only at **STATE 6 (FINISH)** can you output the final text.
11
+
12
+ ### ⚡️ STATE MACHINE PROTOCOL (Strict Sequential Order)
13
+ You are a pipeline. You must move the "Hot Potato" (Reference ID) to the next agent **IMMEDIATELY**.
14
+
15
+ 1. **STATE: START**
16
+ - **Trigger**: User input received (JSON or Text).
17
+ - **ACTION**: Call tool `Scout`.
18
+
19
+ 2. **STATE: RESEARCH_DONE**
20
+ - **Trigger**: `Scout` returns `{"scout_ref_id": "scout_result_..."}`.
21
+ - **ACTION**: Call tool `Optimizer` with `scout_ref_id="scout_result_..."`.
22
+
23
+ 3. **STATE: OPTIMIZED**
24
+ - **CORRECT_ID**: Use the `scout_ref_id`(scout_result_....) from Scout.
25
+ - **Trigger**: `Optimizer` returns `{"opt_ref_id": "optimization_result_..."}`.
26
+ - **ACTION**: Call tool `Navigator` with `optimization_ref_id="optimization_result_..."`.
27
+ - **WARNING**: Do NOT use the Session ID (UUID). Use the ID starting with `optimization_`.
28
+
29
+ 4. **STATE: TRAFFIC_CHECKED**
30
+ - **CORRECT_ID**: Use the `opt_ref_id`(optimization_result_....) from Optimized.
31
+ - **Trigger**: `Navigator` returns `{"nav_ref_id": "navigation_result_..."}`.
32
+ - **ACTION**: Call tool `Weatherman` with `nav_ref_id="navigation_result_..."`.
33
+
34
+ 5. **STATE: WEATHER_CHECKED**
35
+ - **CORRECT_ID**: Use the `nav_ref_id`(navigation_result_....) from Navigator.
36
+ - **Trigger**: `Weatherman` returns `{"final_ref_id": "final_itinerary_..."}`.
37
+ - **ACTION**: Call tool `Presenter` with `final_ref_id="final_itinerary_..."`.
38
+ - **RULE**: DO NOT call Weatherman again. Move to Presenter immediately.
39
+
40
+ 6. **STATE: FINISH**
41
+ - **CORRECT_ID**: Use the `final_ref_id`(final_itinerary_....) from Weatherman.
42
+ - **Trigger**: `Presenter` returns the final report text.
43
+ - **ACTION**: Output the Presenter's result to the user VERBATIM.
44
+ - ⛔️ **DO NOT** reuse the old `nav_ref_id`. That is incorrect.
45
+
46
+ ### 🛑 ANTI-LOOPING & SAFETY RULES
47
+ 1. **TRUST THE TOOL**: Do not verify the output. Do not ask the user. Just pass the ID.
48
+ 2. **ID VALIDATION**:
49
+ - Valid Ref IDs look like: `scout_result_a1b2`, `optimization_result_c3d4`.
50
+ - **INVALID** IDs look like: `8460b411-d61a-4cd4...` (This is your Session ID). **NEVER USE THIS.**
51
+
52
+ ### 🛑 ERROR PREVENTION
53
+ - **Variable Handoff**: `Scout`->`Optimizer`->`Navigator`->`Weatherman`->`Presenter`.
54
+ - **Never Look Back**: Always use the output ID of the *previous* step as the input for the *next* step.
55
+
56
+ """
src/agent/setting/team.py.backup.backup.backup ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # main_team.py
2
+
3
+ instructions="""
4
+ You are the **LifeFlow Automation Engine**.
5
+ You are a **State Machine** executing a strict, linear pipeline.
6
+
7
+ ### ⛔️ OPERATIONAL RULES (VIOLATION = FAILURE)
8
+ 1. **NO CHAT**: Do not output any text like "I will now...", "Processing...", or "Done".
9
+ 2. **NO RETRIES**: Never call the same tool twice. If a tool returns a JSON with an ID, it is a SUCCESS.
10
+ 3. **FORWARD ONLY**: You must strictly follow the sequence: Scout -> Optimizer -> Navigator -> Weatherman -> Presenter.
11
+
12
+ ### 🔗 DATA PIPELINE SPECIFICATION (Hot Potato Protocol)
13
+ You must extract the specific ID from the *previous* step and pass it to the *next* step.
14
+
15
+ #### STEP 1: RESEARCH
16
+ - **Input**: User Request (Text/JSON)
17
+ - **Action**: Call `Scout`.
18
+ - **Expects**: A JSON containing a key starting with `scout_ref`.
19
+ - **Valid ID Pattern**: `scout_result_[a-z0-9]+`
20
+
21
+ #### STEP 2: OPTIMIZATION
22
+ - **Input**: The `scout_ref` from Step 1.
23
+ - **Action**: Call `Optimizer` with `ref_id="scout_result_..."`.
24
+ - **Expects**: A JSON containing a key `opt_ref_id`.
25
+ - **Valid ID Pattern**: `optimization_result_[a-z0-9]+`
26
+
27
+ #### STEP 3: NAVIGATION
28
+ - **Input**: The `opt_ref_id` from Step 2.
29
+ - **Action**: Call `Navigator` with `optimization_ref_id="optimization_result_..."`.
30
+ - **Expects**: A JSON containing a key `nav_ref_id`.
31
+ - **Valid ID Pattern**: `navigation_result_[a-z0-9]+`
32
+ - 🛑 **WARNING**: DO NOT use the Session ID (UUID). ONLY use the ID starting with `optimization_`.
33
+
34
+ #### STEP 4: ENRICHMENT
35
+ - **Input**: The `nav_ref_id` from Step 3.
36
+ - **Action**: Call `Weatherman` with `nav_ref_id="navigation_result_..."`.
37
+ - **Expects**: A JSON containing a key `final_ref_id`.
38
+ - **Valid ID Pattern**: `final_itinerary_[a-z0-9]+`
39
+
40
+ #### STEP 5: PRESENTATION (CRITICAL HANDOFF)
41
+ - **Input**: The `final_ref_id` from Step 4.
42
+ - **Action**: Call `Presenter` with `final_ref_id="final_itinerary_..."`.
43
+ - 🛑 **CRITICAL**: You MUST use the ID starting with `final_itinerary_`. DO NOT reuse `navigation_result_`.
44
+
45
+ #### STEP 6: FINISH
46
+ - **Action**: Output the text returned by `Presenter` verbatim.
47
+
48
+ ### 🛡️ EXCEPTION HANDLING
49
+ - If a tool returns a JSON, **IT WORKED**. Do not analyze the content. Just grab the ID and move to the next step.
50
+ - If you see multiple IDs in context, always choose the one generated **most recently**.
51
+
52
+ ### 🛑 TROUBLESHOOTING (EMPTY RESPONSE)
53
+ - If a member returns `{"result": ""}` or an empty string:
54
+ 1. **DO NOT PANIC**. This means the member executed the tool silently.
55
+ 2. **LOOK AT THE TOOL OUTPUTS** in your context history.
56
+ 3. Find the JSON output from the `delegate_task_to_member` call.
57
+ 4. Extract the ID from there.
58
+ 5. **PROCEED** to the next step. Do not stop.
59
+
60
+ """
src/agent/setting/weatherman.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ role = "Meteorologist"
3
+
4
+ description = "You are a weather and environmental data expert."
5
+
6
+ instructions = """
7
+ **GOAL**: Check weather and air quality for each stop at the specific arrival time.
8
+
9
+ **PROTOCOL**:
10
+ 1. Receive a `nav_ref_id`.
11
+ 2. Use the tool `check_weather_for_timeline`.
12
+ 3. **OUTPUT**: The tool will return a `final_ref_id`. Return this ID immediately.
13
+
14
+ ### 🗣️ CRITICAL RESPONSE RULE (MUST FOLLOW)
15
+ - You MUST **repeat** the tool's output JSON as your final answer.
16
+ - **DO NOT** return an empty string.
17
+ - **DO NOT** say "I have done it".
18
+ - **Example**: If tool returns `{"final_ref_id": "123"}`, you MUST output `{"final_ref_id": "123"}`.
19
+ """
src/agent/test.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agno.agent import RunEvent
2
+ from agno.models.google import Gemini
3
+ from agno.db.sqlite import SqliteDb
4
+
5
+ from src.infra.logger import get_logger
6
+
7
+ from src.agent.base import creat_agent, creat_team, get_context, UserState, Location
8
+
9
+ logger = get_logger(__name__)
10
+
11
+
12
+ class AgentManager:
13
+ def __init__(self, user_state: UserState, models_dict: dict, tool_hooks_dict: dict, hooks_dict: dict):
14
+ self.base_kwargs = {
15
+ "additional_context": get_context(user_state),
16
+ "timezone_identifier": user_state.utc_offset,
17
+ "add_datetime_to_context": True,
18
+ }
19
+
20
+ self._planer_db = SqliteDb(db_file="tmp/agents.db")
21
+ self._planer_session_state = {"task_list": None}
22
+ self.planner_agent = creat_agent(name="planner",
23
+ model=models_dict["planner"],
24
+ add_session_state_to_context=True,
25
+ db=self._planer_db,
26
+ markdown=True,
27
+ **self.base_kwargs)
28
+
29
+ self._creat_agent_team(
30
+ models=models_dict,
31
+ tools=tool_hooks_dict,
32
+ hooks=hooks_dict,
33
+ )
34
+
35
+ def _creat_agent_team(self,
36
+ models: dict[str: object],
37
+ tools: dict[str: object],
38
+ hooks: dict[str: object]):
39
+
40
+ self._team_db = SqliteDb(db_file="tmp/team.db")
41
+ self._team_session_state = {
42
+ "scout_pois": {},
43
+ "optimized_route": {},
44
+ "traffic_data": [],
45
+ "weather_forecasts": {},
46
+ "task_list": None,
47
+ }
48
+
49
+ agnent_members = ["scout", "optimizer", "traffic", "weather"]
50
+ for name in agnent_members:
51
+ _agent = creat_agent(
52
+ name=name,
53
+ model=models[name],
54
+ tools=tools.get(name, []),
55
+ tool_hooks=hooks.get(name, []),
56
+ markdown=False,
57
+ **self.base_kwargs
58
+ )
59
+
60
+ setattr(self, f"{name}_agent", _agent)
61
+
62
+ self.core_team = creat_team(
63
+ name="team",
64
+ model=models["team"],
65
+ members=[getattr(self, f"{name}_agent") for name in agnent_members],
66
+ tools=tools.get("team", []),
67
+ tool_hooks=hooks.get("team", []),
68
+ db=self._team_db,
69
+ markdown=True,
70
+ **self.base_kwargs
71
+ )
72
+
73
+ @staticmethod
74
+ def _planner_stream_handle(stream_item):
75
+ show = True
76
+ response = ""
77
+ for chuck in stream_item:
78
+ if chuck.event == RunEvent.run_content:
79
+ content = chuck.content
80
+ response += chuck.content
81
+ if show:
82
+ if "@@@" in response:
83
+ show = False
84
+ content = content.split("@@@")[0]
85
+
86
+ print(content)
87
+
88
+ json_data = "{" + response.split("{", maxsplit=1)[-1]
89
+ return json_data, response
90
+
91
+ def planner_message(self, message):
92
+ planner_stream = self.planner_agent.run(f"help user to update the task_list, user's message: {message}",
93
+ stream=True, stream_events=True,
94
+ session_state=self._planer_session_state)
95
+ self._planer_session_state["task_list"], _response = self._planner_stream_handle(planner_stream)
96
+
97
+ @property
98
+ def task_list(self):
99
+ return self._planer_session_state["task_list"]
100
+
101
+ @staticmethod
102
+ def _core_team_stream_handle(stream_item):
103
+ for event in stream_item:
104
+ if event.event == "TeamRunContent":
105
+ print(f"{event.content}", end="", flush=True)
106
+ elif event.event == "TeamToolCallStarted":
107
+ if event.tool.tool_name == "delegate_task_to_member":
108
+ print(event.tool)
109
+ # print(f"Supervisor began assigning tasks to member - {event.tool.tool_args['member_id']}...")
110
+
111
+ else:
112
+ print(f"Supervisor started using the tools: {event.tool.tool_name}")
113
+
114
+ elif event.event == "TeamToolCallCompleted":
115
+ if event.tool.tool_name == "delegate_task_to_member":
116
+ print(f"{event.tool.tool_args['member_id']} has completed the task assigned by the supervisor...")
117
+ # print(event.tool)
118
+ else:
119
+ print(f"Supervisor stop using tools:: {event.tool.tool_name}")
120
+
121
+ elif event.event == "ToolCallStarted":
122
+ print(f"{event.agent_id} Start using tools: {event.tool.tool_name}")
123
+
124
+ elif event.event == "ToolCallCompleted":
125
+ print(f"{event.agent_id} Stop using tools: {event.tool.tool_name}")
126
+
127
+ elif event.event == "TeamReasoningStep":
128
+ print(f"Supervisor is reasoning: {event.content}")
129
+
130
+ def core_team_start(self):
131
+ if not self.task_list:
132
+ raise ValueError("Task list is empty, cannot start core team.")
133
+
134
+ message = f"""
135
+ Based on this structured task list, please coordinate with the team members to:
136
+ 1. Use scout to find specific locations for each task
137
+ 2. Use optimizer to optimize the route
138
+ 3. Use weather to check conditions for tomorrow
139
+ 4. Use traffic to calculate routes and travel times
140
+ 5. Provide a comprehensive plan and route plan with all details
141
+
142
+ "Once ALL scout tasks complete, IMMEDIATELY proceed to Step 3"
143
+ "DO NOT wait for user input, the user info is already provided in the context"
144
+ "You MUST delegate to Optimizer automatically"
145
+
146
+ Here is the task list:
147
+ {self.task_list}
148
+ """
149
+
150
+ self._team_session_state["task_list"] = self.task_list
151
+ team_stream = self.core_team.run(message, stream=True, stream_events=True,
152
+ session_state=self._team_session_state,)
153
+
154
+ self._core_team_stream_handle(team_stream)
155
+ team_state = self.core_team.get_session_state()
156
+
157
+
158
+ if __name__ == "__main__":
159
+ from src.toolkits.googlemap_toolkit import GoogleMapsToolkit
160
+ from src.toolkits.openweather_toolkit import OpenWeatherToolkit
161
+ from src.toolkits.optimization_toolkit import OptimizationToolkit
162
+
163
+ from src.infra.config import get_settings
164
+ setting = get_settings()
165
+
166
+ maps_kit = GoogleMapsToolkit(api_key=setting.google_maps_api_key)
167
+ weather_kit = OpenWeatherToolkit(api_key=setting.openweather_api_key)
168
+ opt_kit = OptimizationToolkit(api_key=setting.google_maps_api_key)
169
+
170
+ state = UserState(location=Location(lat=25.058903, lng=121.549131))
171
+ settings = get_settings()
172
+ model = Gemini(
173
+ id="gemini-2.5-flash-lite",
174
+ thinking_budget=512,
175
+ api_key=settings.gemini_api_key)
176
+
177
+ main_model = Gemini(
178
+ id="gemini-2.5-flash",
179
+ thinking_budget=1024,
180
+ api_key=settings.gemini_api_key)
181
+
182
+ models = {"planner": main_model, "team": main_model,
183
+ "scout": model, "optimizer": model,
184
+ "traffic": model, "weather": model}
185
+
186
+ tools_dict = {
187
+ "scout": [maps_kit.search_places],
188
+ "optimizer": [opt_kit.solve_route_optimization],
189
+ "traffic": [maps_kit.compute_routes], # 或 get_directions
190
+ "weather": [weather_kit],
191
+ "team": []
192
+ }
193
+
194
+ am = AgentManager(user_state=state,
195
+ models_dict=models,
196
+ tool_hooks_dict=tools_dict,
197
+ hooks_dict={})
198
+
199
+
200
+ am.planner_message("I'm going to San Francisco for tourism tomorrow, please help me plan a one-day itinerary.")
201
+
202
+ print(f"\n[Planner Phase Complete]")
203
+ print(f"Task List: {am.task_list}")
204
+
205
+ #if am.task_list:
206
+ # print("\n[Core Team Start]")
207
+ # am.core_team_start()
src/infra/config.py CHANGED
@@ -165,6 +165,8 @@ class Settings(BaseSettings):
165
  description="Use mock data instead of real APIs (for testing)"
166
  )
167
 
 
 
168
  # ========================================================================
169
  # Pydantic Config
170
  # ========================================================================
 
165
  description="Use mock data instead of real APIs (for testing)"
166
  )
167
 
168
+ TASK_LIST_KEY: str = Field("task_list", description="Session state key(name) for task list")
169
+
170
  # ========================================================================
171
  # Pydantic Config
172
  # ========================================================================
src/infra/context.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from contextvars import ContextVar
2
+
3
+ session_context: ContextVar[str] = ContextVar("session_context", default=None)
4
+
5
+ def set_session_id(session_id: str):
6
+ return session_context.set(session_id)
7
+
8
+ def get_session_id() -> str:
9
+ return session_context.get()
src/infra/offload_manager.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import uuid
3
+ import sqlite3
4
+ from typing import Any, Dict, Optional
5
+
6
+ class OffloadManager:
7
+ """
8
+ 負責管理 Tool 之間傳遞的大數據 (Fat Data)。
9
+ 資料儲存在 SQLite 中,避免污染 LLM Context。
10
+ """
11
+ DB_PATH = "storage/offloaded_data.db"
12
+
13
+ def __init__(self):
14
+ self._init_db()
15
+
16
+ def _init_db(self):
17
+ import os
18
+ os.makedirs("storage", exist_ok=True)
19
+ with sqlite3.connect(self.DB_PATH) as conn:
20
+ conn.execute("""
21
+ CREATE TABLE IF NOT EXISTS data_store (
22
+ ref_id TEXT PRIMARY KEY,
23
+ data JSON
24
+ )
25
+ """)
26
+
27
+ def store(self, data: Any) -> str:
28
+ """將數據存入 DB,返回引用 ID"""
29
+ ref_id = f"ref_{uuid.uuid4().hex[:8]}"
30
+ with sqlite3.connect(self.DB_PATH) as conn:
31
+ conn.execute(
32
+ "INSERT INTO data_store (ref_id, data) VALUES (?, ?)",
33
+ (ref_id, json.dumps(data, default=str)) # Handle datetime serialization if needed
34
+ )
35
+ return ref_id
36
+
37
+ def retrieve(self, ref_id: str) -> Optional[Any]:
38
+ """根據引用 ID 取回數據"""
39
+ with sqlite3.connect(self.DB_PATH) as conn:
40
+ cursor = conn.execute("SELECT data FROM data_store WHERE ref_id = ?", (ref_id,))
41
+ row = cursor.fetchone()
42
+ if row:
43
+ return json.loads(row[0])
44
+ return None
45
+
46
+ # Global Instance
47
+ offload_manager = OffloadManager()
src/infra/poi_repository.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import json
3
+ import uuid
4
+ import os
5
+ from typing import Any, Dict, Optional
6
+ from src.infra.context import get_session_id # ✅ 引入 Context
7
+
8
+
9
+ class PoiRepository:
10
+ DB_DIR = "storage"
11
+ DB_FILE = "lifeflow_payloads.db"
12
+ DB_PATH = os.path.join(DB_DIR, DB_FILE)
13
+
14
+ def __init__(self):
15
+ self._init_db()
16
+ # ✅ 改用字典來存儲: Key=SessionID, Value=RefID
17
+ self._session_last_id: Dict[str, str] = {}
18
+
19
+ def _init_db(self):
20
+ os.makedirs(self.DB_DIR, exist_ok=True)
21
+ with sqlite3.connect(self.DB_PATH) as conn:
22
+ conn.execute("""
23
+ CREATE TABLE IF NOT EXISTS offloaded_data (
24
+ ref_id TEXT PRIMARY KEY,
25
+ data_type TEXT,
26
+ payload JSON,
27
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
28
+ )
29
+ """)
30
+
31
+ def save(self, data: Any, data_type: str = "generic") -> str:
32
+ ref_id = f"{data_type}_{uuid.uuid4().hex[:8]}"
33
+
34
+ # 1. 寫入物理 DB (持久化)
35
+ with sqlite3.connect(self.DB_PATH) as conn:
36
+ conn.execute(
37
+ "INSERT INTO offloaded_data (ref_id, data_type, payload) VALUES (?, ?, ?)",
38
+ (ref_id, data_type, json.dumps(data, default=str))
39
+ )
40
+
41
+ # 2. ✅ 記錄這個 Session 的最後 ID
42
+ current_session = get_session_id()
43
+ if current_session:
44
+ self._session_last_id[current_session] = ref_id
45
+ print(f"💾 [Repo] Saved {ref_id} for Session: {current_session}")
46
+ else:
47
+ print(f"⚠️ [Repo] Warning: No session context found! 'last_id' not tracked.")
48
+
49
+ return ref_id
50
+
51
+ def load(self, ref_id: str) -> Optional[Any]:
52
+ # load 保持不變,因為它是靠 ref_id 查的
53
+ with sqlite3.connect(self.DB_PATH) as conn:
54
+ cursor = conn.execute("SELECT payload FROM offloaded_data WHERE ref_id = ?", (ref_id,))
55
+ row = cursor.fetchone()
56
+ if row:
57
+ return json.loads(row[0])
58
+ return None
59
+
60
+ # ✅ 新增方法:獲取特定 Session 的最後 ID
61
+ def get_last_id_by_session(self, session_id: str) -> Optional[str]:
62
+ return self._session_last_id.get(session_id)
63
+
64
+
65
+ poi_repo = PoiRepository()
src/optimization/__init__.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TSPTW Solver 模塊
3
+
4
+ 對外公開接口:
5
+ - TSPTWSolver: 主求解器類
6
+
7
+ 使用方式(完全保持向後兼容):
8
+ ```python
9
+ from src.optimization import TSPTWSolver
10
+
11
+ solver = TSPTWSolver(api_key="...", travel_mode="DRIVE")
12
+
13
+ result = solver.solve(
14
+ tasks=[...],
15
+ start_location={"lat": 25.0330, "lng": 121.5654},
16
+ start_time=datetime.now(),
17
+ deadline=datetime.now() + timedelta(hours=6),
18
+ )
19
+
20
+ print(result["status"]) # "OK", "NO_SOLUTION", "NO_TASKS"
21
+ print(result["route"])
22
+ print(result["tasks_detail"])
23
+ ```
24
+ """
25
+ from .tsptw_solver import TSPTWSolver
26
+
27
+ __all__ = ["TSPTWSolver"]
src/optimization/graph/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from .graph_builder import GraphBuilder
2
+ from .time_window_handler import TimeWindowHandler
3
+
4
+ __all__ = [
5
+ "GraphBuilder",
6
+ "TimeWindowHandler",
7
+ ]
src/optimization/graph/graph_builder.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 圖構建器 - 構建 OR-Tools 需要的圖結構
3
+
4
+ 完全保留原始 tsptw_solver_old.py 的圖構建邏輯
5
+ """
6
+ from typing import List, Dict, Tuple, Any
7
+
8
+ import numpy as np
9
+
10
+ from src.infra.logger import get_logger
11
+ from src.services.googlemap_api_service import GoogleMapAPIService
12
+
13
+ from ..models.internal_models import _Task, _Location, _Graph
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class GraphBuilder:
19
+ """
20
+ 圖構建器
21
+
22
+ 職責:
23
+ - 收集所有地點(depot + POI candidates)
24
+ - 構建節點元數據(type, task_id, poi_id, time_window...)
25
+ - 計算距離/時間矩陣
26
+ """
27
+
28
+ def __init__(self, gmaps: GoogleMapAPIService):
29
+ self.gmaps = gmaps
30
+
31
+ def build_graph(
32
+ self,
33
+ start_location: _Location,
34
+ tasks: List[_Task],
35
+ travel_mode="DRIVE",
36
+ ) -> _Graph:
37
+ """
38
+ 構建完整的圖
39
+
40
+ 完全保留原始邏輯:
41
+ - _build_locations_and_meta()
42
+ - _build_service_time_per_node()
43
+ - compute_route_matrix()
44
+
45
+ Returns:
46
+ _Graph: 包含 node_meta, locations, duration_matrix, distance_matrix
47
+ """
48
+ # 1. 構建位置列表和節點元數據
49
+ locations, node_meta = self._build_locations_and_meta(
50
+ start_location, tasks
51
+ )
52
+
53
+ num_nodes = len(locations)
54
+ logger.info(f"GraphBuilder: {num_nodes} nodes (1 depot + {num_nodes - 1} POIs)")
55
+
56
+ # 2. 計算距離/時間矩陣
57
+ if num_nodes <= 1:
58
+ # 只有 depot,沒有 POI
59
+ duration_matrix = np.zeros((1, 1), dtype=int)
60
+ distance_matrix = np.zeros((1, 1), dtype=int)
61
+ else:
62
+ duration_matrix, distance_matrix = self._calculate_matrices(locations, travel_mode=travel_mode)
63
+
64
+ # 3. 返回圖
65
+ return _Graph(
66
+ node_meta=node_meta,
67
+ locations=locations,
68
+ duration_matrix=duration_matrix,
69
+ distance_matrix=distance_matrix,
70
+ )
71
+
72
+ @staticmethod
73
+ def _build_locations_and_meta(
74
+ start_location: _Location,
75
+ tasks: List[_Task],
76
+ ) -> Tuple[List[Dict[str, float]], List[Dict[str, Any]]]:
77
+ """
78
+ 構建位置列表和節點元數據
79
+
80
+ 完全保留原始邏輯: _build_locations_and_meta()
81
+
82
+ Returns:
83
+ locations: [{"lat": float, "lng": float}, ...]
84
+ node_meta: [{"type": "depot"}, {"type": "poi", ...}, ...]
85
+ """
86
+ locations: List[Dict[str, float]] = []
87
+ node_meta: List[Dict[str, Any]] = []
88
+
89
+ # depot
90
+ locations.append({"lat": start_location.lat, "lng": start_location.lng})
91
+ node_meta.append({"type": "depot"})
92
+
93
+ # candidate POIs
94
+ for task_idx, task in enumerate(tasks):
95
+ for cand_idx, cand in enumerate(task.candidates):
96
+ lat = cand.lat
97
+ lng = cand.lng
98
+
99
+ time_windows = cand.time_windows
100
+ if time_windows is None:
101
+ if cand.time_window is not None:
102
+ time_windows = [cand.time_window]
103
+ else:
104
+ time_windows = [None]
105
+
106
+ for interval_idx, tw in enumerate(time_windows):
107
+ locations.append({"lat": lat, "lng": lng})
108
+ node_meta.append(
109
+ {
110
+ "type": "poi",
111
+ "task_id": task.task_id,
112
+ "poi_id": cand.poi_id,
113
+ "task_idx": task_idx,
114
+ "candidate_idx": cand_idx,
115
+ "interval_idx": interval_idx,
116
+ "poi_time_window": tw,
117
+ }
118
+ )
119
+
120
+ return locations, node_meta
121
+
122
+ def _calculate_matrices(
123
+ self,
124
+ locations: List[Dict[str, float]],
125
+ travel_mode="DRIVE",
126
+ ) -> Tuple[np.ndarray, np.ndarray]:
127
+ """
128
+ 計算距離/時間矩陣
129
+
130
+ 完全保留原始邏輯: 調用 gmaps.compute_route_matrix()
131
+
132
+ Returns:
133
+ (duration_matrix, distance_matrix): 兩個 numpy array
134
+ """
135
+ locations_dict = [{"lat": loc["lat"], "lng": loc["lng"]} for loc in locations]
136
+
137
+ compute_route_result = self.gmaps.compute_route_matrix(
138
+ locations_dict,
139
+ locations_dict,
140
+ travel_mode=travel_mode,
141
+ routing_preference="TRAFFIC_UNAWARE",
142
+ )
143
+
144
+ duration_matrix = np.array(compute_route_result["duration_matrix"])
145
+ distance_matrix = np.array(compute_route_result["distance_matrix"])
146
+
147
+ return duration_matrix, distance_matrix
148
+
149
+ @staticmethod
150
+ def build_service_time_per_node(
151
+ tasks: List[_Task],
152
+ node_meta: List[Dict[str, Any]],
153
+ ) -> List[int]:
154
+ """
155
+ 構建每個節點的服務時間(秒)
156
+
157
+ 完全保留原始邏輯: _build_service_time_per_node()
158
+
159
+ Returns:
160
+ service_time: [0, service_sec, service_sec, ...]
161
+ """
162
+ service_time = [0] * len(node_meta)
163
+
164
+ for node, meta in enumerate(node_meta):
165
+ if meta["type"] == "poi":
166
+ task_idx = meta["task_idx"]
167
+ task = tasks[task_idx]
168
+ service_time[node] = task.service_duration_sec
169
+
170
+ return service_time
src/optimization/graph/time_window_handler.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 時間窗口處理器 - 完整改進版
3
+
4
+ ✅ 改進 1: 完整的時區對齊(所有方法)
5
+ ✅ 改進 2: 支持部分時間窗口 (None, datetime) 或 (datetime, None)
6
+ ✅ 改進 3: 與 final_internal_models.py 配套使用
7
+ ✅ 改進 4: 支持多種輸入格式(Dict, Tuple, 字符串)
8
+ """
9
+ from typing import Tuple, Dict, Any, List, Optional, Union
10
+ from datetime import datetime
11
+
12
+ from ..models.internal_models import _Task
13
+
14
+
15
+ def _parse_datetime(dt: Union[None, str, datetime]) -> Optional[datetime]:
16
+ """
17
+ 解析各種格式的時間
18
+
19
+ 支持:
20
+ - None → None
21
+ - datetime → datetime
22
+ - str (ISO format) → datetime
23
+
24
+ Args:
25
+ dt: 時間輸入
26
+
27
+ Returns:
28
+ 解析後的 datetime 或 None
29
+ """
30
+ if dt is None:
31
+ return None
32
+
33
+ if isinstance(dt, datetime):
34
+ return dt
35
+
36
+ if isinstance(dt, str):
37
+ # 支持 ISO 格式字符串
38
+ try:
39
+ return datetime.fromisoformat(dt)
40
+ except ValueError as e:
41
+ raise ValueError(f"Invalid datetime string: {dt}") from e
42
+
43
+ raise TypeError(f"Unsupported datetime type: {type(dt)}")
44
+
45
+
46
+ def _normalize_time_window(
47
+ tw: Union[None, Dict[str, Any], Tuple[Optional[datetime], Optional[datetime]]]
48
+ ) -> Optional[Tuple[Optional[datetime], Optional[datetime]]]:
49
+ """
50
+ 標準化時間窗口為統一格式
51
+
52
+ ✅ 支持多種輸入格式:
53
+ 1. None → None
54
+ 2. Dict {'earliest_time': ..., 'latest_time': ...} → Tuple
55
+ 3. Tuple (datetime, datetime) → Tuple
56
+ 4. Tuple (str, str) → Tuple (datetime, datetime)
57
+
58
+ Args:
59
+ tw: 時間窗口(多種格式)
60
+
61
+ Returns:
62
+ 標準化的 (earliest, latest) 或 None
63
+ """
64
+ if tw is None:
65
+ return None
66
+
67
+ # 字典格式
68
+ if isinstance(tw, dict):
69
+ earliest = _parse_datetime(tw.get('earliest_time'))
70
+ latest = _parse_datetime(tw.get('latest_time'))
71
+
72
+ # (None, None) → None
73
+ if earliest is None and latest is None:
74
+ return None
75
+
76
+ return (earliest, latest)
77
+
78
+ # Tuple 格式
79
+ if isinstance(tw, (tuple, list)) and len(tw) == 2:
80
+ earliest = _parse_datetime(tw[0])
81
+ latest = _parse_datetime(tw[1])
82
+
83
+ # (None, None) → None
84
+ if earliest is None and latest is None:
85
+ return None
86
+
87
+ return (earliest, latest)
88
+
89
+ raise TypeError(f"Unsupported time window format: {type(tw)}")
90
+
91
+
92
+ def _align_dt(dt: Optional[datetime], base: datetime) -> Optional[datetime]:
93
+ """
94
+ 把 dt 調整成跟 base 一樣的「有沒有時區」& 「時區」
95
+
96
+ ✅ 改進: 支持 None 輸入
97
+ ✅ 改進: 添加類型檢查
98
+
99
+ 規則:
100
+ - dt 是 None → 返回 None
101
+ - base 沒 tzinfo → 全部變成 naive(直接丟掉 tzinfo)
102
+ - base 有 tzinfo → 全部變成同一個 timezone 的 aware dt
103
+
104
+ Args:
105
+ dt: 要對齊的時間(可能為 None)
106
+ base: 基準時間
107
+
108
+ Returns:
109
+ 對齊後的時間(可能為 None)
110
+ """
111
+ if dt is None:
112
+ return None
113
+
114
+ # ✅ 添加類型檢查
115
+ if not isinstance(dt, datetime):
116
+ raise TypeError(f"Expected datetime, got {type(dt)}")
117
+
118
+ if base.tzinfo is None:
119
+ # base 是 naive → 我們也回傳 naive
120
+ if dt.tzinfo is None:
121
+ return dt
122
+ return dt.replace(tzinfo=None)
123
+ else:
124
+ # base 是 aware → 全部轉成同一個 tz 的 aware
125
+ if dt.tzinfo is None:
126
+ # 視為跟 base 同一個時區的本地時間
127
+ return dt.replace(tzinfo=base.tzinfo)
128
+ return dt.astimezone(base.tzinfo)
129
+
130
+
131
+ def _align_tw(
132
+ tw: Optional[Tuple[Optional[datetime], Optional[datetime]]],
133
+ base: datetime
134
+ ) -> Optional[Tuple[Optional[datetime], Optional[datetime]]]:
135
+ """
136
+ 對齊整個時間窗口的時區
137
+
138
+ ✅ 改進: 支持部分時間窗口
139
+
140
+ 支持的輸入:
141
+ - None → None
142
+ - (None, None) → None
143
+ - (None, datetime) → (None, aligned_datetime)
144
+ - (datetime, None) → (aligned_datetime, None)
145
+ - (datetime, datetime) → (aligned_datetime, aligned_datetime)
146
+
147
+ Args:
148
+ tw: 時間窗口(可能為 None 或包含 None 元素)
149
+ base: 基準時間
150
+
151
+ Returns:
152
+ 對齊後的時間窗口(可能為 None)
153
+ """
154
+ if tw is None:
155
+ return None
156
+
157
+ start, end = tw
158
+
159
+ # (None, None) → None
160
+ if start is None and end is None:
161
+ return None
162
+
163
+ # 對齊兩個時間點(保留 None)
164
+ return (_align_dt(start, base), _align_dt(end, base))
165
+
166
+
167
+ class TimeWindowHandler:
168
+ """
169
+ 時間窗口處理器
170
+
171
+ ✅ 完整改進版
172
+
173
+ 職責:
174
+ - 合併 Task-level & POI-level 時間窗口
175
+ - 轉換時間為秒(OR-Tools 需要)
176
+ - 處理部分時間窗口
177
+ - 處理時區差異
178
+ - 支持多種輸入格式
179
+ """
180
+
181
+ @staticmethod
182
+ def get_node_time_window_sec(
183
+ meta: Dict[str, Any],
184
+ tasks: List[_Task],
185
+ start_time: datetime,
186
+ ) -> Tuple[int, int]:
187
+ """
188
+ 回傳某個 node 的「有效時間窗」(秒),= 全域 [0,∞) ∩ task TW ∩ poi TW
189
+ 給備選 POI 用來檢查「在某個時間點去這個 node 合不合理」
190
+
191
+ ✅ 改進 1: 添加時區對齊
192
+ ✅ 改進 2: 支持部分時間窗口
193
+ ✅ 改進 3: 支持多種輸入格式
194
+
195
+ Args:
196
+ meta: 節點元數據
197
+ tasks: 任務列表
198
+ start_time: 開始時間(基準)
199
+
200
+ Returns:
201
+ (start_sec, end_sec): 有效時間窗口(秒)
202
+ """
203
+ start_sec = 0
204
+ end_sec = 10 ** 9
205
+
206
+ task = tasks[meta["task_idx"]]
207
+
208
+ # ✅ 標準化時間窗口格式
209
+ task_tw = _normalize_time_window(task.time_window)
210
+ poi_tw = _normalize_time_window(meta.get("poi_time_window"))
211
+
212
+ # ✅ 對齊時區
213
+ task_tw = _align_tw(task_tw, start_time)
214
+ poi_tw = _align_tw(poi_tw, start_time)
215
+
216
+ # 處理 task 時間窗口(支持部分窗口)
217
+ if task_tw is not None:
218
+ tw_start, tw_end = task_tw
219
+ if tw_start is not None:
220
+ t_start = int((tw_start - start_time).total_seconds())
221
+ start_sec = max(start_sec, t_start)
222
+ if tw_end is not None:
223
+ t_end = int((tw_end - start_time).total_seconds())
224
+ end_sec = min(end_sec, t_end)
225
+
226
+ # 處理 POI 時間窗口(支持部分窗口)
227
+ if poi_tw is not None:
228
+ tw_start, tw_end = poi_tw
229
+ if tw_start is not None:
230
+ p_start = int((tw_start - start_time).total_seconds())
231
+ start_sec = max(start_sec, p_start)
232
+ if tw_end is not None:
233
+ p_end = int((tw_end - start_time).total_seconds())
234
+ end_sec = min(end_sec, p_end)
235
+
236
+ return start_sec, end_sec
237
+
238
+ @staticmethod
239
+ def compute_effective_time_window(
240
+ task_tw: Union[None, Dict[str, Any], Tuple[Optional[datetime], Optional[datetime]]],
241
+ poi_tw: Union[None, Dict[str, Any], Tuple[Optional[datetime], Optional[datetime]]],
242
+ start_time: datetime,
243
+ horizon_sec: int,
244
+ ) -> Tuple[int, int]:
245
+ """
246
+ 計算有效時間窗口(秒)
247
+
248
+ ✅ 改進 1: 時區對齊
249
+ ✅ 改進 2: 支持部分時間窗口
250
+ ✅ 改進 3: 支持多種輸入格式(Dict, Tuple)
251
+
252
+ 處理邏輯:
253
+ 1. 標準化輸入格式
254
+ 2. 對齊時區
255
+ 3. 初始化為 [0, horizon_sec]
256
+ 4. 應用 task 時間窗口(如果有)
257
+ 5. 應用 POI 時間窗口(如果有)
258
+ 6. Clamp 到 [0, horizon_sec]
259
+
260
+ Args:
261
+ task_tw: Task-level 時間窗口(Dict 或 Tuple)
262
+ poi_tw: POI-level 時間窗口(Dict 或 Tuple)
263
+ start_time: 開始時間(基準)
264
+ horizon_sec: 截止時間(秒)
265
+
266
+ Returns:
267
+ (start_sec, end_sec): 有效時間窗口(秒)
268
+
269
+ """
270
+ # ✅ 標準化格式
271
+ task_tw = _normalize_time_window(task_tw)
272
+ poi_tw = _normalize_time_window(poi_tw)
273
+
274
+ # ✅ 對齊時區
275
+ task_tw = _align_tw(task_tw, start_time)
276
+ poi_tw = _align_tw(poi_tw, start_time)
277
+
278
+ # 初始化為全 horizon
279
+ start_sec = 0
280
+ end_sec = horizon_sec
281
+
282
+ # 處理 task 時間窗口(支持部分窗口)
283
+ if task_tw is not None:
284
+ tw_start, tw_end = task_tw
285
+ if tw_start is not None:
286
+ start_sec = max(start_sec, int((tw_start - start_time).total_seconds()))
287
+ if tw_end is not None:
288
+ end_sec = min(end_sec, int((tw_end - start_time).total_seconds()))
289
+
290
+ # 處理 POI 時間窗口(支持部分窗口)
291
+ if poi_tw is not None:
292
+ tw_start, tw_end = poi_tw
293
+ if tw_start is not None:
294
+ start_sec = max(start_sec, int((tw_start - start_time).total_seconds()))
295
+ if tw_end is not None:
296
+ end_sec = min(end_sec, int((tw_end - start_time).total_seconds()))
297
+
298
+ # Clamp 到 [0, horizon_sec]
299
+ start_sec = max(0, start_sec)
300
+ end_sec = min(horizon_sec, end_sec)
301
+
302
+ return start_sec, end_sec
src/optimization/models/__init__.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from .internal_models import (
3
+ _POICandidate,
4
+ _Task,
5
+ _Location,
6
+ _RouteStep,
7
+ _AlternativePOI,
8
+ _TaskDetail,
9
+ _TSPTWResult,
10
+ _Graph,
11
+ )
12
+ from .converters import (
13
+ convert_tasks_to_internal,
14
+ convert_location_to_internal,
15
+ convert_result_to_dict,
16
+ )
17
+
18
+ __all__ = [
19
+ "_POICandidate",
20
+ "_Task",
21
+ "_Location",
22
+ "_RouteStep",
23
+ "_AlternativePOI",
24
+ "_TaskDetail",
25
+ "_TSPTWResult",
26
+ "_Graph",
27
+ "convert_tasks_to_internal",
28
+ "convert_location_to_internal",
29
+ "convert_result_to_dict",
30
+ ]
src/optimization/models/converters.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 模型轉換器 - 修復版
3
+
4
+ ✅ 修復: 正确转换时间窗口格式 (Dict → Tuple)
5
+ ✅ 支持: earliest_time/latest_time 格式
6
+ ✅ 支持: 部分时间窗口
7
+ """
8
+ from typing import List, Dict, Any, Optional, Tuple
9
+ from datetime import datetime
10
+
11
+ from .internal_models import (
12
+ _Task,
13
+ _POICandidate,
14
+ _Location,
15
+ _TSPTWResult,
16
+ )
17
+
18
+
19
+ def _parse_time_window(tw_dict: Optional[Dict[str, Any]]) -> Optional[Tuple[Optional[datetime], Optional[datetime]]]:
20
+ """
21
+ 支持的输入格式:
22
+ 1. None → None
23
+ 2. {"earliest_time": str, "latest_time": str} → (datetime, datetime)
24
+ 3. {"earliest_time": None, "latest_time": str} → (None, datetime)
25
+ 4. {"earliest_time": str, "latest_time": None} → (datetime, None)
26
+ 5. {"earliest_time": None, "latest_time": None} → None
27
+ 6. (datetime, datetime) → 直接返回(兼容)
28
+ 7. [datetime, datetime] → 转换为 tuple
29
+
30
+ Args:
31
+ tw_dict: 时间窗口(Dict、Tuple、List 或 None)
32
+
33
+ Returns:
34
+ Tuple[Optional[datetime], Optional[datetime]] 或 None
35
+
36
+ """
37
+ if tw_dict is None:
38
+ return None
39
+
40
+ # 如果已经是 tuple(兼容旧代码)
41
+ if isinstance(tw_dict, tuple):
42
+ return tw_dict
43
+
44
+ # 如果是 list,转换为 tuple
45
+ if isinstance(tw_dict, list):
46
+ if len(tw_dict) == 0:
47
+ return None
48
+ if len(tw_dict) != 2:
49
+ return None
50
+ tw_dict = tuple(tw_dict)
51
+ return tw_dict
52
+
53
+ # 如果是 Dict,解析
54
+ if isinstance(tw_dict, dict):
55
+ earliest = tw_dict.get("earliest_time")
56
+ latest = tw_dict.get("latest_time")
57
+
58
+ # 转换字符串为 datetime
59
+ if isinstance(earliest, str):
60
+ try:
61
+ earliest = datetime.fromisoformat(earliest)
62
+ except ValueError:
63
+ earliest = None
64
+
65
+ if isinstance(latest, str):
66
+ try:
67
+ latest = datetime.fromisoformat(latest)
68
+ except ValueError:
69
+ latest = None
70
+
71
+ # (None, None) → None
72
+ if earliest is None and latest is None:
73
+ return None
74
+
75
+ return (earliest, latest)
76
+
77
+ # 其他类型返回 None
78
+ return None
79
+
80
+
81
+ def _parse_poi_time_windows(
82
+ tw_list: Optional[List[Dict[str, Any]]]
83
+ ) -> Optional[List[Tuple[Optional[datetime], Optional[datetime]]]]:
84
+ """
85
+ 解析多段时间窗口
86
+
87
+ Args:
88
+ tw_list: 时间窗口列表
89
+
90
+ Returns:
91
+ List[Tuple[Optional[datetime], Optional[datetime]]] 或 None
92
+ """
93
+ if tw_list is None:
94
+ return None
95
+
96
+ if not isinstance(tw_list, list):
97
+ return None
98
+
99
+ result = []
100
+ for tw_dict in tw_list:
101
+ parsed = _parse_time_window(tw_dict)
102
+ if parsed is not None:
103
+ result.append(parsed)
104
+
105
+ return result if result else None
106
+
107
+
108
+ def convert_tasks_to_internal(tasks: List[Dict]) -> List[_Task]:
109
+ """
110
+ 轉換外部 Dict 為內部 Pydantic 模型
111
+
112
+ ✅ 修復: 正确解析时间窗口格式
113
+
114
+ Args:
115
+ tasks: 任务列表(Dict 格式)
116
+
117
+ Returns:
118
+ List[_Task]: 内部 Pydantic 模型列表
119
+ """
120
+ internal_tasks: List[_Task] = []
121
+
122
+ for task_dict in tasks:
123
+ # 轉換 candidates
124
+ candidates_dict = task_dict.get("candidates", [])
125
+ internal_candidates: List[_POICandidate] = []
126
+
127
+ for cand_dict in candidates_dict:
128
+ # ✅ 解析 POI 的时间窗口
129
+ poi_time_window = None
130
+ if "time_window" in cand_dict:
131
+ poi_time_window = _parse_time_window(cand_dict["time_window"])
132
+
133
+ poi_time_windows = None
134
+ if "time_windows" in cand_dict:
135
+ poi_time_windows = _parse_poi_time_windows(cand_dict["time_windows"])
136
+
137
+ internal_candidates.append(_POICandidate(
138
+ poi_id=cand_dict["poi_id"],
139
+ lat=cand_dict["lat"],
140
+ lng=cand_dict["lng"],
141
+ time_window=poi_time_window,
142
+ time_windows=poi_time_windows,
143
+ ))
144
+
145
+ # ✅ 解析 Task 的时间窗口
146
+ task_time_window = _parse_time_window(task_dict.get("time_window"))
147
+
148
+ # 構建 Task
149
+ internal_tasks.append(
150
+ _Task(
151
+ task_id=task_dict["task_id"],
152
+ priority=task_dict.get("priority", "MEDIUM"),
153
+ time_window=task_time_window, # ✅ 已转换为 Tuple
154
+ service_duration_min=task_dict.get("service_duration_min", 30),
155
+ candidates=internal_candidates,
156
+ )
157
+ )
158
+
159
+ return internal_tasks
160
+
161
+
162
+ def convert_location_to_internal(location: Dict[str, float]) -> _Location:
163
+ """轉換外部 location Dict 為內部 _Location"""
164
+ return _Location(**location)
165
+
166
+
167
+ def convert_result_to_dict(result: _TSPTWResult) -> Dict[str, Any]:
168
+ """
169
+ 轉換內部 _TSPTWResult 為外部 Dict
170
+
171
+ 完全保留原始輸出格式
172
+ """
173
+ return result.model_dump(mode="python")
src/optimization/models/internal_models.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 內部數據模型 - 完整修復版
3
+
4
+ ✅ 修復: 支持部分时间窗口 (start=None 或 end=None)
5
+ ✅ 改進: 添加 departure_time (出發時間)
6
+
7
+ 真实场景:
8
+ - (None, datetime): 只有截止时间,任何时间开始都可以
9
+ - (datetime, None): 只有开始时间,任何时间结束都可以
10
+ - (None, None): 完全没有时间限制
11
+ - (datetime, datetime): 完整的时间窗口
12
+ """
13
+ from typing import List, Dict, Tuple, Any, Optional, Literal, Union
14
+ from datetime import datetime
15
+
16
+ import numpy as np
17
+ from pydantic import BaseModel, Field, field_validator, model_validator
18
+
19
+
20
+ class _POICandidate(BaseModel):
21
+ """
22
+ POI 候選地點 (內部模型)
23
+
24
+ ✅ 支持部分时间窗口:
25
+ - time_window = (datetime, datetime): 完整窗口
26
+ - time_window = (None, datetime): 只有结束时间
27
+ - time_window = (datetime, None): 只有开始时间
28
+ - time_window = (None, None): 无限制 → 转为 None
29
+ - time_window = None: 无限制
30
+ """
31
+ poi_id: str
32
+ lat: float
33
+ lng: float
34
+ time_window: Optional[Tuple[Optional[datetime], Optional[datetime]]] = None
35
+ time_windows: Optional[List[Tuple[Optional[datetime], Optional[datetime]]]] = None
36
+
37
+ @field_validator('time_window', mode='before')
38
+ @classmethod
39
+ def validate_time_window(cls, v):
40
+ """
41
+ 驗證 time_window 字段
42
+
43
+ 支持的输入:
44
+ 1. None → None (无限制)
45
+ 2. (None, None) → None (无限制)
46
+ 3. (datetime, None) → (datetime, None) (只有开始)
47
+ 4. (None, datetime) → (None, datetime) (只有结束)
48
+ 5. (datetime, datetime) → (datetime, datetime) (完整窗口)
49
+ 6. [datetime, datetime] → 转为 tuple
50
+ """
51
+ if v is None:
52
+ return None
53
+
54
+ # 处理 list → tuple
55
+ if isinstance(v, list):
56
+ if len(v) == 0:
57
+ return None
58
+ if len(v) != 2:
59
+ return None
60
+ v = tuple(v)
61
+
62
+ # 处理 tuple
63
+ if isinstance(v, tuple):
64
+ if len(v) != 2:
65
+ return None
66
+
67
+ start, end = v
68
+
69
+ # (None, None) → 无限制
70
+ if start is None and end is None:
71
+ return None
72
+
73
+ # 其他情况都保留(包括部分None)
74
+ return (start, end)
75
+
76
+ return None
77
+
78
+ @field_validator('time_windows', mode='before')
79
+ @classmethod
80
+ def validate_time_windows(cls, v):
81
+ """
82
+ 驗證 time_windows 字段
83
+
84
+ 过滤掉 (None, None),保留部分时间窗口
85
+ """
86
+ if v is None:
87
+ return None
88
+
89
+ if not isinstance(v, list):
90
+ return None
91
+
92
+ result = []
93
+ for item in v:
94
+ if item is None:
95
+ continue
96
+
97
+ # 转换 list → tuple
98
+ if isinstance(item, list):
99
+ if len(item) == 2:
100
+ item = tuple(item)
101
+ else:
102
+ continue
103
+
104
+ # 检查 tuple
105
+ if isinstance(item, tuple):
106
+ if len(item) != 2:
107
+ continue
108
+
109
+ start, end = item
110
+
111
+ # 跳过 (None, None)
112
+ if start is None and end is None:
113
+ continue
114
+
115
+ # 保留其他情况(包括部分None)
116
+ result.append((start, end))
117
+
118
+ return result if result else None
119
+
120
+ @model_validator(mode='after')
121
+ def validate_time_window_logic(self):
122
+ """
123
+ 验证时间窗口的逻辑
124
+
125
+ 如果 start 和 end 都存在,确保 start < end
126
+ """
127
+ # 检查 time_window
128
+ if self.time_window is not None:
129
+ start, end = self.time_window
130
+ if start is not None and end is not None:
131
+ if start >= end:
132
+ raise ValueError(
133
+ f"time_window start ({start}) must be before end ({end})"
134
+ )
135
+
136
+ # 检查 time_windows
137
+ if self.time_windows is not None:
138
+ for i, (start, end) in enumerate(self.time_windows):
139
+ if start is not None and end is not None:
140
+ if start >= end:
141
+ raise ValueError(
142
+ f"time_windows[{i}] start ({start}) must be before end ({end})"
143
+ )
144
+
145
+ return self
146
+
147
+
148
+ class _Task(BaseModel):
149
+ """任務 (內部模型)"""
150
+ task_id: str
151
+ priority: Literal["HIGH", "MEDIUM", "LOW"] = "MEDIUM"
152
+ time_window: Optional[Tuple[Optional[datetime], Optional[datetime]]] = None
153
+ service_duration_min: int = Field(default=30, description="服務時間(分鐘)")
154
+ candidates: List[_POICandidate]
155
+
156
+ @field_validator('time_window', mode='before')
157
+ @classmethod
158
+ def validate_time_window(cls, v):
159
+ """验证 time_window - 与 POICandidate 相同的逻辑"""
160
+ if v is None:
161
+ return None
162
+
163
+ # 处理 list → tuple
164
+ if isinstance(v, list):
165
+ if len(v) == 0:
166
+ return None
167
+ if len(v) != 2:
168
+ return None
169
+ v = tuple(v)
170
+
171
+ # 处理 tuple
172
+ if isinstance(v, tuple):
173
+ if len(v) != 2:
174
+ return None
175
+
176
+ start, end = v
177
+
178
+ # (None, None) → 无限制
179
+ if start is None and end is None:
180
+ return None
181
+
182
+ # 其他情况都保留(包括部分None)
183
+ return (start, end)
184
+
185
+ return None
186
+
187
+ @model_validator(mode='after')
188
+ def validate_time_window_logic(self):
189
+ """验证时间窗口逻辑"""
190
+ if self.time_window is not None:
191
+ start, end = self.time_window
192
+ if start is not None and end is not None:
193
+ if start >= end:
194
+ raise ValueError(
195
+ f"time_window start ({start}) must be before end ({end})"
196
+ )
197
+ return self
198
+
199
+ @property
200
+ def service_duration_sec(self) -> int:
201
+ """轉換為秒(給 OR-Tools 用)"""
202
+ return self.service_duration_min * 60
203
+
204
+
205
+ class _Location(BaseModel):
206
+ """地點 (內部模型)"""
207
+ lat: float
208
+ lng: float
209
+
210
+
211
+ class _RouteStep(BaseModel):
212
+ """
213
+ 路線步驟 (內部模型)
214
+
215
+ ✅ 改進: 添加 departure_time (出發時間)
216
+
217
+ 說明:
218
+ - arrival_time: 到達該地點的時間
219
+ - departure_time: 離開該地點的時間 (到達時間 + 服務時間)
220
+ """
221
+ step: int
222
+ node_index: int
223
+ arrival_time: str = Field(description="到達時間 (ISO 8601 格式)")
224
+ departure_time: str = Field(description="離開時間 (ISO 8601 格式)")
225
+ type: Literal["depot", "task_poi"]
226
+ task_id: Optional[str] = None
227
+ poi_id: Optional[str] = None
228
+ service_duration_min: int = Field(
229
+ default=0,
230
+ description="服務時間(分鐘),depot 為 0"
231
+ )
232
+
233
+
234
+ class _AlternativePOI(BaseModel):
235
+ """備選 POI (內部模型)"""
236
+ node_index: int
237
+ poi_id: str
238
+ lat: float
239
+ lng: float
240
+ interval_idx: Optional[int] = None
241
+ delta_travel_time_min: int = Field(description="額外旅行時間(分鐘)")
242
+ delta_travel_distance_m: int = Field(description="額外距離(米)")
243
+
244
+
245
+ class _TaskDetail(BaseModel):
246
+ """任務詳情 (內部模型)"""
247
+ task_id: str
248
+ priority: str
249
+ visited: bool
250
+ chosen_poi: Optional[Dict[str, Any]] = None
251
+ alternative_pois: List[_AlternativePOI] = []
252
+
253
+
254
+ class _TSPTWResult(BaseModel):
255
+ """TSPTW 求解結果 (內部模型)"""
256
+ status: Literal["OK", "NO_SOLUTION", "NO_TASKS"]
257
+ total_travel_time_min: int = Field(description="總旅行時間(分鐘)")
258
+ total_travel_distance_m: int = Field(description="總距離(米)")
259
+ route: List[_RouteStep]
260
+ visited_tasks: List[str]
261
+ skipped_tasks: List[str]
262
+ tasks_detail: List[_TaskDetail]
263
+
264
+
265
+
266
+ class _OptimizationMetrics(BaseModel):
267
+ """優化指標 (快樂表數據)"""
268
+ # ✅ 新增:完成率指標
269
+ total_tasks: int = Field(description="總任務數")
270
+ completed_tasks: int = Field(description="完成任務數")
271
+ completion_rate_pct: float = Field(description="任務完成率")
272
+
273
+ # 距離指標
274
+ original_distance_m: int
275
+ optimized_distance_m: int
276
+ distance_saved_m: int
277
+ distance_improvement_pct: float
278
+
279
+ # 時間指標
280
+ original_duration_min: int
281
+ optimized_duration_min: int
282
+ time_saved_min: int
283
+ time_improvement_pct: float
284
+
285
+ # 效率指標
286
+ route_efficiency_pct: float
287
+
288
+
289
+ class _TSPTWResult(BaseModel):
290
+ """TSPTW 求解結果 (內部模型)"""
291
+ status: Literal["OK", "NO_SOLUTION", "NO_TASKS"]
292
+ total_travel_time_min: int = Field(description="總旅行時間(分鐘)")
293
+ total_travel_distance_m: int = Field(description="總距離(米)")
294
+
295
+ # ✅ 新增 metrics 欄位
296
+ metrics: Optional[_OptimizationMetrics] = None
297
+
298
+ route: List[_RouteStep]
299
+ visited_tasks: List[str]
300
+ skipped_tasks: List[str]
301
+ tasks_detail: List[_TaskDetail]
302
+
303
+
304
+ class _Graph(BaseModel):
305
+ """圖數據結構 - 包含求解所需的所有信息"""
306
+ node_meta: List[Dict[str, Any]] = Field(description="節點元數據")
307
+ locations: List[Dict[str, float]] = Field(description="所有地點座標")
308
+ duration_matrix: Any = Field(description="時間矩陣 (numpy array)")
309
+ distance_matrix: Any = Field(description="距離矩陣 (numpy array)")
310
+
311
+ class Config:
312
+ arbitrary_types_allowed = True
src/optimization/solver/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+
2
+ from .ortools_solver import ORToolsSolver
3
+ from .solution_extractor import SolutionExtractor
4
+
5
+ __all__ = [
6
+ "ORToolsSolver",
7
+ "SolutionExtractor",
8
+ ]
src/optimization/solver/ortools_solver.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OR-Tools 求解器 - 封裝 OR-Tools API
3
+
4
+ 完全保留原始 tsptw_solver_old.py 的 OR-Tools 設置邏輯
5
+ """
6
+ from typing import List, Dict, Any, Tuple
7
+ from datetime import datetime, timedelta
8
+
9
+ import numpy as np
10
+ from ortools.constraint_solver import routing_enums_pb2
11
+ from ortools.constraint_solver import pywrapcp
12
+
13
+ from src.infra.logger import get_logger
14
+
15
+ from src.optimization.models.internal_models import _Task, _Graph
16
+ from src.optimization.graph.time_window_handler import TimeWindowHandler
17
+
18
+ logger = get_logger(__name__)
19
+
20
+
21
+ class ORToolsSolver:
22
+ """
23
+ OR-Tools 求解器
24
+
25
+ 職責:
26
+ - 創建 RoutingModel 和 RoutingIndexManager
27
+ - 設置時間維度約束
28
+ - 設置優先級約束
29
+ - 執行求解
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ time_limit_seconds: int = 30,
35
+ verbose: bool = False,
36
+ ):
37
+ self.time_limit_seconds = time_limit_seconds
38
+ self.verbose = verbose
39
+ self.tw_handler = TimeWindowHandler()
40
+
41
+ def solve(
42
+ self,
43
+ graph: _Graph,
44
+ tasks: List[_Task],
45
+ start_time: datetime,
46
+ deadline: datetime,
47
+ max_wait_time_sec: int,
48
+ ) -> Tuple[pywrapcp.RoutingModel, pywrapcp.RoutingIndexManager, pywrapcp.Assignment]:
49
+ """
50
+ 求解 TSPTW
51
+
52
+ 完全保留原始邏輯:
53
+ - _solve_internal() 中的 OR-Tools 設置部分
54
+
55
+ Returns:
56
+ (routing, manager, solution): OR-Tools 求解結果
57
+ """
58
+ num_nodes = len(graph.node_meta)
59
+
60
+ # 1. 計算服務時間
61
+ service_time = self._build_service_time_per_node(tasks, graph.node_meta)
62
+
63
+ # 2. 創建 manager
64
+ manager = pywrapcp.RoutingIndexManager(num_nodes, 1, 0)
65
+
66
+ # 3. 創建 routing model
67
+ routing = pywrapcp.RoutingModel(manager)
68
+
69
+ # 4. 註冊 transit callback
70
+ transit_cb_index = self._register_transit_callback(
71
+ routing, manager, graph.duration_matrix, service_time
72
+ )
73
+ routing.SetArcCostEvaluatorOfAllVehicles(transit_cb_index)
74
+
75
+ # 5. 添加時間維度
76
+ time_dimension = self._add_time_dimension(
77
+ routing,
78
+ manager,
79
+ transit_cb_index,
80
+ tasks,
81
+ graph.node_meta,
82
+ start_time,
83
+ deadline,
84
+ max_wait_time_sec,
85
+ )
86
+
87
+ # 6. 添加優先級約束
88
+ self._add_priority_disjunctions(routing, manager, tasks, graph.node_meta)
89
+
90
+ # 7. 設置搜索參數
91
+ search_parameters = self._create_search_parameters()
92
+
93
+ # 8. 求解
94
+ if self.verbose:
95
+ logger.info(
96
+ "Starting OR-Tools search with time limit = %ds",
97
+ self.time_limit_seconds,
98
+ )
99
+
100
+ solution = routing.SolveWithParameters(search_parameters)
101
+
102
+ if self.verbose:
103
+ logger.info("OR-Tools search completed")
104
+
105
+ return routing, manager, solution
106
+
107
+ @staticmethod
108
+ def _build_service_time_per_node(
109
+ tasks: List[_Task],
110
+ node_meta: List[Dict[str, Any]],
111
+ ) -> List[int]:
112
+ """
113
+ 構建每個節點的服務時間(秒)
114
+
115
+ 完全保留原始邏輯: _build_service_time_per_node()
116
+ """
117
+ service_time = [0] * len(node_meta)
118
+
119
+ for node, meta in enumerate(node_meta):
120
+ if meta["type"] == "poi":
121
+ task_idx = meta["task_idx"]
122
+ task = tasks[task_idx]
123
+ service_time[node] = task.service_duration_sec
124
+
125
+ return service_time
126
+
127
+ @staticmethod
128
+ def _register_transit_callback(
129
+ routing: pywrapcp.RoutingModel,
130
+ manager: pywrapcp.RoutingIndexManager,
131
+ duration_matrix: np.ndarray,
132
+ service_time: List[int],
133
+ ) -> int:
134
+ """
135
+ 註冊 transit callback
136
+
137
+ 完全保留原始邏輯: _register_transit_callback()
138
+ """
139
+
140
+ def transit_callback(from_index: int, to_index: int) -> int:
141
+ from_node = manager.IndexToNode(from_index)
142
+ to_node = manager.IndexToNode(to_index)
143
+ travel = duration_matrix[from_node, to_node]
144
+ service = service_time[from_node]
145
+ return int(travel + service)
146
+
147
+ transit_cb_index = routing.RegisterTransitCallback(transit_callback)
148
+ return transit_cb_index
149
+
150
+ def _add_time_dimension(
151
+ self,
152
+ routing: pywrapcp.RoutingModel,
153
+ manager: pywrapcp.RoutingIndexManager,
154
+ transit_cb_index: int,
155
+ tasks: List[_Task],
156
+ node_meta: List[Dict[str, Any]],
157
+ start_time: datetime,
158
+ deadline: datetime,
159
+ max_wait_time_sec: int,
160
+ ) -> pywrapcp.RoutingDimension:
161
+ """
162
+ 添加時間維度約束
163
+
164
+ 完全保留原始邏輯: _add_time_dimension()
165
+ """
166
+ if deadline is None:
167
+ deadline = start_time + timedelta(days=3)
168
+
169
+ horizon_sec = int((deadline - start_time).total_seconds())
170
+
171
+ routing.AddDimension(
172
+ transit_cb_index,
173
+ max_wait_time_sec,
174
+ horizon_sec,
175
+ False,
176
+ "Time",
177
+ )
178
+
179
+ time_dimension = routing.GetDimensionOrDie("Time")
180
+
181
+ # depot 起點:允許在 [0, horizon] 內出發
182
+ start_index = routing.Start(0)
183
+ time_dimension.CumulVar(start_index).SetRange(0, horizon_sec)
184
+
185
+ for node in range(1, len(node_meta)):
186
+ meta = node_meta[node]
187
+ if meta["type"] != "poi":
188
+ continue
189
+
190
+ index = manager.NodeToIndex(node)
191
+ task_idx = meta["task_idx"]
192
+ task = tasks[task_idx]
193
+
194
+ poi_tw = meta.get("poi_time_window")
195
+ task_tw = task.time_window
196
+
197
+ # 計算有效時間窗口
198
+ start_sec, end_sec = self.tw_handler.compute_effective_time_window(
199
+ task_tw, poi_tw, start_time, horizon_sec
200
+ )
201
+
202
+ if start_sec > end_sec:
203
+ # 完全無交集 → 強制一個無效的 0 範圍,讓 solver 自己避免
204
+ logger.warning(
205
+ "Node(%s) has infeasible time window, forcing tiny 0 range.",
206
+ meta,
207
+ )
208
+ start_sec = end_sec = 0
209
+
210
+ time_dimension.CumulVar(index).SetRange(start_sec, end_sec)
211
+
212
+ end_index = routing.End(0)
213
+ time_dimension.CumulVar(end_index).SetRange(0, horizon_sec)
214
+
215
+ return time_dimension
216
+
217
+ @staticmethod
218
+ def _add_priority_disjunctions(
219
+ routing: pywrapcp.RoutingModel,
220
+ manager: pywrapcp.RoutingIndexManager,
221
+ tasks: List[_Task],
222
+ node_meta: List[Dict[str, Any]],
223
+ ) -> None:
224
+ """
225
+ 添加優先級約束
226
+
227
+ 完全保留原始邏輯: _add_priority_disjunctions()
228
+ """
229
+ task_nodes: Dict[int, List[int]] = {i: [] for i in range(len(tasks))}
230
+
231
+ for node in range(1, len(node_meta)):
232
+ meta = node_meta[node]
233
+ if meta["type"] != "poi":
234
+ continue
235
+ task_idx = meta["task_idx"]
236
+ task_nodes[task_idx].append(node)
237
+
238
+ for task_idx, nodes in task_nodes.items():
239
+ if not nodes:
240
+ continue
241
+
242
+ task = tasks[task_idx]
243
+ priority = task.priority
244
+
245
+ # 根據優先級設定 penalty
246
+ if priority == "HIGH":
247
+ penalty = 10_000_000
248
+ elif priority == "MEDIUM":
249
+ penalty = 100_000
250
+ else:
251
+ penalty = 10_000
252
+
253
+ routing_indices = [manager.NodeToIndex(n) for n in nodes]
254
+ routing.AddDisjunction(routing_indices, penalty)
255
+
256
+ def _create_search_parameters(self) -> pywrapcp.DefaultRoutingSearchParameters:
257
+ """
258
+ 創建搜索參數
259
+
260
+ 完全保留原始邏輯
261
+ """
262
+ search_parameters = pywrapcp.DefaultRoutingSearchParameters()
263
+ search_parameters.first_solution_strategy = (
264
+ routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
265
+ )
266
+ search_parameters.local_search_metaheuristic = (
267
+ routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH
268
+ )
269
+ search_parameters.time_limit.FromSeconds(self.time_limit_seconds)
270
+
271
+ return search_parameters
272
+
273
+
274
+
src/optimization/solver/solution_extractor.py ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 結果提取器 - 從 OR-Tools 結果提取路線和任務詳情
3
+ """
4
+ from typing import List, Dict, Tuple, Any, Set, Optional
5
+ from datetime import datetime, timedelta
6
+
7
+ import numpy as np
8
+ from ortools.constraint_solver import pywrapcp
9
+
10
+ from src.infra.logger import get_logger
11
+
12
+ from ..models.internal_models import (
13
+ _Task,
14
+ _Graph,
15
+ _TSPTWResult,
16
+ _RouteStep,
17
+ _TaskDetail,
18
+ _AlternativePOI,
19
+ _OptimizationMetrics, # ✅ 確保有 import 這個
20
+ )
21
+ from ..graph.time_window_handler import TimeWindowHandler
22
+
23
+ logger = get_logger(__name__)
24
+
25
+
26
+ class SolutionExtractor:
27
+ """
28
+ 結果提取器
29
+
30
+ 職責:
31
+ - 從 OR-Tools 結果提取路線
32
+ - 計算總時間/距離
33
+ - 構建任務詳情(包含備選 POI)
34
+ - ✅ 計算快樂表指標 (Metrics)
35
+ """
36
+
37
+ def __init__(self):
38
+ self.tw_handler = TimeWindowHandler()
39
+
40
+ def extract(
41
+ self,
42
+ routing: pywrapcp.RoutingModel,
43
+ manager: pywrapcp.RoutingIndexManager,
44
+ solution: pywrapcp.Assignment,
45
+ time_dimension: pywrapcp.RoutingDimension,
46
+ start_time: datetime,
47
+ graph: _Graph,
48
+ tasks: List[_Task],
49
+ alt_k: int,
50
+ return_to_start: bool,
51
+ ) -> _TSPTWResult:
52
+ """
53
+ 提取完整結果
54
+ """
55
+ duration_matrix = graph.duration_matrix
56
+ distance_matrix = graph.distance_matrix
57
+ node_meta = graph.node_meta
58
+ locations = graph.locations
59
+
60
+ route: List[_RouteStep] = []
61
+ visited_task_ids = set()
62
+
63
+ total_travel_time = 0
64
+ total_travel_distance = 0
65
+
66
+ sequence_nodes: List[int] = []
67
+ sequence_indices: List[int] = []
68
+
69
+ # 1. 提取訪問順序
70
+ idx = routing.Start(0)
71
+ while not routing.IsEnd(idx):
72
+ node = manager.IndexToNode(idx)
73
+ sequence_indices.append(idx)
74
+ sequence_nodes.append(node)
75
+ idx = solution.Value(routing.NextVar(idx))
76
+
77
+ end_node = manager.IndexToNode(idx)
78
+ sequence_indices.append(idx)
79
+ sequence_nodes.append(end_node)
80
+
81
+ # 如果不要顯示回到出發點,就把最後這個 depot 去掉
82
+ if not return_to_start:
83
+ if len(sequence_nodes) >= 2 and node_meta[sequence_nodes[-1]]["type"] == "depot":
84
+ sequence_nodes = sequence_nodes[:-1]
85
+ sequence_indices = sequence_indices[:-1]
86
+
87
+ # 2. 構建前後節點映射(用於計算備選 POI)
88
+ node_prev_next: Dict[int, Tuple[int, int]] = {}
89
+ for i, node in enumerate(sequence_nodes):
90
+ prev_node = sequence_nodes[i - 1] if i > 0 else sequence_nodes[0]
91
+ next_node = sequence_nodes[i + 1] if i < len(sequence_nodes) - 1 else sequence_nodes[-1]
92
+ node_prev_next[node] = (prev_node, next_node)
93
+
94
+ task_nodes: Dict[str, List[int]] = {}
95
+ chosen_node_per_task: Dict[str, int] = {}
96
+ arrival_sec_map: Dict[int, int] = {}
97
+
98
+ task_service_duration_map = {
99
+ i: task.service_duration_min
100
+ for i, task in enumerate(tasks)
101
+ }
102
+
103
+ # 3. 提取路線和訪問信息
104
+ for step_idx, (routing_index, node) in enumerate(zip(sequence_indices, sequence_nodes)):
105
+ meta = node_meta[node]
106
+ time_var = time_dimension.CumulVar(routing_index)
107
+ arr_sec = solution.Value(time_var)
108
+ arr_dt = start_time + timedelta(seconds=int(arr_sec))
109
+
110
+ arrival_sec_map[node] = arr_sec
111
+
112
+ if meta["type"] == "depot":
113
+ step = _RouteStep(
114
+ step=step_idx,
115
+ node_index=node,
116
+ arrival_time=arr_dt.isoformat(),
117
+ departure_time=arr_dt.isoformat(),
118
+ type="depot",
119
+ )
120
+ else:
121
+ task_id = meta["task_id"]
122
+ task_idx = meta["task_idx"]
123
+ service_duration_min = task_service_duration_map.get(task_idx, 5)
124
+ service_duration_sec = service_duration_min * 60
125
+ dep_dt = arr_dt + timedelta(seconds=service_duration_sec)
126
+
127
+ visited_task_ids.add(task_id)
128
+ task_nodes.setdefault(task_id, [])
129
+ task_nodes[task_id].append(node)
130
+
131
+ if task_id not in chosen_node_per_task:
132
+ chosen_node_per_task[task_id] = node
133
+
134
+ step = _RouteStep(
135
+ step=step_idx,
136
+ node_index=node,
137
+ arrival_time=arr_dt.isoformat(),
138
+ departure_time=dep_dt.isoformat(),
139
+ type="task_poi",
140
+ task_id=task_id,
141
+ poi_id=meta["poi_id"],
142
+ service_duration_min=service_duration_min, # 確保回傳服務時間
143
+ )
144
+
145
+ route.append(step)
146
+
147
+ # 4. 補充未訪問的節點到 task_nodes(用於備選 POI 計算)
148
+ for node in range(1, len(node_meta)):
149
+ meta = node_meta[node]
150
+ if meta["type"] != "poi":
151
+ continue
152
+ task_id = meta["task_id"]
153
+ task_nodes.setdefault(task_id, [])
154
+ if node not in task_nodes[task_id]:
155
+ task_nodes[task_id].append(node)
156
+
157
+ # 5. 計算優化後的總距離 (Optimized Distance)
158
+ for i in range(len(sequence_nodes) - 1):
159
+ n1 = sequence_nodes[i]
160
+ n2 = sequence_nodes[i + 1]
161
+ total_travel_distance += distance_matrix[n1, n2]
162
+ total_travel_time += duration_matrix[n1, n2] # 這是純交通時間
163
+
164
+ # 6. 計算快樂表 metrics (Baseline vs Optimized)
165
+
166
+ # 6.1 計算總服務時間 (分子)
167
+ total_service_time_sec = sum(
168
+ tasks[node_meta[node]["task_idx"]].service_duration_sec
169
+ for node in sequence_nodes
170
+ if node_meta[node]["type"] == "poi"
171
+ )
172
+
173
+ # 6.2 優化後的總耗時 (Total Duration = Finish - Start)
174
+ # 注意: 這是包含 Waiting Time 的總工時
175
+ #last_node_idx = sequence_indices[-1]
176
+ #optimized_total_duration_sec = solution.Value(time_dimension.CumulVar(last_node_idx))
177
+ optimized_pure_duration = total_travel_time + total_service_time_sec
178
+
179
+ # 6.3 執行計算
180
+ metrics = self._calculate_metrics(
181
+ graph=graph,
182
+ tasks=tasks,
183
+ visited_task_ids=visited_task_ids,
184
+ optimized_dist=total_travel_distance,
185
+ optimized_duration=optimized_pure_duration,
186
+ total_service_time=total_service_time_sec,
187
+ return_to_start=return_to_start
188
+ )
189
+
190
+ # 7. 構建任務詳情
191
+ tasks_detail = self._build_tasks_detail(
192
+ tasks=tasks,
193
+ visited_task_ids=visited_task_ids,
194
+ chosen_node_per_task=chosen_node_per_task,
195
+ task_nodes=task_nodes,
196
+ node_meta=node_meta,
197
+ locations=locations,
198
+ node_prev_next=node_prev_next,
199
+ arrival_sec_map=arrival_sec_map,
200
+ duration_matrix=duration_matrix,
201
+ distance_matrix=distance_matrix,
202
+ start_time=start_time,
203
+ alt_k=alt_k,
204
+ )
205
+
206
+ skipped_tasks = sorted(set([t.task_id for t in tasks]) - visited_task_ids)
207
+
208
+ return _TSPTWResult(
209
+ status="OK",
210
+ total_travel_time_min=int(total_travel_time // 60),
211
+ total_travel_distance_m=int(total_travel_distance),
212
+ metrics=metrics, # ✅ 這裡把計算好的 metrics 塞進去
213
+ route=route,
214
+ visited_tasks=sorted(list(visited_task_ids)),
215
+ skipped_tasks=skipped_tasks,
216
+ tasks_detail=tasks_detail,
217
+ )
218
+
219
+ def _calculate_metrics(
220
+ self,
221
+ graph: _Graph,
222
+ tasks: List[_Task],
223
+ visited_task_ids: Set[str],
224
+ optimized_dist: float,
225
+ optimized_duration: int,
226
+ total_service_time: int,
227
+ return_to_start: bool
228
+ ) -> _OptimizationMetrics:
229
+ """
230
+ 計算優化指標
231
+ """
232
+ dist_matrix = graph.distance_matrix
233
+ dur_matrix = graph.duration_matrix
234
+ node_meta = graph.node_meta
235
+
236
+ # 1. 建立映射: 哪個 Task 的第 0 個 Candidate 對應哪個 Node Index
237
+ task_cand_to_node = {}
238
+ for idx, meta in enumerate(node_meta):
239
+ if meta["type"] == "poi":
240
+ key = (meta["task_idx"], meta["candidate_idx"])
241
+ task_cand_to_node[key] = idx
242
+
243
+ # 2. 模擬 Baseline (笨方法): 照順序跑,每個都選第一個點
244
+ baseline_nodes = [0] # Start at Depot
245
+ for i, task in enumerate(tasks):
246
+ # 總是選第一個候選點 (Candidate 0)
247
+ target_node = task_cand_to_node.get((i, 0))
248
+ if target_node:
249
+ baseline_nodes.append(target_node)
250
+
251
+ if return_to_start:
252
+ baseline_nodes.append(0)
253
+
254
+ # 3. 計算 Baseline 成本
255
+ base_dist = 0
256
+ base_travel_time = 0
257
+
258
+ if len(baseline_nodes) > 1:
259
+ for i in range(len(baseline_nodes) - 1):
260
+ u = baseline_nodes[i]
261
+ v = baseline_nodes[i + 1]
262
+ base_dist += dist_matrix[u, v]
263
+ base_travel_time += dur_matrix[u, v]
264
+
265
+ # Baseline 總時間 = 純交通 + 總服務 (假設笨跑法不考慮等待,只算硬成本)
266
+ base_total_duration = base_travel_time + total_service_time
267
+
268
+ # 4. 計算百分比
269
+ dist_imp_pct = 0.0
270
+ if base_dist > 0:
271
+ dist_imp_pct = ((base_dist - optimized_dist) / base_dist) * 100
272
+
273
+ time_imp_pct = 0.0
274
+ if base_total_duration > 0:
275
+ time_imp_pct = ((base_total_duration - optimized_duration) / base_total_duration) * 100
276
+
277
+ efficiency_pct = 0.0
278
+ if optimized_duration > 0:
279
+ efficiency_pct = (total_service_time / optimized_duration) * 100
280
+
281
+ total_task_count = len(tasks)
282
+ completed_count = len(visited_task_ids)
283
+ completion_rate = (completed_count / total_task_count * 100) if total_task_count > 0 else 0.0
284
+
285
+ return _OptimizationMetrics(
286
+ # ✅ 新增完成率資訊
287
+ total_tasks=total_task_count,
288
+ completed_tasks=completed_count,
289
+ completion_rate_pct=round(completion_rate, 1),
290
+
291
+ original_distance_m=int(base_dist),
292
+ optimized_distance_m=int(optimized_dist),
293
+ distance_saved_m=int(base_dist - optimized_dist),
294
+ distance_improvement_pct=round(dist_imp_pct, 1),
295
+
296
+ original_duration_min=int(base_total_duration // 60),
297
+ optimized_duration_min=int(optimized_duration // 60),
298
+ time_saved_min=int((base_total_duration - optimized_duration) // 60),
299
+ time_improvement_pct=round(time_imp_pct, 1),
300
+
301
+ route_efficiency_pct=round(efficiency_pct, 1)
302
+ )
303
+
304
+ def _build_tasks_detail(
305
+ self,
306
+ tasks: List[_Task],
307
+ visited_task_ids: Set[str],
308
+ chosen_node_per_task: Dict[str, int],
309
+ task_nodes: Dict[str, List[int]],
310
+ node_meta: List[Dict[str, Any]],
311
+ locations: List[Dict[str, float]],
312
+ node_prev_next: Dict[int, Tuple[int, int]],
313
+ arrival_sec_map: Dict[int, int],
314
+ duration_matrix: np.ndarray,
315
+ distance_matrix: np.ndarray,
316
+ start_time: datetime,
317
+ alt_k: int,
318
+ ) -> List[_TaskDetail]:
319
+ """
320
+ 構建任務詳情(保持原本邏輯不變)
321
+ """
322
+ tasks_detail: List[_TaskDetail] = []
323
+ all_task_ids = [t.task_id for t in tasks]
324
+ task_priority_map = {t.task_id: t.priority for t in tasks}
325
+
326
+ for task_id in all_task_ids:
327
+ visited = task_id in visited_task_ids
328
+ priority = task_priority_map.get(task_id, "MEDIUM")
329
+
330
+ chosen_node = chosen_node_per_task.get(task_id, None)
331
+ all_nodes_for_task = task_nodes.get(task_id, [])
332
+
333
+ chosen_poi_info = None
334
+ if visited and chosen_node is not None:
335
+ meta = node_meta[chosen_node]
336
+ loc = locations[chosen_node]
337
+ chosen_poi_info = {
338
+ "node_index": chosen_node,
339
+ "poi_id": meta["poi_id"],
340
+ "lat": loc["lat"],
341
+ "lng": loc["lng"],
342
+ "interval_idx": meta.get("interval_idx"),
343
+ }
344
+
345
+ alternative_pois: List[_AlternativePOI] = []
346
+
347
+ if visited and chosen_node is not None and len(all_nodes_for_task) > 1:
348
+ alternative_pois = self._find_alternative_pois(
349
+ chosen_node=chosen_node,
350
+ all_nodes_for_task=all_nodes_for_task,
351
+ node_meta=node_meta,
352
+ locations=locations,
353
+ node_prev_next=node_prev_next,
354
+ arrival_sec_map=arrival_sec_map,
355
+ duration_matrix=duration_matrix,
356
+ distance_matrix=distance_matrix,
357
+ tasks=tasks,
358
+ start_time=start_time,
359
+ alt_k=alt_k,
360
+ )
361
+
362
+ tasks_detail.append(
363
+ _TaskDetail(
364
+ task_id=task_id,
365
+ priority=priority,
366
+ visited=visited,
367
+ chosen_poi=chosen_poi_info,
368
+ alternative_pois=alternative_pois,
369
+ )
370
+ )
371
+
372
+ return tasks_detail
373
+
374
+ def _find_alternative_pois(
375
+ self,
376
+ chosen_node: int,
377
+ all_nodes_for_task: List[int],
378
+ node_meta: List[Dict[str, Any]],
379
+ locations: List[Dict[str, float]],
380
+ node_prev_next: Dict[int, Tuple[int, int]],
381
+ arrival_sec_map: Dict[int, int],
382
+ duration_matrix: np.ndarray,
383
+ distance_matrix: np.ndarray,
384
+ tasks: List[_Task],
385
+ start_time: datetime,
386
+ alt_k: int,
387
+ ) -> List[_AlternativePOI]:
388
+ """
389
+ 找出 Top-K 備選 POI (保持原本邏輯不變)
390
+ """
391
+ prev_node, next_node = node_prev_next[chosen_node]
392
+ base_time = duration_matrix[prev_node, chosen_node] + duration_matrix[chosen_node, next_node]
393
+ base_dist = distance_matrix[prev_node, chosen_node] + distance_matrix[chosen_node, next_node]
394
+
395
+ chosen_meta = node_meta[chosen_node]
396
+ chosen_poi_id = chosen_meta["poi_id"]
397
+ chosen_arr_sec = arrival_sec_map.get(chosen_node, 0)
398
+
399
+ candidates_alt: List[_AlternativePOI] = []
400
+
401
+ for cand_node in all_nodes_for_task:
402
+ meta_c = node_meta[cand_node]
403
+
404
+ if cand_node == chosen_node:
405
+ continue
406
+
407
+ if meta_c["poi_id"] == chosen_poi_id:
408
+ continue
409
+
410
+ cand_start_sec, cand_end_sec = self.tw_handler.get_node_time_window_sec(
411
+ meta_c, tasks, start_time
412
+ )
413
+
414
+ if not (cand_start_sec <= chosen_arr_sec <= cand_end_sec):
415
+ continue
416
+
417
+ dt = duration_matrix[prev_node, cand_node] + duration_matrix[cand_node, next_node]
418
+ dd = distance_matrix[prev_node, cand_node] + distance_matrix[cand_node, next_node]
419
+
420
+ delta_time_sec = int(dt - base_time)
421
+ delta_dist = int(dd - base_dist)
422
+
423
+ loc_c = locations[cand_node]
424
+
425
+ candidates_alt.append(
426
+ _AlternativePOI(
427
+ node_index=cand_node,
428
+ poi_id=meta_c["poi_id"],
429
+ lat=loc_c["lat"],
430
+ lng=loc_c["lng"],
431
+ interval_idx=meta_c.get("interval_idx"),
432
+ delta_travel_time_min=delta_time_sec // 60,
433
+ delta_travel_distance_m=delta_dist,
434
+ )
435
+ )
436
+
437
+ candidates_alt.sort(key=lambda x: x.delta_travel_time_min)
438
+ return candidates_alt[:alt_k]
src/optimization/test/__init__.py ADDED
File without changes
src/optimization/test/_solver_test.py ADDED
@@ -0,0 +1,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # test_tsptw_solver_improved.py
2
+
3
+ from datetime import datetime
4
+ from src.optimization.tsptw_solver import TSPTWSolver
5
+
6
+ def build_sample_tasks():
7
+ """構建示例任務(使用分鐘作為時間單位)"""
8
+ base_date = datetime(2025, 11, 15)
9
+
10
+ tasks = [
11
+ {
12
+ "task_id": "museum",
13
+ "priority": "MEDIUM",
14
+ # Task-level 單段 time_window: 09:00—18:00
15
+ "time_window": (
16
+ base_date.replace(hour=9, minute=0),
17
+ base_date.replace(hour=18, minute=0),
18
+ ),
19
+ "service_duration_min": 90, # ← 改為分鐘: 看展 1.5 小時
20
+ "candidates": [
21
+ {
22
+ "poi_id": "museum_A",
23
+ "lat": 25.0350,
24
+ "lng": 121.5200,
25
+ # POI-level 多段 time_windows: 早上10-12, 下午14-16
26
+ "time_windows": [
27
+ (
28
+ base_date.replace(hour=10, minute=0),
29
+ base_date.replace(hour=12, minute=0),
30
+ ),
31
+ (
32
+ base_date.replace(hour=14, minute=0),
33
+ base_date.replace(hour=16, minute=0),
34
+ ),
35
+ ],
36
+ }
37
+ ],
38
+ },
39
+ {
40
+ "task_id": "hospital",
41
+ "priority": "HIGH", # ← 改為 HIGH,測試優先級
42
+ "time_window": (
43
+ base_date.replace(hour=9, minute=0),
44
+ base_date.replace(hour=12, minute=0),
45
+ ),
46
+ "service_duration_min": 30, # ← 改為分鐘: 30分鐘
47
+ "candidates": [
48
+ {
49
+ "poi_id": "ntuh",
50
+ "lat": 25.0418,
51
+ "lng": 121.5360,
52
+ }
53
+ ],
54
+ },
55
+ {
56
+ "task_id": "coffee",
57
+ "priority": "LOW",
58
+ "service_duration_min": 45, # ← 改為分鐘: 45分鐘
59
+ "candidates": [
60
+ {
61
+ "poi_id": "coffee_A",
62
+ "lat": 25.0370,
63
+ "lng": 121.5250,
64
+ "time_window": (
65
+ base_date.replace(hour=13, minute=0),
66
+ base_date.replace(hour=18, minute=0),
67
+ ),
68
+ },
69
+ {
70
+ "poi_id": "coffee_B",
71
+ "lat": 25.0390,
72
+ "lng": 121.5270,
73
+ "time_window": (
74
+ base_date.replace(hour=10, minute=0),
75
+ base_date.replace(hour=20, minute=0),
76
+ ),
77
+ },
78
+ ],
79
+ },
80
+ ]
81
+ return tasks
82
+
83
+
84
+ def build_complex_tasks():
85
+ """
86
+ 構建一組較複雜的測試場景:
87
+ - 5 個 task
88
+ - 每個 task 1–5 個候選 POI
89
+ - Task-level 單段 time_window
90
+ - POI-level 有單段 / 多段 time_window(s)
91
+ - service_duration 使用「分鐘」欄位 (service_duration_min)
92
+ -> 之後用 helper 轉成秒給 solver
93
+ """
94
+ base_date = datetime(2025, 11, 15)
95
+
96
+ tasks = [
97
+ # 1) 早餐:3 間早餐店,早上才開
98
+ {
99
+ "task_id": "breakfast",
100
+ "priority": "MEDIUM",
101
+ "time_window": (
102
+ base_date.replace(hour=7, minute=30),
103
+ base_date.replace(hour=10, minute=30),
104
+ ),
105
+ "service_duration_min": 30,
106
+ "candidates": [
107
+ {
108
+ "poi_id": "breakfast_taipei_main",
109
+ "lat": 25.0478,
110
+ "lng": 121.5170,
111
+ "time_window": (
112
+ base_date.replace(hour=7, minute=30),
113
+ base_date.replace(hour=10, minute=0),
114
+ ),
115
+ },
116
+ {
117
+ "poi_id": "breakfast_taoyuan",
118
+ "lat": 24.9936,
119
+ "lng": 121.2969,
120
+ "time_window": (
121
+ base_date.replace(hour=8, minute=0),
122
+ base_date.replace(hour=11, minute=0),
123
+ ),
124
+ },
125
+ {
126
+ "poi_id": "breakfast_tamsui",
127
+ "lat": 25.1676,
128
+ "lng": 121.4450,
129
+ "time_window": (
130
+ base_date.replace(hour=7, minute=0),
131
+ base_date.replace(hour=10, minute=30),
132
+ ),
133
+ },
134
+ ],
135
+ },
136
+
137
+ # 2) 景點:陽明山 / 淡水,含多段 time_windows(午休)
138
+ {
139
+ "task_id": "scenic_spot",
140
+ "priority": "MEDIUM",
141
+ "time_window": (
142
+ base_date.replace(hour=9, minute=0),
143
+ base_date.replace(hour=17, minute=30),
144
+ ),
145
+ "service_duration_min": 90,
146
+ "candidates": [
147
+ {
148
+ "poi_id": "yangmingshan_park",
149
+ "lat": 25.1618,
150
+ "lng": 121.5395,
151
+ # 上午場 + 下午場
152
+ "time_windows": [
153
+ (
154
+ base_date.replace(hour=9, minute=30),
155
+ base_date.replace(hour=12, minute=0),
156
+ ),
157
+ (
158
+ base_date.replace(hour=13, minute=30),
159
+ base_date.replace(hour=17, minute=0),
160
+ ),
161
+ ],
162
+ },
163
+ {
164
+ "poi_id": "tamsui_old_street",
165
+ "lat": 25.1697,
166
+ "lng": 121.4444,
167
+ # 一整天開放
168
+ "time_window": (
169
+ base_date.replace(hour=10, minute=0),
170
+ base_date.replace(hour=19, minute=0),
171
+ ),
172
+ },
173
+ ],
174
+ },
175
+
176
+ # 3) 午餐(HIGH):4 間餐廳
177
+ {
178
+ "task_id": "lunch",
179
+ "priority": "HIGH",
180
+ "time_window": (
181
+ base_date.replace(hour=11, minute=0),
182
+ base_date.replace(hour=15, minute=0),
183
+ ),
184
+ "service_duration_min": 60,
185
+ "candidates": [
186
+ {
187
+ "poi_id": "lunch_ximen",
188
+ "lat": 25.0422,
189
+ "lng": 121.5078,
190
+ "time_window": (
191
+ base_date.replace(hour=11, minute=0),
192
+ base_date.replace(hour=14, minute=0),
193
+ ),
194
+ },
195
+ {
196
+ "poi_id": "lunch_zhongshan",
197
+ "lat": 25.0522,
198
+ "lng": 121.5200,
199
+ "time_window": (
200
+ base_date.replace(hour=11, minute=30),
201
+ base_date.replace(hour=15, minute=0),
202
+ ),
203
+ },
204
+ {
205
+ "poi_id": "lunch_keelung_miaokou",
206
+ "lat": 25.1283,
207
+ "lng": 121.7410,
208
+ "time_window": (
209
+ base_date.replace(hour=11, minute=0),
210
+ base_date.replace(hour=16, minute=0),
211
+ ),
212
+ },
213
+ {
214
+ "poi_id": "lunch_taoyuan_station",
215
+ "lat": 24.9890,
216
+ "lng": 121.3110,
217
+ "time_window": (
218
+ base_date.replace(hour=11, minute=0),
219
+ base_date.replace(hour=14, minute=30),
220
+ ),
221
+ },
222
+ ],
223
+ },
224
+
225
+ # 4) 下午購物(LOW):3 間 mall
226
+ {
227
+ "task_id": "shopping",
228
+ "priority": "LOW",
229
+ "time_window": (
230
+ base_date.replace(hour=12, minute=0),
231
+ base_date.replace(hour=21, minute=30),
232
+ ),
233
+ "service_duration_min": 90,
234
+ "candidates": [
235
+ {
236
+ "poi_id": "mall_xinyi",
237
+ "lat": 25.0340,
238
+ "lng": 121.5645,
239
+ "time_window": (
240
+ base_date.replace(hour=11, minute=0),
241
+ base_date.replace(hour=22, minute=0),
242
+ ),
243
+ },
244
+ {
245
+ "poi_id": "mall_banciao",
246
+ "lat": 25.0143,
247
+ "lng": 121.4672,
248
+ "time_window": (
249
+ base_date.replace(hour=12, minute=0),
250
+ base_date.replace(hour=22, minute=30),
251
+ ),
252
+ },
253
+ {
254
+ "poi_id": "mall_taoyuan",
255
+ "lat": 25.0134,
256
+ "lng": 121.2150,
257
+ "time_window": (
258
+ base_date.replace(hour=11, minute=0),
259
+ base_date.replace(hour=21, minute=30),
260
+ ),
261
+ },
262
+ ],
263
+ },
264
+
265
+ # 5) 夜景(LOW):2 個夜景點,晚間才開
266
+ {
267
+ "task_id": "night_view",
268
+ "priority": "LOW",
269
+ "time_window": (
270
+ base_date.replace(hour=18, minute=0),
271
+ base_date.replace(hour=23, minute=59),
272
+ ),
273
+ "service_duration_min": 60,
274
+ "candidates": [
275
+ {
276
+ "poi_id": "elephant_mountain",
277
+ "lat": 25.0272,
278
+ "lng": 121.5703,
279
+ "time_window": (
280
+ base_date.replace(hour=17, minute=30),
281
+ base_date.replace(hour=23, minute=0),
282
+ ),
283
+ },
284
+ {
285
+ "poi_id": "yangmingshan_night_view",
286
+ "lat": 25.1630,
287
+ "lng": 121.5450,
288
+ "time_window": (
289
+ base_date.replace(hour=18, minute=0),
290
+ base_date.replace(hour=23, minute=30),
291
+ ),
292
+ },
293
+ ],
294
+ },
295
+ ]
296
+
297
+ return tasks
298
+
299
+
300
+
301
+
302
+ def print_separator(title: str):
303
+ """打印分隔線"""
304
+ print("\n" + "=" * 80)
305
+ print(f" {title}")
306
+ print("=" * 80 + "\n")
307
+
308
+
309
+ def main():
310
+ print_separator("TSPTW Solver 改進版測試 (時間單位: 分鐘)")
311
+ from src.infra.config import get_settings
312
+ settings = get_settings()
313
+ # 創建求解器
314
+ solver = TSPTWSolver(api_key=settings.google_maps_api_key,
315
+ time_limit_seconds=5, verbose=True)
316
+
317
+ # 設置參數
318
+ start_location = {"lat": 25.0400, "lng": 121.5300}
319
+ start_time = datetime(2025, 11, 15, 9, 0)
320
+ deadline = datetime(2025, 11, 15, 21, 0)
321
+
322
+ # 構建任務
323
+ tasks = build_sample_tasks()
324
+ #tasks = build_complex_tasks()
325
+
326
+ print("📋 任務列表:")
327
+ for i, task in enumerate(tasks, 1):
328
+ print(f"{i}. {task['task_id']}")
329
+ print(f" - 優先級: {task['priority']}")
330
+ print(f" - 服務時間: {task['service_duration_min']} 分鐘") # ← 分鐘
331
+ print(f" - 候選 POI 數量: {len(task['candidates'])}")
332
+ if task.get('time_window'):
333
+ tw = task['time_window']
334
+ print(f" - 時間窗口: {tw[0].strftime('%H:%M')}-{tw[1].strftime('%H:%M')}")
335
+ print()
336
+
337
+ print_separator("開始求解")
338
+
339
+ # 求解
340
+ result = solver.solve(
341
+ tasks=tasks,
342
+ start_location=start_location,
343
+ start_time=start_time,
344
+ deadline=deadline,
345
+ max_wait_time_min=60,
346
+ alt_k=3,
347
+ return_to_start=True,
348
+ )
349
+
350
+ print_separator("求解結果")
351
+
352
+ print(result)
353
+
354
+ # 打印結果
355
+ print(f"狀態: {result['status']}")
356
+ print(f"總旅行時間: {result['total_travel_time_min']} 分鐘") # ← 分鐘
357
+ print(f"總距離: {result['total_travel_distance_m']} 米")
358
+ print()
359
+
360
+ print_separator("路線詳情")
361
+ for step in result["route"]:
362
+ print(f"Step {step['step']}:")
363
+ print(f" 到达: {step['arrival_time']}")
364
+ print(f" 离开: {step['departure_time']}") # ✅ 新增
365
+ print(f" 停留: {step['service_duration_min']}分钟") # ✅ 新增
366
+ print()
367
+
368
+ print_separator("任務狀態")
369
+ print(f"✅ 已完成任務: {', '.join(result['visited_tasks']) if result['visited_tasks'] else '無'}")
370
+ print(f"❌ 跳過任務: {', '.join(result['skipped_tasks']) if result['skipped_tasks'] else '無'}")
371
+
372
+ print_separator("任務詳情與備選方案")
373
+ for task_detail in result["tasks_detail"]:
374
+ task_id = task_detail["task_id"]
375
+ priority = task_detail["priority"]
376
+ visited = task_detail["visited"]
377
+
378
+ status_emoji = "✅" if visited else "❌"
379
+ print(f"\n{status_emoji} 任務: {task_id} (優先級: {priority})")
380
+
381
+ if visited and task_detail["chosen_poi"]:
382
+ chosen = task_detail["chosen_poi"]
383
+ print(f" 選擇的 POI: {chosen['poi_id']}")
384
+ print(f" 位置: ({chosen['lat']:.4f}, {chosen['lng']:.4f})")
385
+
386
+ if task_detail["alternative_pois"]:
387
+ print(f"\n 📍 備選方案 (Top {len(task_detail['alternative_pois'])}):")
388
+ for i, alt in enumerate(task_detail["alternative_pois"], 1):
389
+ delta_time = alt["delta_travel_time_min"] # ← 分鐘
390
+ delta_dist = alt["delta_travel_distance_m"]
391
+
392
+ time_str = f"+{delta_time}" if delta_time > 0 else str(delta_time)
393
+ dist_str = f"+{delta_dist}" if delta_dist > 0 else str(delta_dist)
394
+
395
+ print(f" {i}. {alt['poi_id']}")
396
+ print(f" 時間差: {time_str} 分鐘, 距離差: {dist_str} 米") # ← 分鐘
397
+ else:
398
+ print(f" 狀態: 未訪問")
399
+
400
+ print_separator("測試完成")
401
+
402
+ # 驗證時間單位
403
+ print("\n✅ 驗證:")
404
+ print(f" - service_duration_min: {tasks[0]['service_duration_min']} 分鐘")
405
+ print(f" - total_travel_time_min: {result['total_travel_time_min']} 分鐘")
406
+ print(f" - max_wait_time_min: 60 分鐘")
407
+
408
+
409
+ if __name__ == "__main__":
410
+ main()
src/optimization/test/_test_convertes.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 测试時間視窗转换和求解
3
+
4
+ 验证修复是否正確
5
+ """
6
+ from datetime import datetime
7
+
8
+
9
+ def test_parse_time_window():
10
+ """测试時間視窗解析"""
11
+ print("=" * 60)
12
+ print("测试 _parse_time_window()")
13
+ print("=" * 60)
14
+ print()
15
+
16
+ from src.optimization.models.converters import _parse_time_window
17
+
18
+ # 测试 1: Dict 格式(完整)
19
+ print("测试 1: Dict 格式(完整時間視窗)")
20
+ tw_dict = {
21
+ "earliest_time": "2025-11-19T16:00:00",
22
+ "latest_time": "2025-11-19T18:00:00"
23
+ }
24
+ result = _parse_time_window(tw_dict)
25
+ print(f" input: {tw_dict}")
26
+ print(f" output: {result}")
27
+ assert result == (datetime(2025, 11, 19, 16, 0), datetime(2025, 11, 19, 18, 0))
28
+ print(" ✅ passed\n")
29
+
30
+ # 测试 2: Dict 格式(部分時間視窗 - 只有结束)
31
+ print("测试 2: Dict 格式(只有结束时间)")
32
+ tw_dict = {
33
+ "earliest_time": None,
34
+ "latest_time": "2025-11-19T15:00:00"
35
+ }
36
+ result = _parse_time_window(tw_dict)
37
+ print(f" input: {tw_dict}")
38
+ print(f" output: {result}")
39
+ assert result == (None, datetime(2025, 11, 19, 15, 0))
40
+ print(" ✅ passed\n")
41
+
42
+ # 测试 3: Dict 格式(部分時間視窗 - 只有开始)
43
+ print("测试 3: Dict 格式(只有开始时间)")
44
+ tw_dict = {
45
+ "earliest_time": "2025-11-19T10:00:00",
46
+ "latest_time": None
47
+ }
48
+ result = _parse_time_window(tw_dict)
49
+ print(f" input: {tw_dict}")
50
+ print(f" output: {result}")
51
+ assert result == (datetime(2025, 11, 19, 10, 0), None)
52
+ print(" ✅ passed\n")
53
+
54
+ # 测试 4: Dict 格式(全 None)
55
+ print("测试 4: Dict 格式(全 None)")
56
+ tw_dict = {
57
+ "earliest_time": None,
58
+ "latest_time": None
59
+ }
60
+ result = _parse_time_window(tw_dict)
61
+ print(f" input: {tw_dict}")
62
+ print(f" output: {result}")
63
+ assert result is None
64
+ print(" ✅ passed\n")
65
+
66
+ # 测试 5: None
67
+ print("测试 5: None input")
68
+ result = _parse_time_window(None)
69
+ print(f" input: None")
70
+ print(f" output: {result}")
71
+ assert result is None
72
+ print(" ✅ passed\n")
73
+
74
+ # 测试 6: Tuple(兼容)
75
+ print("测试 6: Tuple input(兼容旧代码)")
76
+ tw_tuple = (datetime(2025, 11, 19, 16, 0), datetime(2025, 11, 19, 18, 0))
77
+ result = _parse_time_window(tw_tuple)
78
+ print(f" input: {tw_tuple}")
79
+ print(f" output: {result}")
80
+ assert result == tw_tuple
81
+ print(" ✅ passed\n")
82
+
83
+
84
+ def test_convert_tasks():
85
+ """测试完整的任务转换"""
86
+ print("=" * 60)
87
+ print("测试 convert_tasks_to_internal()")
88
+ print("=" * 60)
89
+ print()
90
+
91
+ from converters import convert_tasks_to_internal
92
+
93
+ # 测试数据:模拟你的实际input
94
+ tasks = [
95
+ {
96
+ "task_id": "task_1",
97
+ "priority": "HIGH",
98
+ "service_duration_min": 60,
99
+ "time_window": {
100
+ "earliest_time": "2025-11-19T16:00:00",
101
+ "latest_time": "2025-11-19T18:00:00"
102
+ },
103
+ "candidates": [
104
+ {
105
+ "poi_id": "hospital_1",
106
+ "lat": 25.040978499999998,
107
+ "lng": 121.51919869999999
108
+ }
109
+ ]
110
+ },
111
+ {
112
+ "task_id": "task_2",
113
+ "priority": "MEDIUM",
114
+ "service_duration_min": 45,
115
+ "time_window": None,
116
+ "candidates": [
117
+ {
118
+ "poi_id": "supermarket_1",
119
+ "lat": 25.0376995,
120
+ "lng": 121.50622589999999
121
+ }
122
+ ]
123
+ },
124
+ {
125
+ "task_id": "task_3",
126
+ "priority": "HIGH",
127
+ "service_duration_min": 20,
128
+ "time_window": {
129
+ "earliest_time": None,
130
+ "latest_time": "2025-11-19T15:00:00"
131
+ },
132
+ "candidates": [
133
+ {
134
+ "poi_id": "post_office_1",
135
+ "lat": 25.0514598,
136
+ "lng": 121.5481353
137
+ }
138
+ ]
139
+ }
140
+ ]
141
+
142
+ # 转换
143
+ internal_tasks = convert_tasks_to_internal(tasks)
144
+
145
+ # 验证
146
+ print("测试 1: task_1 (看病 16:00-18:00)")
147
+ print(f" task_id: {internal_tasks[0].task_id}")
148
+ print(f" priority: {internal_tasks[0].priority}")
149
+ print(f" time_window: {internal_tasks[0].time_window}")
150
+ assert internal_tasks[0].time_window == (
151
+ datetime(2025, 11, 19, 16, 0),
152
+ datetime(2025, 11, 19, 18, 0)
153
+ )
154
+ print(" ✅ passed\n")
155
+
156
+ print("测试 2: task_2 (买菜 无限制)")
157
+ print(f" task_id: {internal_tasks[1].task_id}")
158
+ print(f" priority: {internal_tasks[1].priority}")
159
+ print(f" time_window: {internal_tasks[1].time_window}")
160
+ assert internal_tasks[1].time_window is None
161
+ print(" ✅ passed\n")
162
+
163
+ print("测试 3: task_3 (寄包裹 None-15:00)")
164
+ print(f" task_id: {internal_tasks[2].task_id}")
165
+ print(f" priority: {internal_tasks[2].priority}")
166
+ print(f" time_window: {internal_tasks[2].time_window}")
167
+ assert internal_tasks[2].time_window == (
168
+ None,
169
+ datetime(2025, 11, 19, 15, 0)
170
+ )
171
+ print(" ✅ passed\n")
172
+
173
+
174
+ def test_time_window_seconds():
175
+ """测试時間視窗转换为秒"""
176
+ print("=" * 60)
177
+ print("测试時間視窗秒数计算")
178
+ print("=" * 60)
179
+ print()
180
+
181
+ from converters import _parse_time_window
182
+ from datetime import timedelta
183
+
184
+ # 开始时间: 08:00
185
+ start_time = datetime(2025, 11, 19, 8, 0)
186
+
187
+ # task_1: 16:00-18:00
188
+ tw1 = _parse_time_window({
189
+ "earliest_time": "2025-11-19T16:00:00",
190
+ "latest_time": "2025-11-19T18:00:00"
191
+ })
192
+
193
+ start_sec = int((tw1[0] - start_time).total_seconds())
194
+ end_sec = int((tw1[1] - start_time).total_seconds())
195
+
196
+ print("task_1 時間視窗:")
197
+ print(f" 原始: 16:00-18:00")
198
+ print(f" 相对 08:00: {start_sec}秒 - {end_sec}秒")
199
+ print(f" 即: {start_sec / 3600:.1f}小时 - {end_sec / 3600:.1f}小时")
200
+
201
+ assert start_sec == 8 * 3600 # 8小时 = 28800秒
202
+ assert end_sec == 10 * 3600 # 10小时 = 36000秒
203
+ print(" ✅ passed\n")
204
+
205
+ # task_3: None - 15:00
206
+ tw3 = _parse_time_window({
207
+ "earliest_time": None,
208
+ "latest_time": "2025-11-19T15:00:00"
209
+ })
210
+
211
+ print("task_3 時間視窗:")
212
+ print(f" 原始: None - 15:00")
213
+ if tw3[0] is None:
214
+ print(f" start: 0秒 (无限制)")
215
+ if tw3[1] is not None:
216
+ end_sec = int((tw3[1] - start_time).total_seconds())
217
+ print(f" end: {end_sec}秒 = {end_sec / 3600:.1f}小时")
218
+ assert end_sec == 7 * 3600 # 7小时 = 25200秒
219
+ print(" ✅ passed\n")
220
+
221
+
222
+ def main():
223
+ """运行所有测试"""
224
+ print("\n")
225
+ print("╔" + "=" * 58 + "╗")
226
+ print("║" + " " * 15 + "時間視窗转换测试" + " " * 15 + "║")
227
+ print("╚" + "=" * 58 + "╝")
228
+ print("\n")
229
+
230
+ try:
231
+ test_parse_time_window()
232
+ test_convert_tasks()
233
+ test_time_window_seconds()
234
+
235
+ print("\n")
236
+ print("╔" + "=" * 58 + "╗")
237
+ print("║" + " " * 18 + "✅ 所有测试passed" + " " * 18 + "║")
238
+ print("╚" + "=" * 58 + "╝")
239
+ print("\n")
240
+
241
+ print("总结:")
242
+ print("✅ Dict 格式解析正確")
243
+ print("✅ 部分時間視窗支持")
244
+ print("✅ 完整任务转换正確")
245
+ print("✅ 时间秒数计算正確")
246
+ print("\n")
247
+
248
+ except Exception as e:
249
+ print(f"\n❌ 測試失敗: {e}")
250
+ import traceback
251
+ traceback.print_exc()
252
+
253
+
254
+ if __name__ == "__main__":
255
+ main()
src/optimization/test/_time_time_windows.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 完整测试 - 验证部分時間視窗支持
3
+
4
+ 测试所有真实场景
5
+ """
6
+ from datetime import datetime
7
+ import sys
8
+
9
+ # 导入修复后的模型
10
+ try:
11
+ from src.optimization.models.internal_models import _POICandidate, _Task
12
+
13
+ print("✅ 成功导入 final_internal_models\n")
14
+ except ImportError as e:
15
+ print(f"❌ 导入失败: {e}")
16
+ sys.exit(1)
17
+
18
+
19
+ def test_real_world_scenarios():
20
+ """测试真实业务场景"""
21
+
22
+ print("=" * 60)
23
+ print("真实业务场景测试")
24
+ print("=" * 60)
25
+ print()
26
+
27
+ dt_start = datetime(2025, 11, 18, 9, 0)
28
+ dt_end = datetime(2025, 11, 18, 15, 0)
29
+
30
+ # 场景 1: 24小时便利店(无时间限制)
31
+ print("場景 1: 24小時便利商店")
32
+ print(" 輸入: time_window = None")
33
+ poi1 = _POICandidate(
34
+ poi_id="7-11",
35
+ lat=25.0408,
36
+ lng=121.5318,
37
+ time_window=None
38
+ )
39
+ print(f" 結果: {poi1.time_window}")
40
+ print(" ✅ 通過\n")
41
+
42
+ # 场景 2: 只有营业结束时间的餐厅
43
+ print("場景 2: 餐廳(營業到晚上10點,沒有開始限制)")
44
+ print(" 結果: time_window = (None, 22:00)")
45
+ poi2 = _POICandidate(
46
+ poi_id="restaurant",
47
+ lat=25.0408,
48
+ lng=121.5318,
49
+ time_window=(None, datetime(2025, 11, 18, 22, 0))
50
+ )
51
+ print(f" 結果: {poi2.time_window}")
52
+ assert poi2.time_window == (None, datetime(2025, 11, 18, 22, 0))
53
+ print(" ✅ 通過 - 保留部分時間窗口\n")
54
+
55
+ # 场景 3: 只有营业开始时间的配送服务
56
+ print("場景 3: 配送服務(早上6點開始,沒有結束限制)")
57
+ print(" 輸入: time_window = (06:00, None)")
58
+ poi3 = _POICandidate(
59
+ poi_id="delivery",
60
+ lat=25.0408,
61
+ lng=121.5318,
62
+ time_window=(datetime(2025, 11, 18, 6, 0), None)
63
+ )
64
+ print(f" 結果: {poi3.time_window}")
65
+ assert poi3.time_window == (datetime(2025, 11, 18, 6, 0), None)
66
+ print(" ✅ 通過 - 保留部分時間窗口\n")
67
+
68
+ # 场景 4: 标准营业时间
69
+ print("場景 4: 標準營業時間(09:00-17:00)")
70
+ print(" 輸入: time_window = (09:00, 17:00)")
71
+ poi4 = _POICandidate(
72
+ poi_id="office",
73
+ lat=25.0408,
74
+ lng=121.5318,
75
+ time_window=(dt_start, datetime(2025, 11, 18, 17, 0))
76
+ )
77
+ print(f" 結果: {poi4.time_window}")
78
+ assert poi4.time_window == (dt_start, datetime(2025, 11, 18, 17, 0))
79
+ print(" ✅ 通過 - 完整時間窗口\n")
80
+
81
+ # 场景 5: (None, None) 应该转为 None)
82
+ print("場景 5: 完全無限制")
83
+ print(" 輸入: time_window = (None, None)")
84
+ poi5 = _POICandidate(
85
+ poi_id="flexible",
86
+ lat=25.0408,
87
+ lng=121.5318,
88
+ time_window=(None, None)
89
+ )
90
+ print(f" 結果: {poi5.time_window}")
91
+ assert poi5.time_window is None
92
+ print(" ✅ 通過 - 轉為 None\n")
93
+
94
+
95
+ def test_multiple_time_windows():
96
+ """测试多段時間視窗"""
97
+
98
+ print("=" * 60)
99
+ print("多段時間視窗測試")
100
+ print("=" * 60)
101
+ print()
102
+
103
+ dt1_start = datetime(2025, 11, 18, 9, 0)
104
+ dt1_end = datetime(2025, 11, 18, 12, 0)
105
+ dt2_start = datetime(2025, 11, 18, 14, 0)
106
+ dt2_end = datetime(2025, 11, 18, 17, 0)
107
+
108
+ # 场景 1: 午休的商店(上午 + 下午)
109
+ print("場景 1: 有午休的商店")
110
+ print(" 輸入: time_windows = [(09:00-12:00), (14:00-17:00)]")
111
+ poi = _POICandidate(
112
+ poi_id="shop_with_lunch_break",
113
+ lat=25.0408,
114
+ lng=121.5318,
115
+ time_windows=[
116
+ (dt1_start, dt1_end),
117
+ (dt2_start, dt2_end)
118
+ ]
119
+ )
120
+ print(f" 結果: {poi.time_windows}")
121
+ assert len(poi.time_windows) == 2
122
+ print(" ✅ 通過\n")
123
+
124
+ # 场景 2: 包含部分時間視窗
125
+ print("場景 2: 混合時間窗口")
126
+ print(" 輸入: time_windows = [(09:00-12:00), (None, 22:00)]")
127
+ poi = _POICandidate(
128
+ poi_id="mixed",
129
+ lat=25.0408,
130
+ lng=121.5318,
131
+ time_windows=[
132
+ (dt1_start, dt1_end),
133
+ (None, datetime(2025, 11, 18, 22, 0))
134
+ ]
135
+ )
136
+ print(f" 結果: {poi.time_windows}")
137
+ assert len(poi.time_windows) == 2
138
+ print(" ✅ 場景 2: 混合時間窗口\n")
139
+
140
+ # 场景 3: 過滤 (None, None)
141
+ print("場景 3: 包含無效視窗")
142
+ print(" 輸入: time_windows = [(09:00-12:00), (None, None)]")
143
+ poi = _POICandidate(
144
+ poi_id="with_invalid",
145
+ lat=25.0408,
146
+ lng=121.5318,
147
+ time_windows=[
148
+ (dt1_start, dt1_end),
149
+ (None, None)
150
+ ]
151
+ )
152
+ print(f" 結果: {poi.time_windows}")
153
+ assert len(poi.time_windows) == 1
154
+ assert poi.time_windows[0] == (dt1_start, dt1_end)
155
+ print(" ✅ 通過 - 過濾掉 (None, None)\n")
156
+
157
+
158
+ def test_validation_logic():
159
+ """测试验证逻辑"""
160
+
161
+ print("=" * 60)
162
+ print("驗證邏輯測試")
163
+ print("=" * 60)
164
+ print()
165
+
166
+ # 测试 1: start > end 应该失败
167
+ print("测试 1: start > end (应该报错)")
168
+ try:
169
+ poi = _POICandidate(
170
+ poi_id="invalid",
171
+ lat=25.0408,
172
+ lng=121.5318,
173
+ time_window=(
174
+ datetime(2025, 11, 18, 17, 0),
175
+ datetime(2025, 11, 18, 9, 0)
176
+ )
177
+ )
178
+ print(" ❌ 沒有報錯(應該要報錯)\n")
179
+ except ValueError as e:
180
+ print(f" 結果: {e}")
181
+ print(" ✅ 正確報錯\n")
182
+
183
+ # 测试 2: start = end 应该失败
184
+ print("測試 2: start = end (應該報錯)")
185
+ try:
186
+ poi = _POICandidate(
187
+ poi_id="invalid",
188
+ lat=25.0408,
189
+ lng=121.5318,
190
+ time_window=(
191
+ datetime(2025, 11, 18, 9, 0),
192
+ datetime(2025, 11, 18, 9, 0)
193
+ )
194
+ )
195
+ print(" ❌ 沒有報錯(應該報錯)\n")
196
+ except ValueError as e:
197
+ print(f" 結果: {e}")
198
+ print(" ✅ 正確報錯\n")
199
+
200
+ # 测试 3: 部分時間視窗不需要验证
201
+ print("測試 3: 部分時間視窗 (None, datetime) - 無需驗證")
202
+ poi = _POICandidate(
203
+ poi_id="partial",
204
+ lat=25.0408,
205
+ lng=121.5318,
206
+ time_window=(None, datetime(2025, 11, 18, 22, 0))
207
+ )
208
+ print(f" 結果: {poi.time_window}")
209
+ print(" ✅ 通過 \n")
210
+
211
+
212
+ def test_task_time_window():
213
+ """测试 Task 的時間視窗"""
214
+
215
+ print("=" * 60)
216
+ print("Task 時間窗口測試")
217
+ print("=" * 60)
218
+ print()
219
+
220
+ dt_start = datetime(2025, 11, 18, 9, 0)
221
+ dt_end = datetime(2025, 11, 18, 15, 0)
222
+
223
+ # 测试 1: 完整時間視窗
224
+ print("測試 1: Task 完整時間窗口")
225
+ task = _Task(
226
+ task_id="task_1",
227
+ priority="HIGH",
228
+ time_window=(dt_start, dt_end),
229
+ service_duration_min=45,
230
+ candidates=[
231
+ _POICandidate(
232
+ poi_id="poi_1",
233
+ lat=25.0408,
234
+ lng=121.5318
235
+ )
236
+ ]
237
+ )
238
+ print(f" 結果: {task.time_window}")
239
+ assert task.time_window == (dt_start, dt_end)
240
+ print(" ✅ 通過\n")
241
+
242
+ # 测试 2: 部分時間視窗
243
+ print("测试 2: Task 部分時間窗口")
244
+ task = _Task(
245
+ task_id="task_2",
246
+ priority="MEDIUM",
247
+ time_window=(None, dt_end),
248
+ service_duration_min=30,
249
+ candidates=[
250
+ _POICandidate(
251
+ poi_id="poi_2",
252
+ lat=25.0408,
253
+ lng=121.5318
254
+ )
255
+ ]
256
+ )
257
+ print(f" 結果: {task.time_window}")
258
+ assert task.time_window == (None, dt_end)
259
+ print(" ✅ 通過\n")
260
+
261
+
262
+ def main():
263
+ print("\n")
264
+ print("╔" + "=" * 58 + "╗")
265
+ print("║" + " " * 15 + "時間窗口完整測試" + " " * 15 + "║")
266
+ print("╚" + "=" * 58 + "╝")
267
+ print("\n")
268
+
269
+ try:
270
+ test_real_world_scenarios()
271
+ test_multiple_time_windows()
272
+ test_validation_logic()
273
+ test_task_time_window()
274
+
275
+ print("\n")
276
+ print("╔" + "=" * 58 + "╗")
277
+ print("║" + " " * 18 + "✅ 所有测试通過" + " " * 18 + "║")
278
+ print("╚" + "=" * 58 + "╝")
279
+ print("\n")
280
+
281
+ print("总结:")
282
+ print("✅ 支持完整時間視窗: (datetime, datetime)")
283
+ print("✅ 支持部分時間視窗: (None, datetime) 或 (datetime, None)")
284
+ print("✅ 支持無限制: None 或 (None, None)")
285
+ print("✅ 正确過滤無效窗口")
286
+ print("\n")
287
+
288
+ except Exception as e:
289
+ print(f"\n❌ 測試失敗: {e}")
290
+ import traceback
291
+ traceback.print_exc()
292
+ sys.exit(1)
293
+
294
+
295
+ if __name__ == "__main__":
296
+ main()
src/optimization/tsptw_solver.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ from typing import List, Dict, Any, Optional
4
+ from datetime import datetime
5
+
6
+ from src.infra.logger import get_logger
7
+ from src.services.googlemap_api_service import GoogleMapAPIService
8
+
9
+ from src.optimization.models import (
10
+ convert_tasks_to_internal,
11
+ convert_location_to_internal,
12
+ convert_result_to_dict,
13
+ )
14
+ from src.optimization.graph import GraphBuilder
15
+ from src.optimization.solver import ORToolsSolver, SolutionExtractor
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class TSPTWSolver:
21
+ """
22
+ TSPTW (Traveling Salesman Problem with Time Windows) 求解器
23
+
24
+ ✅ 完全保留原始功能:
25
+ - 外部 API 使用 Dict (向後兼容)
26
+ - 內部使用 Pydantic (類型檢查 + 驗證)
27
+ - 時間單位使用分鐘(service_duration_min)
28
+ - 時間窗同時支援 Task-level & POI-level (含多段 time_windows)
29
+ - 備選 POI 會考慮 time window 且不再推薦同一個 poi_id
30
+
31
+ ✨ 重構改進:
32
+ - 模塊化架構(易於測試和維護)
33
+ - 清晰的職責分離
34
+ - 保持對外 API 完全不變
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ api_key: Optional[str] = None,
40
+ time_limit_seconds: Optional[int] = None,
41
+ verbose: bool = False,
42
+ ):
43
+ """
44
+ 初始化求解器
45
+
46
+ Args:
47
+ api_key: Google Maps API Key
48
+ time_limit_seconds: 求解時間限制(秒)
49
+ verbose: 是否顯示詳細日誌
50
+ """
51
+ env_limit = (
52
+ os.getenv("SOLVER_TIME_LIMIT")
53
+ or os.getenv("solver_time_limit")
54
+ or "1"
55
+ )
56
+
57
+ self.gmaps = GoogleMapAPIService(api_key=api_key)
58
+ self.time_limit_seconds = (
59
+ time_limit_seconds if time_limit_seconds is not None else int(env_limit)
60
+ )
61
+ self.verbose = verbose
62
+
63
+ # 初始化各模塊
64
+ self.graph_builder = GraphBuilder(gmaps=self.gmaps)
65
+ self.ortools_solver = ORToolsSolver(
66
+ time_limit_seconds=self.time_limit_seconds,
67
+ verbose=verbose,
68
+ )
69
+ self.solution_extractor = SolutionExtractor()
70
+
71
+ # ------------------------------------------------------------------ #
72
+ # Public API - 完全保留原始接口 #
73
+ # ------------------------------------------------------------------ #
74
+ def solve(
75
+ self,
76
+ start_location: Dict[str, float],
77
+ start_time: datetime,
78
+ deadline: datetime,
79
+ tasks: List[Dict[str, Any]] = None,
80
+ travel_mode="DRIVE",
81
+ max_wait_time_min: int = 30,
82
+ alt_k: int = 3,
83
+ return_to_start: bool = True,
84
+ ) -> Dict[str, Any]:
85
+
86
+ """
87
+ 求解 TSPTW
88
+
89
+ ✅ 完全保留原始 API 和功能
90
+
91
+ Args:
92
+ tasks: 任務列表,每個任務格式:
93
+ {
94
+ "task_id": str,
95
+ "priority": "HIGH" | "MEDIUM" | "LOW",
96
+ "time_window": (datetime, datetime) | None,
97
+ "service_duration_min": int,
98
+ "candidates": [
99
+ {
100
+ "poi_id": str,
101
+ "lat": float,
102
+ "lng": float,
103
+ "time_window": (datetime, datetime) | None,
104
+ "time_windows": [(datetime, datetime), ...] | None
105
+ }
106
+ ]
107
+ }
108
+ start_location: {"lat": float, "lng": float}
109
+ start_time: 開始時間
110
+ deadline: 截止時間
111
+ max_wait_time_min: 最大等待時間(分鐘)
112
+ travel_mode: 矩陣計算的交通模式
113
+ alt_k: 回傳 Top-K 備選 POI
114
+ return_to_start: 是否回到出發點
115
+
116
+ Returns: Dict(由 _TSPTWResult 轉出)
117
+ {
118
+ "status": "OK" | "NO_SOLUTION" | "NO_TASKS",
119
+ "total_travel_time_min": int,
120
+ "total_travel_distance_m": int,
121
+ "route": [...],
122
+ "visited_tasks": [...],
123
+ "skipped_tasks": [...],
124
+ "tasks_detail": [...]
125
+ }
126
+ """
127
+ logger.info("TSPTWSolver.solve() start, tasks=%d", len(tasks))
128
+
129
+ # 1. 驗證和轉換輸入
130
+ try:
131
+ internal_tasks = convert_tasks_to_internal(tasks)
132
+ internal_start_location = convert_location_to_internal(start_location)
133
+ except Exception as e:
134
+ logger.error(f"Failed to validate input: {e}")
135
+ return {
136
+ "status": "INVALID_INPUT",
137
+ "error": str(e),
138
+ "total_travel_time_min": 0,
139
+ "total_travel_distance_m": 0,
140
+ "route": [],
141
+ "visited_tasks": [],
142
+ "skipped_tasks": [t.get("task_id", "") for t in tasks],
143
+ "tasks_detail": [],
144
+ }
145
+
146
+ # 2. ��建圖
147
+ graph = self.graph_builder.build_graph(
148
+ start_location=internal_start_location,
149
+ tasks=internal_tasks,
150
+ travel_mode=travel_mode,
151
+ )
152
+
153
+ num_nodes = len(graph.node_meta)
154
+ if num_nodes <= 1:
155
+ logger.warning("No POIs to visit, only depot.")
156
+ return {
157
+ "status": "NO_TASKS",
158
+ "total_travel_time_min": 0,
159
+ "total_travel_distance_m": 0,
160
+ "route": [],
161
+ "visited_tasks": [],
162
+ "skipped_tasks": [t.task_id for t in internal_tasks],
163
+ "tasks_detail": [],
164
+ }
165
+
166
+ # 3. 求解
167
+ max_wait_time_sec = max_wait_time_min * 60
168
+
169
+ try:
170
+ routing, manager, solution = self.ortools_solver.solve(
171
+ graph=graph,
172
+ tasks=internal_tasks,
173
+ start_time=start_time,
174
+ deadline=deadline,
175
+ max_wait_time_sec=max_wait_time_sec,
176
+ )
177
+ except Exception as e:
178
+ logger.error(f"OR-Tools solver failed: {e}")
179
+ return {
180
+ "status": "SOLVER_ERROR",
181
+ "error": str(e),
182
+ "total_travel_time_min": 0,
183
+ "total_travel_distance_m": 0,
184
+ "route": [],
185
+ "visited_tasks": [],
186
+ "skipped_tasks": [t.task_id for t in internal_tasks],
187
+ "tasks_detail": [],
188
+ }
189
+
190
+ # 4. 檢查是否有解
191
+ if solution is None:
192
+ logger.warning("No solution found")
193
+ return {
194
+ "status": "NO_SOLUTION",
195
+ "total_travel_time_min": 0,
196
+ "total_travel_distance_m": 0,
197
+ "route": [],
198
+ "visited_tasks": [],
199
+ "skipped_tasks": [t.task_id for t in internal_tasks],
200
+ "tasks_detail": [],
201
+ }
202
+
203
+ # 5. 提取結果
204
+ time_dimension = routing.GetDimensionOrDie("Time")
205
+
206
+ result = self.solution_extractor.extract(
207
+ routing=routing,
208
+ manager=manager,
209
+ solution=solution,
210
+ time_dimension=time_dimension,
211
+ start_time=start_time,
212
+ graph=graph,
213
+ tasks=internal_tasks,
214
+ alt_k=alt_k,
215
+ return_to_start=return_to_start,
216
+ )
217
+
218
+ logger.info("TSPTWSolver.solve() done, status=%s", result.status)
219
+
220
+ # 6. 轉換為外部 Dict
221
+ return convert_result_to_dict(result)
222
+
223
+ def test_time_window_handler():
224
+ from datetime import datetime, timezone, timedelta
225
+ from src.optimization.graph.time_window_handler import TimeWindowHandler
226
+
227
+ handler = TimeWindowHandler()
228
+ tz = timezone(timedelta(hours=8)) # UTC+8
229
+ start_time = datetime(2025, 11, 22, 10, 0, 0, tzinfo=tz)
230
+ horizon_sec = 8 * 3600 # 8 hours
231
+
232
+ print("=== Test Case 1: 都沒有時間窗口 ===")
233
+ start, end = handler.compute_effective_time_window(None, None, start_time, horizon_sec)
234
+ assert start == 0 and end == horizon_sec
235
+ print(f"✅ Pass: [{start}, {end}]")
236
+
237
+ print("\n=== Test Case 2: Dict 格式 - 只有 task 有時間窗口 ===")
238
+ task_tw = {
239
+ 'earliest_time': datetime(2025, 11, 22, 11, 0, 0, tzinfo=tz),
240
+ 'latest_time': datetime(2025, 11, 22, 15, 0, 0, tzinfo=tz)
241
+ }
242
+ start, end = handler.compute_effective_time_window(task_tw, None, start_time, horizon_sec)
243
+ assert start == 3600 # 1 hour after start
244
+ assert end == 18000 # 5 hours after start
245
+ print(f"✅ Pass: [{start}, {end}]")
246
+
247
+ print("\n=== Test Case 3: Tuple 格式 - 只有 POI 有時間窗口 ===")
248
+ poi_tw = (
249
+ datetime(2025, 11, 22, 9, 0, 0, tzinfo=tz), # 開放時間
250
+ datetime(2025, 11, 22, 17, 0, 0, tzinfo=tz) # 關門時間
251
+ )
252
+ start, end = handler.compute_effective_time_window(None, poi_tw, start_time, horizon_sec)
253
+ assert start == 0 # POI 已經開門
254
+ assert end == 25200 # 7 hours after start
255
+ print(f"✅ Pass: [{start}, {end}]")
256
+
257
+ print("\n=== Test Case 4: 字符串格式 ===")
258
+ task_tw_str = {
259
+ 'earliest_time': '2025-11-22T11:00:00+08:00',
260
+ 'latest_time': '2025-11-22T15:00:00+08:00'
261
+ }
262
+ start, end = handler.compute_effective_time_window(task_tw_str, None, start_time, horizon_sec)
263
+ assert start == 3600
264
+ assert end == 18000
265
+ print(f"✅ Pass: [{start}, {end}]")
266
+
267
+ print("\n=== Test Case 5: 部分時間窗口 (只有 earliest) ===")
268
+ partial_tw = {
269
+ 'earliest_time': datetime(2025, 11, 22, 12, 0, 0, tzinfo=tz),
270
+ 'latest_time': None
271
+ }
272
+ start, end = handler.compute_effective_time_window(partial_tw, None, start_time, horizon_sec)
273
+ assert start == 7200 # 2 hours after start
274
+ assert end == horizon_sec
275
+ print(f"✅ Pass: [{start}, {end}]")
276
+
277
+ print("\n=== Test Case 6: 部分時間窗口 (只有 latest) ===")
278
+ partial_tw = {
279
+ 'earliest_time': None,
280
+ 'latest_time': datetime(2025, 11, 22, 16, 0, 0, tzinfo=tz)
281
+ }
282
+ start, end = handler.compute_effective_time_window(partial_tw, None, start_time, horizon_sec)
283
+ assert start == 0
284
+ assert end == 21600 # 6 hours after start
285
+ print(f"✅ Pass: [{start}, {end}]")
286
+
287
+ print("\n=== Test Case 7: 實際場景 - Scout 返回的 POI time_window = None ===")
288
+ poi_data = {
289
+ 'place_id': 'ChIJ...',
290
+ 'name': 'Rainbow Village',
291
+ 'time_window': None # 你的實際情況
292
+ }
293
+ start, end = handler.compute_effective_time_window(task_tw, poi_data.get('time_window'), start_time, horizon_sec)
294
+ assert start == 3600 and end == 18000
295
+ print(f"✅ Pass: [{start}, {end}]")
296
+
297
+ print("\n🎉 All tests passed!")
298
+
299
+
300
+ if __name__ == "__main__":
301
+ test_time_window_handler()
src/services/googlemap_api_service.py CHANGED
@@ -6,8 +6,7 @@ import requests
6
  from typing import List, Dict, Any, Optional, Tuple, Union
7
  from datetime import datetime, timedelta, timezone
8
  import numpy as np
9
- import polyline
10
- from agno.run import RunContext
11
 
12
  from src.infra.logger import get_logger
13
 
@@ -126,7 +125,7 @@ class GoogleMapAPIService:
126
  self,
127
  query: str,
128
  location: Optional[Dict[str, float]] = None,
129
- radius: Optional[int] = None,
130
  limit: int = 3,
131
  ) -> List[Dict[str, Any]]:
132
  """
@@ -180,7 +179,7 @@ class GoogleMapAPIService:
180
  }
181
 
182
  try:
183
- logger.debug(f"🔍 Searching places: '{query}' (limit={limit})")
184
 
185
  response = requests.post(
186
  self.places_text_search_url,
@@ -188,7 +187,10 @@ class GoogleMapAPIService:
188
  headers=headers,
189
  timeout=10
190
  )
 
 
191
  response.raise_for_status()
 
192
  data = response.json()
193
 
194
  places = data.get("places", [])
@@ -272,17 +274,21 @@ class GoogleMapAPIService:
272
  request_body = {
273
  "origin": {
274
  "location": {
 
 
275
  "latLng": {
276
  "latitude": origin["lat"],
277
- "longitude": origin["lng"] # ✅ 修復: 原來是 origin["lat"]
278
  }
279
  }
280
  },
281
  "destination": {
282
  "location": {
 
 
283
  "latLng": {
284
  "latitude": destination["lat"],
285
- "longitude": destination["lng"] # ✅ 修復: 原來是 destination["lat"]
286
  }
287
  }
288
  },
@@ -341,7 +347,7 @@ class GoogleMapAPIService:
341
 
342
  logger.debug(
343
  f"🗺️ Computing route: {origin['lat']:.4f},{origin['lng']:.4f} → "
344
- f"{destination['lat']:.4f},{destination['lng']:.4f} "
345
  f"({len(waypoints) if waypoints else 0} waypoints)"
346
  )
347
 
@@ -357,28 +363,42 @@ class GoogleMapAPIService:
357
  response.raise_for_status()
358
  data = response.json()
359
 
360
- # P0-3 修復: 完整解析 API 響應
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  if "routes" not in data or not data["routes"]:
362
  logger.error("❌ No routes found in API response")
363
  raise ValueError("No routes found")
364
 
365
- route = data["routes"][0] # 取第一條路線
366
 
367
- # ✅ 解析距離
368
  distance_meters = route.get("distanceMeters", 0)
369
 
370
- # ✅ 解析時間 (格式: "300s")
371
  duration_str = route.get("duration", "0s")
372
  duration_seconds = int(duration_str.rstrip("s"))
373
 
374
- # ✅ 解析 polyline
375
  polyline_data = route.get("polyline", {})
376
  encoded_polyline = polyline_data.get("encodedPolyline", "")
377
 
378
- # ✅ 解析 legs
379
  legs = route.get("legs", [])
380
 
381
- # ✅ 解析優化後的順序 (如果有)
382
  optimized_order = route.get("optimizedIntermediateWaypointIndex")
383
 
384
  result = {
@@ -409,33 +429,31 @@ class GoogleMapAPIService:
409
  raise
410
 
411
  # ========================================================================
412
- # Polyline Helper
413
  # ========================================================================
414
 
415
- def _merge_polylines(self, poly_lines: List[str]) -> str:
416
- """
417
- Merge multiple encoded polylines into a single polyline
418
-
419
- Args:
420
- poly_lines: List of encoded polyline strings
421
-
422
- Returns:
423
- Merged encoded polyline string
424
- """
425
- if not poly_lines:
426
- return ""
427
-
428
- all_points = []
429
- for pl in poly_lines:
430
- if pl: # ✅ 改進: 跳過空字符串
431
- points = polyline.decode(pl)
432
- all_points.extend(points)
433
-
434
- return polyline.encode(all_points) if all_points else ""
435
 
436
- # ========================================================================
437
- # Multi-leg Route Computation
438
- # ========================================================================
 
 
 
 
 
439
 
440
  def compute_routes(
441
  self,
@@ -445,42 +463,11 @@ class GoogleMapAPIService:
445
  travel_mode: Union[str, List[str]] = "DRIVE",
446
  routing_preference: str = "TRAFFIC_AWARE_OPTIMAL"
447
  ) -> Dict[str, Any]:
448
- """
449
- Compute a detailed multi-stop route. Supports MIXED travel modes.
450
-
451
- Args:
452
- place_points: Ordered list of locations. Min 2 points.
453
- start_time: "now" or datetime object.
454
- stop_times: List of stay durations (minutes).
455
- CRITICAL: Length must match len(place_points).
456
- e.g., [0, 60, 30, 0] means:
457
- - Start at P0 (stay 0)
458
- - Arrive P1 (stay 60) -> Depart P1
459
- - Arrive P2 (stay 30) -> Depart P2
460
- - Arrive P3 (End)
461
- travel_mode: Single string or List of strings.
462
- routing_preference: "TRAFFIC_AWARE_OPTIMAL" (ignored for non-DRIVE modes).
463
-
464
- Returns:
465
- Dict: Route summary.
466
- """
467
 
468
  if len(place_points) < 2:
469
  raise ValueError("At least 2 places required for route computation")
470
 
471
- # 強制驗證:stop_times 長度必須與地點數量一致,避免邏輯混亂
472
- if len(stop_times) != len(place_points):
473
- raise ValueError(
474
- f"stop_times length ({len(stop_times)}) must match "
475
- f"place_points length ({len(place_points)})"
476
- )
477
-
478
- num_legs = len(place_points) - 1
479
-
480
- # 驗證 travel_mode 列表長度
481
- if isinstance(travel_mode, list):
482
- if len(travel_mode) != num_legs:
483
- raise ValueError(f"travel_mode list length ({len(travel_mode)}) must match legs count ({num_legs})")
484
 
485
  # 處理開始時間
486
  if start_time == "now" or start_time is None:
@@ -496,68 +483,125 @@ class GoogleMapAPIService:
496
  total_duration = 0
497
  place_points = clean_dict_list_keys(place_points)
498
 
499
- logger.debug(f"🚦 Computing route. Points: {len(place_points)}, Modes: {travel_mode}")
500
 
501
  for i in range(num_legs):
502
  origin = place_points[i]
503
  destination = place_points[i + 1]
504
 
505
- # 1. 決定當前模式
506
  if isinstance(travel_mode, list):
507
- current_mode = travel_mode[i].upper()
508
  else:
509
- current_mode = travel_mode.upper()
 
 
510
 
511
- # 2. 決定 Traffic Preference
512
- if current_mode in ["DRIVE", "TWO_WHEELER"]:
513
- current_preference = None if routing_preference == "UNDRIVE" else routing_preference.upper()
 
 
 
 
514
  else:
515
- current_preference = None
516
 
517
- departure_time = current_time if i > 0 else None
 
518
 
519
- try:
520
- route = self._compute_routes(
521
- origin=origin,
522
- destination=destination,
523
- waypoints=None,
524
- travel_mode=current_mode,
525
- routing_preference=current_preference,
526
- include_traffic_on_polyline=True,
527
- departure_time=departure_time
528
- )
529
 
530
- leg_distance = route["distance_meters"]
531
- leg_duration = route["duration_seconds"]
 
 
 
 
 
 
532
 
533
- total_distance += leg_distance
534
- total_duration += leg_duration
535
 
536
- legs_info.append({
537
- "from_index": i,
538
- "to_index": i + 1,
539
- "travel_mode": current_mode,
540
- "distance_meters": leg_distance,
541
- "duration_seconds": leg_duration,
542
- "departure_time": current_time.isoformat(),
543
- "polyline": route["encoded_polyline"]
544
- })
 
 
 
545
 
546
- current_time += timedelta(seconds=leg_duration)
 
 
547
 
548
- stop_duration = stop_times[i + 1]
 
549
 
550
- current_time += timedelta(minutes=stop_duration)
 
 
551
 
552
- except Exception as e:
553
- logger.error(f"❌ Leg {i + 1} computation failed: {e}")
554
- raise e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
555
 
556
  return {
557
  "total_distance_meters": total_distance,
558
- "total_duration_seconds": total_duration, # 純交通時間
559
- "total_residence_time_minutes": sum(stop_times), # 總停留時間
560
- "total_time_seconds": int((current_time - start_time).total_seconds()), # 總行程時間 (含交通+停留)
561
  "start_time": start_time,
562
  "end_time": current_time,
563
  "stops": place_points,
@@ -754,6 +798,7 @@ class GoogleMapAPIService:
754
  dest_batch = destinations[d_start:d_end]
755
 
756
  try:
 
757
  elements = self._compute_route_matrix(
758
  origins=origin_batch,
759
  destinations=dest_batch,
@@ -770,6 +815,9 @@ class GoogleMapAPIService:
770
  continue
771
 
772
  for el in elements:
 
 
 
773
  oi = o_start + el["originIndex"]
774
  di = d_start + el["destinationIndex"]
775
 
@@ -784,11 +832,13 @@ class GoogleMapAPIService:
784
  distance_matrix[oi, di] = distance_meters
785
 
786
  except Exception as e:
 
 
 
 
787
  logger.error(
788
  f"❌ Failed to compute matrix batch "
789
- f"[{o_start}:{o_end}, {d_start}:{d_end}]: {e}"
790
- )
791
- raise e
792
 
793
  logger.info(
794
  f"✅ Route matrix computed: "
@@ -800,7 +850,6 @@ class GoogleMapAPIService:
800
  "distance_matrix": distance_matrix,
801
  }
802
 
803
-
804
  # ============================================================================
805
  # Usage Example
806
  # ============================================================================
@@ -858,7 +907,7 @@ if __name__ == "__main__":
858
  route = service.compute_routes(
859
  place_points=places,
860
  start_time=datetime.now(timezone.utc),
861
- stop_times=[30], # 停留時間 (分鐘)
862
  travel_mode="DRIVE",
863
  routing_preference="TRAFFIC_AWARE" ##"TRAFFIC_AWARE_OPTIMAL"
864
  )
 
6
  from typing import List, Dict, Any, Optional, Tuple, Union
7
  from datetime import datetime, timedelta, timezone
8
  import numpy as np
9
+ import time
 
10
 
11
  from src.infra.logger import get_logger
12
 
 
125
  self,
126
  query: str,
127
  location: Optional[Dict[str, float]] = None,
128
+ radius: Optional[int] = 10000,
129
  limit: int = 3,
130
  ) -> List[Dict[str, Any]]:
131
  """
 
179
  }
180
 
181
  try:
182
+ logger.debug(f"🔍 Searching places: '{query}', cent_loca:'{location}'(limit={limit})")
183
 
184
  response = requests.post(
185
  self.places_text_search_url,
 
187
  headers=headers,
188
  timeout=10
189
  )
190
+ if not response.ok:
191
+ print(f"❌ Places Text Search API error: {response.text}")
192
  response.raise_for_status()
193
+
194
  data = response.json()
195
 
196
  places = data.get("places", [])
 
274
  request_body = {
275
  "origin": {
276
  "location": {
277
+ "placeId": origin["place_id"]
278
+ } if "place_id" in origin else {
279
  "latLng": {
280
  "latitude": origin["lat"],
281
+ "longitude": origin["lng"]
282
  }
283
  }
284
  },
285
  "destination": {
286
  "location": {
287
+ "placeId": destination["place_id"]
288
+ } if "place_id" in destination else {
289
  "latLng": {
290
  "latitude": destination["lat"],
291
+ "longitude": destination["lng"]
292
  }
293
  }
294
  },
 
347
 
348
  logger.debug(
349
  f"🗺️ Computing route: {origin['lat']:.4f},{origin['lng']:.4f} → "
350
+ f"{destination['lat']:.4f},{destination['lng']:.4f}, {request_body['travelMode']} "
351
  f"({len(waypoints) if waypoints else 0} waypoints)"
352
  )
353
 
 
363
  response.raise_for_status()
364
  data = response.json()
365
 
366
+ if hasattr(response, 'json'):
367
+ try:
368
+ data = response.json() # 📦 拆開包裹,取得字典資料
369
+ except ValueError:
370
+ print("❌ API 回傳的內容不是有效的 JSON")
371
+ data = {}
372
+ # 如果它已經是 list 或 dict (例如某些 client library 會自動轉),就直接用
373
+ elif isinstance(response, (dict, list)):
374
+ data = response
375
+ else:
376
+ # 防呆:未知的格式
377
+ data = {}
378
+
379
+ # [轉接] 確保格式符合 Parser 需求 (Google Maps API 通常回傳 dict,但也可能包在 list 裡)
380
+ if isinstance(data, list):
381
+ formatted_response = {"routes": data}
382
+ else:
383
+ formatted_response = data
384
+
385
+
386
  if "routes" not in data or not data["routes"]:
387
  logger.error("❌ No routes found in API response")
388
  raise ValueError("No routes found")
389
 
390
+ route = data["routes"][0]
391
 
 
392
  distance_meters = route.get("distanceMeters", 0)
393
 
 
394
  duration_str = route.get("duration", "0s")
395
  duration_seconds = int(duration_str.rstrip("s"))
396
 
 
397
  polyline_data = route.get("polyline", {})
398
  encoded_polyline = polyline_data.get("encodedPolyline", "")
399
 
 
400
  legs = route.get("legs", [])
401
 
 
402
  optimized_order = route.get("optimizedIntermediateWaypointIndex")
403
 
404
  result = {
 
429
  raise
430
 
431
  # ========================================================================
432
+ # Multi-leg Route Computation
433
  # ========================================================================
434
 
435
+ @staticmethod
436
+ def _encode_polyline(points: List[Tuple[float, float]]) -> str:
437
+ def encode_coord(coord):
438
+ coord = int(round(coord * 1e5))
439
+ coord <<= 1
440
+ if coord < 0:
441
+ coord = ~coord
442
+ result = ""
443
+ while coord >= 0x20:
444
+ result += chr((0x20 | (coord & 0x1f)) + 63)
445
+ coord >>= 5
446
+ result += chr(coord + 63)
447
+ return result
 
 
 
 
 
 
 
448
 
449
+ encoded = ""
450
+ last_lat, last_lng = 0, 0
451
+ for lat, lng in points:
452
+ d_lat = lat - last_lat
453
+ d_lng = lng - last_lng
454
+ encoded += encode_coord(d_lat) + encode_coord(d_lng)
455
+ last_lat, last_lng = lat, lng
456
+ return encoded
457
 
458
  def compute_routes(
459
  self,
 
463
  travel_mode: Union[str, List[str]] = "DRIVE",
464
  routing_preference: str = "TRAFFIC_AWARE_OPTIMAL"
465
  ) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
 
467
  if len(place_points) < 2:
468
  raise ValueError("At least 2 places required for route computation")
469
 
470
+ # ... (驗證 stop_times 長度與時間初始化代碼保持不變) ...
 
 
 
 
 
 
 
 
 
 
 
 
471
 
472
  # 處理開始時間
473
  if start_time == "now" or start_time is None:
 
483
  total_duration = 0
484
  place_points = clean_dict_list_keys(place_points)
485
 
486
+ num_legs = len(place_points) - 1
487
 
488
  for i in range(num_legs):
489
  origin = place_points[i]
490
  destination = place_points[i + 1]
491
 
492
+ # 1. 決定當前首選模式
493
  if isinstance(travel_mode, list):
494
+ primary_mode = travel_mode[i].upper()
495
  else:
496
+ primary_mode = travel_mode.upper()
497
+
498
+ modes_to_try = []
499
 
500
+ if primary_mode == "DRIVE":
501
+ # 開車失敗 -> 試試機車 -> 再不行試試公車 (適合跨海/長途) -> 最後才走路
502
+ modes_to_try = ["DRIVE", "TWO_WHEELER", "TRANSIT", "WALK"]
503
+ elif primary_mode == "TWO_WHEELER":
504
+ modes_to_try = ["TWO_WHEELER", "TRANSIT", "WALK"]
505
+ elif primary_mode == "TRANSIT":
506
+ modes_to_try = ["TRANSIT", "WALK"]
507
  else:
508
+ modes_to_try = ["WALK"]
509
 
510
+ route_found = False
511
+ final_mode_used = primary_mode
512
 
513
+ leg_distance = 0
514
+ leg_duration = 0
515
+ encoded_polyline = ""
 
 
 
 
 
 
 
516
 
517
+ # 3. 開始嘗試
518
+ for mode in modes_to_try:
519
+ # 設定 Preference
520
+ # ✅ TRANSIT 不支援 traffic_aware,必須設為 None
521
+ if mode in ["DRIVE", "TWO_WHEELER"]:
522
+ pref = None if routing_preference == "UNDRIVE" else routing_preference.upper()
523
+ else:
524
+ pref = None
525
 
526
+ departure_time = current_time if i > 0 else None
 
527
 
528
+ try:
529
+ # 呼叫 API
530
+ route = self._compute_routes(
531
+ origin=origin,
532
+ destination=destination,
533
+ waypoints=None,
534
+ travel_mode=mode,
535
+ routing_preference=pref,
536
+ include_traffic_on_polyline=True,
537
+ # ✅ TRANSIT 模式非常依賴 departure_time,務必確保有傳入
538
+ departure_time=departure_time
539
+ )
540
 
541
+ leg_distance = route["distance_meters"]
542
+ leg_duration = route["duration_seconds"]
543
+ encoded_polyline = route["encoded_polyline"]
544
 
545
+ route_found = True
546
+ final_mode_used = mode
547
 
548
+ if mode != primary_mode:
549
+ logger.info(f" 🔄 Leg {i + 1}: {primary_mode} failed. Switched to {mode} (Polyline OK).")
550
+ break
551
 
552
+ except Exception:
553
+ continue
554
+
555
+ # 4. 絕望情況:連走路都失敗 (Math Fallback)
556
+ if not route_found:
557
+ logger.warning(f"⚠️ Leg {i + 1}: All API modes (DRIVE/TWO_WHEELER/TRANSIT/WALK) failed.")
558
+ logger.warning(
559
+ f" 📍 From: {origin['lat']},{origin['lng']} -> To: {destination['lat']},{destination['lng']}")
560
+ logger.warning(f" 🔄 Activating Math Fallback (Straight Line).")
561
+
562
+ # 計算直線距離
563
+ leg_distance = self._calculate_haversine_distance(
564
+ origin['lat'], origin['lng'],
565
+ destination['lat'], destination['lng']
566
+ )
567
+
568
+ # 估算時間 (走路速度 5km/h)
569
+ speed = 1.38
570
+ leg_duration = int(leg_distance / speed)
571
+
572
+ # ✅ [FIX] 生成「直線 Polyline」給前端
573
+ # 雖然是直線,但至少前端不會因為空字串而報錯
574
+ encoded_polyline = self._encode_polyline([
575
+ (origin['lat'], origin['lng']),
576
+ (destination['lat'], destination['lng'])
577
+ ])
578
+
579
+ final_mode_used = "MATH_ESTIMATE"
580
+
581
+ # --- 累加數據 ---
582
+ total_distance += leg_distance
583
+ total_duration += leg_duration
584
+
585
+ legs_info.append({
586
+ "from_index": i,
587
+ "to_index": i + 1,
588
+ "travel_mode": final_mode_used,
589
+ "distance_meters": leg_distance,
590
+ "duration_seconds": leg_duration,
591
+ "departure_time": current_time.isoformat(),
592
+ "polyline": encoded_polyline # ✅ 現在這裡保證有值
593
+ })
594
+
595
+ # 更新時間 (使用秒數)
596
+ current_time += timedelta(seconds=leg_duration)
597
+ stop_duration = stop_times[i + 1]
598
+ current_time += timedelta(seconds=stop_duration)
599
 
600
  return {
601
  "total_distance_meters": total_distance,
602
+ "total_duration_seconds": total_duration,
603
+ "total_residence_time_minutes": sum(stop_times) // 60,
604
+ "total_time_seconds": int((current_time - start_time).total_seconds()),
605
  "start_time": start_time,
606
  "end_time": current_time,
607
  "stops": place_points,
 
798
  dest_batch = destinations[d_start:d_end]
799
 
800
  try:
801
+ time.sleep(0.2)
802
  elements = self._compute_route_matrix(
803
  origins=origin_batch,
804
  destinations=dest_batch,
 
815
  continue
816
 
817
  for el in elements:
818
+ if "originIndex" not in el or "destinationIndex" not in el:
819
+ continue
820
+
821
  oi = o_start + el["originIndex"]
822
  di = d_start + el["destinationIndex"]
823
 
 
832
  distance_matrix[oi, di] = distance_meters
833
 
834
  except Exception as e:
835
+ if "429" in str(e) or "Too Many Requests" in str(e):
836
+ logger.error(f"🚨 Rate Limit Hit! Sleeping for 5s...")
837
+ time.sleep(5)
838
+
839
  logger.error(
840
  f"❌ Failed to compute matrix batch "
841
+ f"[{o_start}:{o_end}, {d_start}:{d_end}]: {e}")
 
 
842
 
843
  logger.info(
844
  f"✅ Route matrix computed: "
 
850
  "distance_matrix": distance_matrix,
851
  }
852
 
 
853
  # ============================================================================
854
  # Usage Example
855
  # ============================================================================
 
907
  route = service.compute_routes(
908
  place_points=places,
909
  start_time=datetime.now(timezone.utc),
910
+ stop_times=[30, 60, 50], # 停留時間 (分鐘)
911
  travel_mode="DRIVE",
912
  routing_preference="TRAFFIC_AWARE" ##"TRAFFIC_AWARE_OPTIMAL"
913
  )
src/tools/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from .navigation_toolkit import NavigationToolkit
2
+ from .optimizer_toolkit import OptimizationToolkit
3
+ from .reader_toolkit import ReaderToolkit
4
+ from .weather_toolkit import WeatherToolkit
5
+ from .scout_toolkit import ScoutToolkit
src/tools/navigation_toolkit.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta, timezone
2
+ import json
3
+ from agno.tools import Toolkit
4
+ from src.services.googlemap_api_service import GoogleMapAPIService
5
+ from src.infra.poi_repository import poi_repo
6
+
7
+
8
+ class NavigationToolkit(Toolkit):
9
+ def __init__(self):
10
+ super().__init__(name="navigation_toolkit")
11
+ self.gmaps = GoogleMapAPIService()
12
+ self.register(self.calculate_traffic_and_timing)
13
+
14
+ def calculate_traffic_and_timing(self, optimization_ref_id: str) -> str:
15
+ """
16
+ 結合 route (順序) 與 tasks_detail (座標) 來計算精確導航。
17
+ 優先使用 global_info 中的 start_location 作為起點。
18
+ """
19
+ print(f"🚗 Navigator: Loading Ref {optimization_ref_id}...")
20
+ data = poi_repo.load(optimization_ref_id)
21
+ if not data:
22
+ return "❌ Error: Data not found."
23
+
24
+ route = data.get("route", [])
25
+ tasks_detail = data.get("tasks_detail", [])
26
+ global_info = data.get("global_info", {})
27
+
28
+ if not route:
29
+ return "❌ Error: No route found in data."
30
+
31
+ # 1. 建立座標查照表 (Lookup Map)
32
+ poi_lookup = {}
33
+ for task in tasks_detail:
34
+ t_id = str(task.get("task_id"))
35
+ chosen = task.get("chosen_poi", {})
36
+ if chosen is None:
37
+ continue
38
+ if "lat" in chosen and "lng" in chosen:
39
+ poi_lookup[t_id] = {"lat": chosen["lat"], "lng": chosen["lng"]}
40
+
41
+ # 2. 決定起點 (Start Location)
42
+ # 優先級:global_info > 預設值 (例如台北車站)
43
+ user_start = global_info.get("start_location")
44
+ if user_start and "lat" in user_start and "lng" in user_start:
45
+ start_coord = user_start
46
+ print(f"📍 Using User Start Location: {start_coord}")
47
+ else:
48
+ print("global_info", global_info)
49
+ start_coord = {"lat": 25.0478, "lng": 121.5170}
50
+ print(f"⚠️ Using Default Start Location: {start_coord}")
51
+
52
+ # 3. 依據 Route 的順序組裝 Waypoints
53
+ waypoints = []
54
+ stop_times = [] # 單位:秒
55
+
56
+ for i, step in enumerate(route):
57
+ lat, lng = None, None
58
+ step_type = step.get("type")
59
+ task_id = str(step.get("task_id")) if step.get("task_id") is not None else None
60
+
61
+ # [FIX] 從 Route Step 讀取正確的服務時間 (分鐘 -> 秒)
62
+ # 優化器 (Optimizer) 已經算好每個點要停多久,這裡必須沿用
63
+ service_min = step.get("service_duration_min", 0)
64
+ service_sec = service_min * 60
65
+
66
+ # Case A: 起點/終點 (Depot)
67
+ if step_type in ["depot", "start"] or i == 0:
68
+ lat = start_coord["lat"]
69
+ lng = start_coord["lng"]
70
+ # Depot 停留時間通常為 0,除非有特別設定
71
+ stop_times.append(service_sec)
72
+
73
+ # Case B: 任務點 (Task POI)
74
+ elif task_id and task_id in poi_lookup:
75
+ coords = poi_lookup[task_id]
76
+ lat = coords["lat"]
77
+ lng = coords["lng"]
78
+ stop_times.append(service_sec)
79
+
80
+ if lat is not None and lng is not None:
81
+ waypoints.append({"lat": lat, "lng": lng})
82
+ else:
83
+ print(f"⚠️ Skipped step {i}: No coordinates found. Type: {step_type}, ID: {task_id}")
84
+
85
+ # 4. 驗證與呼叫 API
86
+ if len(waypoints) < 2:
87
+ return f"❌ Error: Not enough valid waypoints ({len(waypoints)})."
88
+
89
+ try:
90
+ dep_str = global_info.get("departure_time")
91
+ # 確保時間帶有時區,避免 API 錯誤
92
+ try:
93
+ start_time = datetime.fromisoformat(dep_str)
94
+ if start_time.tzinfo is None:
95
+ start_time = start_time.replace(tzinfo=timezone.utc)
96
+ except:
97
+ start_time = datetime.now(timezone.utc)
98
+
99
+ print(f"🚗 Navigator: Calling Google Routes for {len(waypoints)} stops...")
100
+
101
+ traffic_result = self.gmaps.compute_routes(
102
+ place_points=waypoints,
103
+ start_time=start_time,
104
+ stop_times=stop_times, # [FIX] 傳入正確的秒數列表
105
+ travel_mode="DRIVE",
106
+ routing_preference="TRAFFIC_AWARE"
107
+ )
108
+ except Exception as e:
109
+ return f"❌ Traffic API Failed: {e}"
110
+
111
+ data["traffic_summary"] = {
112
+ "total_distance_km": traffic_result.get('total_distance_meters', 0) / 1000,
113
+ "total_duration_min": traffic_result.get('total_duration_seconds', 0) // 60
114
+ }
115
+ data["precise_traffic_result"] = traffic_result
116
+ data["solved_waypoints"] = waypoints
117
+ if "global_info" not in data:
118
+ data["global_info"] = global_info
119
+
120
+ print(f"✅ Traffic and timing calculated successfully.\n {data}")
121
+
122
+ nav_ref_id = poi_repo.save(data, data_type="navigation_result")
123
+
124
+ return json.dumps({
125
+ #"status": "SUCCESS",
126
+ "nav_ref_id": nav_ref_id,
127
+ #"traffic_summary": data["traffic_summary"],
128
+ #"note": "Please pass this nav_ref_id to the Weatherman immediately."
129
+ })
src/tools/optimizer_toolkit.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import json
3
+ from datetime import datetime, timedelta
4
+
5
+ from agno.tools import Toolkit
6
+ from src.optimization.tsptw_solver import TSPTWSolver
7
+ from src.infra.poi_repository import poi_repo
8
+
9
+
10
+ class OptimizationToolkit(Toolkit):
11
+ def __init__(self):
12
+ super().__init__(name="optimization_toolkit")
13
+ self.solver = TSPTWSolver()
14
+ self.register(self.optimize_from_ref)
15
+
16
+ def optimize_from_ref(self, ref_id: str) -> str:
17
+ """
18
+ 從 Ref ID 載入資料並執行路徑優化。
19
+ """
20
+ print(f"🧮 Optimizer: Fetching data for {ref_id}...")
21
+ data = poi_repo.load(ref_id)
22
+ if not data: return "❌ Error: Data not found."
23
+
24
+ tasks = data.get("tasks", [])
25
+
26
+ # ✅ [Critical] 讀取 global_info
27
+ global_info = data.get("global_info", {})
28
+
29
+ # 處理時間
30
+ start_time_str = global_info.get("departure_time")
31
+ default_date = datetime.now().date() + timedelta(days=1)
32
+
33
+ if start_time_str:
34
+ try:
35
+ # 嘗試 1: 標準 ISO 解析 (YYYY-MM-DDTHH:MM:SS+TZ)
36
+ start_time = datetime.fromisoformat(start_time_str)
37
+ except ValueError:
38
+ try:
39
+ from datetime import time as dt_time
40
+
41
+ parsed_time = dt_time.fromisoformat(start_time_str)
42
+
43
+ start_time = datetime.combine(default_date, parsed_time)
44
+ print(f"⚠️ Warning: Received time-only '{start_time_str}'. Auto-fixed to: {start_time}")
45
+
46
+ except ValueError:
47
+ # 嘗試 3: 真的爛掉了,Fallback 到現在
48
+ print(f"❌ Error: Could not parse '{start_time_str}'. Fallback to NOW.")
49
+ start_time = datetime.now().astimezone()
50
+ else:
51
+
52
+ start_time = datetime.now().astimezone()
53
+
54
+ deadline = start_time.replace(hour=23, minute=59)
55
+
56
+ # 處理起點 (優先從 global_info 拿,沒有則 fallback)
57
+ start_loc_data = global_info.get("start_location", {})
58
+ if "lat" in start_loc_data:
59
+ start_location = start_loc_data
60
+ else:
61
+ start_location = None
62
+
63
+ # 執行優化
64
+ try:
65
+ if not start_location:
66
+ raise ValueError(f"Start location not found in global_info. {global_info}")
67
+
68
+ result = self.solver.solve(
69
+ start_location=start_location,
70
+ start_time=start_time,
71
+ deadline=deadline,
72
+ tasks=tasks,
73
+ return_to_start=False
74
+ )
75
+ except Exception as e:
76
+ return f"❌ Solver Failed: {e}"
77
+
78
+ # ✅ [Critical] 將 global_info 繼承下去!
79
+ # 如果不加這一行,Navigator 就會因為找不到 departure_time 而報錯
80
+ result["tasks"] = tasks
81
+ result["global_info"] = global_info
82
+ print(f"🧾 Optimizer: Inherited global_info to result.\n {result}")
83
+ # 儲存結果
84
+ result_ref_id = poi_repo.save(result, data_type="optimization_result")
85
+ status = result.get("status")
86
+
87
+ output = {
88
+ #"status": "SUCCESS" if status == "OK" else "FAILED",
89
+ "opt_ref_id": result_ref_id,
90
+ #"note": "Please pass this opt_ref_id to the Navigator immediately."
91
+ }
92
+
93
+ return json.dumps(output)
src/tools/reader_toolkit.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from agno.tools import Toolkit
3
+ from src.infra.poi_repository import poi_repo
4
+ from src.infra.context import get_session_id
5
+ from src.infra.logger import get_logger
6
+
7
+ logger = get_logger(__name__)
8
+
9
+ class ReaderToolkit(Toolkit):
10
+ def __init__(self):
11
+ super().__init__(name="reader_toolkit")
12
+ self.register(self.read_final_itinerary)
13
+
14
+ def read_final_itinerary(self, ref_id: str) -> str:
15
+ logger.info(f"📖 Presenter: QA Checking Ref {ref_id}...")
16
+
17
+ data = poi_repo.load(ref_id)
18
+ if not data:
19
+ logger.warning(f"⚠️ Warning: Ref ID '{ref_id}' not found.")
20
+ session_id = get_session_id()
21
+ if session_id:
22
+ latest_id = poi_repo.get_last_id_by_session(session_id)
23
+ if latest_id and latest_id != ref_id:
24
+ logger.warning(f"🔄 Auto-Correcting: Switching to latest Session ID: {latest_id}")
25
+ data = poi_repo.load(latest_id)
26
+
27
+
28
+ if not data:
29
+ return "CRITICAL_ERROR: Ref ID not found."
30
+ if not data.get("timeline"):
31
+ return json.dumps({"status": "INCOMPLETE", "action_required": "DELEGATE_BACK_TO_WEATHERMAN"})
32
+
33
+ traffic = data.get("traffic_summary", {})
34
+ global_info = data.get("global_info", {})
35
+ timeline = data.get("timeline", [])
36
+ cleaned_timeline = []
37
+
38
+ for stop in timeline:
39
+ addr = stop.get("address", "")
40
+ if not addr:
41
+ coords = stop.get("coordinates", {})
42
+ addr = f"coords: {coords.get('lat'):.4f}, {coords.get('lng'):.4f}"
43
+
44
+ aqi_data = stop.get("aqi", {})
45
+ aqi_text = aqi_data.get("label", "N/A")
46
+
47
+ cleaned_timeline.append({
48
+ "time": stop.get("time"),
49
+ "location": stop.get("location"),
50
+ "address": addr,
51
+ "weather": stop.get("weather"),
52
+ "air_quality": aqi_text,
53
+ "travel_time_from_prev": stop.get("travel_time_from_prev", "- mins"),
54
+ "travel_mode": stop.get("travel_mode", "DRIVE")
55
+ })
56
+
57
+ summary_view = {
58
+ "status": "COMPLETE",
59
+ "global_info": global_info,
60
+
61
+ "traffic_summary": {
62
+ "total_distance": f"{traffic.get('total_distance_km', 0):.1f} km",
63
+ "total_drive_time": f"{traffic.get('total_duration_min', 0)} mins",
64
+ },
65
+ "schedule": cleaned_timeline
66
+ }
67
+
68
+ return json.dumps(summary_view, ensure_ascii=False, indent=2)
src/tools/scout_toolkit.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import ast
3
+ from agno.tools import Toolkit
4
+ from src.services.googlemap_api_service import GoogleMapAPIService
5
+ from src.infra.poi_repository import poi_repo
6
+ from src.infra.logger import get_logger
7
+
8
+ logger = get_logger(__name__)
9
+ MAX_SEARCH = 1000
10
+
11
+ class ScoutToolkit(Toolkit):
12
+ def __init__(self):
13
+ super().__init__(name="scout_toolkit")
14
+ self.gmaps = GoogleMapAPIService()
15
+ self.register(self.search_and_offload)
16
+
17
+ def _extract_first_json_object(self, text: str) -> str:
18
+ """
19
+ 使用堆疊 (Stack) 邏輯精準提取第一個完整的 JSON 物件 {}。
20
+ 比 Regex 更能抵抗前後文干擾。
21
+ """
22
+ text = text.strip()
23
+
24
+ # 1. 尋找第一個 '{'
25
+ start_idx = text.find('{')
26
+ if start_idx == -1:
27
+ return text # 找不到,原樣回傳碰運氣
28
+
29
+ # 2. 開始數括號
30
+ balance = 0
31
+ for i in range(start_idx, len(text)):
32
+ char = text[i]
33
+ if char == '{':
34
+ balance += 1
35
+ elif char == '}':
36
+ balance -= 1
37
+
38
+ # 當括號歸零時,代表找到了一個完整的 JSON Object
39
+ if balance == 0:
40
+ return text[start_idx: i + 1]
41
+
42
+ # 如果跑完迴圈 balance 還不是 0,代表 JSON 被截斷了 (Truncated)
43
+ # 這種情況下,我們盡量回傳目前抓到的部分,讓後面的修正邏輯去試試看
44
+ return text[start_idx:]
45
+
46
+ def _robust_parse_json(self, text: str) -> dict:
47
+ """
48
+ 強力解析器
49
+ """
50
+ # 1. 先移除 Markdown Code Block 標記 (如果有)
51
+ if "```" in text:
52
+ lines = text.split('\n')
53
+ clean_lines = []
54
+ in_code = False
55
+ for line in lines:
56
+ if "```" in line:
57
+ in_code = not in_code
58
+ continue
59
+ if in_code: # 只保留 code block 內的內容
60
+ clean_lines.append(line)
61
+
62
+ # 如果有提取到內容,就用提取的;否則假設整個 text 都是
63
+ if clean_lines:
64
+ text = "\n".join(clean_lines)
65
+
66
+ # 2. 使用堆疊提取器抓出純淨的 JSON 字串
67
+ json_str = self._extract_first_json_object(text)
68
+
69
+ # 3. 第一關:標準 JSON load
70
+ try:
71
+ return json.loads(json_str)
72
+ except json.JSONDecodeError:
73
+ pass
74
+
75
+ # 4. 第二關:處理 Python 風格 (單引號, True/False/None)
76
+ try:
77
+ return ast.literal_eval(json_str)
78
+ except (ValueError, SyntaxError):
79
+ pass
80
+
81
+ # 5. 第三關:暴力修正 (針對 Python 字串中的 unescaped quotes)
82
+ # 嘗試把 Python 的 None/True/False 換成 JSON 格式
83
+ try:
84
+ fixed_text = json_str.replace("True", "true").replace("False", "false").replace("None", "null")
85
+ return json.loads(fixed_text)
86
+ except json.JSONDecodeError as e:
87
+ raise ValueError(f"Failed to parse JSON via all methods. Raw: {text[:100]}...") from e
88
+
89
+ def search_and_offload(self, task_list_json: str) -> str:
90
+ """
91
+ Search places and offload to DB.
92
+ """
93
+ try:
94
+ data = self._robust_parse_json(task_list_json)
95
+ tasks = data.get("tasks", [])
96
+ global_info = data.get("global_info", {})
97
+ except Exception as e:
98
+ logger.warning(f"❌ JSON Parse Error: {e}")
99
+ # 這裡回傳錯誤訊息給 Agent,讓它知道格式錯了,它通常會自我修正並重試
100
+ return f"❌ Error: Invalid JSON format. Please output RAW JSON only. Details: {e}"
101
+
102
+ logger.debug(f"🕵️ Scout: Processing Global Info & {len(tasks)} tasks...")
103
+
104
+ # ============================================================
105
+ # 1. 處理 Start Location & 設定錨點 (兼容性修復版)
106
+ # ============================================================
107
+
108
+ # Helper: 提取 lat/lng (兼容 lat/latitude)
109
+ def extract_lat_lng(d):
110
+ if not isinstance(d, dict): return None, None
111
+ lat = d.get("lat") or d.get("latitude")
112
+ lng = d.get("lng") or d.get("longitude")
113
+ return lat, lng
114
+
115
+ start_loc = global_info.get("start_location")
116
+ anchor_point = None
117
+
118
+ if isinstance(start_loc, str):
119
+ logger.debug(f"🕵️ Scout: Resolving Start Location Name '{start_loc}'...")
120
+ try:
121
+ results = self.gmaps.text_search(query=start_loc, limit=1)
122
+ if results:
123
+ loc = results[0].get("location", {})
124
+ lat = loc.get("latitude") or loc.get("lat")
125
+ lng = loc.get("longitude") or loc.get("lng")
126
+ name = results[0].get("name") or start_loc
127
+
128
+ global_info["start_location"] = {"name": name, "lat": lat, "lng": lng}
129
+ anchor_point = {"lat": lat, "lng": lng}
130
+ logger.info(f" ✅ Resolved Start: {name}")
131
+ except Exception as e:
132
+ logger.warning(f" ❌ Error searching start location: {e}")
133
+
134
+ elif isinstance(start_loc, dict):
135
+ lat, lng = extract_lat_lng(start_loc)
136
+ if lat is not None and lng is not None:
137
+ # 已經有座標
138
+ anchor_point = {"lat": lat, "lng": lng}
139
+ global_info["start_location"] = {"name": "User Location", "lat": lat, "lng": lng}
140
+ logger.info(f" ✅ Anchor Point set from input: {anchor_point}")
141
+ else:
142
+
143
+ query_name = start_loc.get("name", "Unknown Start")
144
+ logger.info(f"🕵️ Scout: Resolving Start Location Dict '{query_name}'...")
145
+ try:
146
+ results = self.gmaps.text_search(query=query_name, limit=1)
147
+ if results:
148
+ loc = results[0].get("location", {})
149
+ lat = loc.get("latitude") or loc.get("lat")
150
+ lng = loc.get("longitude") or loc.get("lng")
151
+
152
+ global_info["start_location"] = {
153
+ "name": results[0].get("name", query_name),
154
+ "lat": lat, "lng": lng
155
+ }
156
+ anchor_point = {"lat": lat, "lng": lng}
157
+ logger.info(f" ✅ Resolved Start: {global_info['start_location']}")
158
+ except Exception as e:
159
+ logger.warning(f" ❌ Error searching start location: {e}")
160
+
161
+ total_tasks_count = len(tasks)
162
+ total_node_budget = MAX_SEARCH ** 0.5
163
+ HARD_CAP_PER_TASK = 15
164
+ MIN_LIMIT = 3
165
+
166
+ logger.info(f"🕵️ Scout: Starting Adaptive Search (Budget: {total_node_budget} nodes)")
167
+
168
+ enriched_tasks = []
169
+ for i, task in enumerate(tasks):
170
+ tasks_remaining = total_tasks_count - i
171
+
172
+ if total_node_budget <= 0:
173
+ current_limit = MIN_LIMIT
174
+ else:
175
+ allocation = total_node_budget // tasks_remaining
176
+ current_limit = max(MIN_LIMIT, min(HARD_CAP_PER_TASK, allocation))
177
+
178
+ desc = task.get("description", "")
179
+ hint = task.get("location_hint", "")
180
+ query = hint if hint else desc
181
+ if not query: query = "Unknown Location"
182
+
183
+ try:
184
+ places = self.gmaps.text_search(
185
+ query=query,
186
+ limit=current_limit,
187
+ location=anchor_point
188
+ )
189
+ except Exception as e:
190
+ logger.warning(f"⚠️ Search failed for {query}: {e}")
191
+ places = []
192
+
193
+ candidates = []
194
+ for p in places:
195
+ loc = p.get("location", {})
196
+ lat = loc.get("latitude") if "latitude" in loc else loc.get("lat")
197
+ lng = loc.get("longitude") if "longitude" in loc else loc.get("lng")
198
+
199
+ if lat is not None and lng is not None:
200
+ candidates.append({
201
+ "poi_id": p.get("place_id") or p.get("id"),
202
+ "name": p.get("name") or p.get("displayName", {}).get("text"),
203
+ "lat": lat,
204
+ "lng": lng,
205
+ "rating": p.get("rating"),
206
+ "time_window": None
207
+ })
208
+
209
+ # ✅ ID Fallback 機制
210
+ raw_id = task.get("task_id") or task.get("id") # 兼容 task_id 和 id
211
+ if raw_id and str(raw_id).strip().lower() not in ["none", "", "null"]:
212
+ task_id = str(raw_id)
213
+ else:
214
+ task_id = f"task_{i + 1}"
215
+
216
+ task_entry = {
217
+ "task_id": task_id,
218
+ "priority": task.get("priority", "MEDIUM"),
219
+ "service_duration_min": task.get("service_duration_min", 60), # 兼容欄位名
220
+ "time_window": task.get("time_window"),
221
+ "candidates": candidates
222
+ }
223
+ enriched_tasks.append(task_entry)
224
+ #print(f" - Task {task_id}: Found {len(candidates)} POIs")
225
+
226
+ full_payload = {"global_info": global_info, "tasks": enriched_tasks}
227
+ ref_id = poi_repo.save(full_payload, data_type="scout_result")
228
+
229
+ return json.dumps({
230
+ #"status": "SUCCESS",
231
+ #"message": "Search POI complete.",
232
+ "scout_ref": ref_id,
233
+ #"note": "Please pass this scout_ref to the Optimizer immediately."
234
+ })
src/tools/weather_toolkit.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta, timezone
2
+ import json
3
+ from agno.tools import Toolkit
4
+ from src.services.openweather_service import OpenWeatherMapService
5
+ from src.infra.poi_repository import poi_repo
6
+ from src.infra.logger import get_logger
7
+
8
+ logger = get_logger(__name__)
9
+
10
+
11
+ class WeatherToolkit(Toolkit):
12
+ def __init__(self):
13
+ super().__init__(name="weather_toolkit")
14
+ self.weather_service = OpenWeatherMapService()
15
+ self.register(self.check_weather_for_timeline)
16
+
17
+ def check_weather_for_timeline(self, nav_ref_id: str) -> str:
18
+ logger.debug(f"🌤️ Weatherman: Loading Ref {nav_ref_id}...")
19
+ data = poi_repo.load(nav_ref_id)
20
+ if not data: return "❌ Error: Data not found."
21
+
22
+ # [CRITICAL] 複製原始數據,確保 global_info, tasks 等不會遺失
23
+ final_data = data.copy()
24
+
25
+ traffic_res = data.get("precise_traffic_result", {})
26
+ legs = traffic_res.get("legs", [])
27
+ waypoints = data.get("solved_waypoints", []) or traffic_res.get("stops", [])
28
+
29
+ # 準備查表 Map (用於解析地點名稱)
30
+ tasks = data.get("tasks", [])
31
+ task_map = {str(t.get('id') or t.get('task_id')): t for t in tasks}
32
+ route_structure = data.get("route", [])
33
+
34
+ # ============================================================
35
+ # ✅ [FIX] 動態提取目標時區 (Dynamic Timezone Extraction)
36
+ # 我們從 global_info.departure_time 提取時區,而不是寫死 +8
37
+ # ============================================================
38
+ global_info = data.get("global_info", {})
39
+ departure_str = global_info.get("departure_time")
40
+
41
+ # 預設 fallback 為 UTC (萬一真的沒資料)
42
+ target_tz = timezone.utc
43
+
44
+ if departure_str:
45
+ try:
46
+ # 解析出發時間字串 (e.g. "2025-11-24T09:00:00+08:00")
47
+ start_dt_ref = datetime.fromisoformat(departure_str)
48
+
49
+ # 如果這個時間有帶時區 (tzinfo),我們就用它作為整個行程的標準時區
50
+ if start_dt_ref.tzinfo:
51
+ target_tz = start_dt_ref.tzinfo
52
+ logger.debug(f" 🌍 Detected Trip Timezone: {target_tz}")
53
+ except ValueError:
54
+ logger.warning(" ⚠️ Could not parse departure_time timezone, defaulting to UTC")
55
+
56
+ current_now = datetime.now(timezone.utc)
57
+ final_timeline = []
58
+
59
+ logger.debug(f"🌤️ Weatherman: Checking Weather & AQI for {len(waypoints)} stops...")
60
+
61
+ for i, point in enumerate(waypoints):
62
+ target_time = None
63
+ travel_time = 0
64
+ if i == 0:
65
+ start_str = traffic_res.get("start_time")
66
+ target_time = datetime.fromisoformat(start_str) if start_str else current_now
67
+ elif i - 1 < len(legs):
68
+ leg = legs[i - 1]
69
+
70
+ duration_sec = leg.get("duration_seconds", 0)
71
+ travel_time = duration_sec // 60
72
+ dep_str = leg.get("departure_time")
73
+ if dep_str:
74
+ target_time = datetime.fromisoformat(dep_str) + timedelta(seconds=duration_sec)
75
+
76
+ # 防呆:如果沒算出時間,用現在
77
+ if not target_time: target_time = current_now
78
+ if target_time.tzinfo is None: target_time = target_time.replace(tzinfo=timezone.utc)
79
+
80
+ # ============================================================
81
+ # ✅ [FIX] 將 UTC 時間轉為「該行程的時區」 (Local Time Conversion)
82
+ # ============================================================
83
+ local_time = target_time.astimezone(target_tz)
84
+ logger.debug(
85
+ f" ⏱️ Stop {i + 1}: UTC {target_time.strftime('%H:%M')} -> Local {local_time.strftime('%H:%M')}")
86
+
87
+ # --- 天氣 & AQI 查詢 ---
88
+ # 天氣查詢使用 UTC 時間比較準確 (API 通常吃 UTC)
89
+ diff_min = int((target_time - current_now).total_seconds() / 60)
90
+ weather_desc = "N/A"
91
+ temp_str = ""
92
+ aqi_info = {"aqi": -1, "label": "N/A"}
93
+
94
+ if diff_min >= 0:
95
+ try:
96
+ # 1. 查天氣
97
+ forecast = self.weather_service.get_forecast_weather(
98
+ location=point, future_minutes=diff_min
99
+ )
100
+ cond = forecast.get('condition', 'Unknown')
101
+ temp = forecast.get('temperature', 'N/A')
102
+ weather_desc = f"{cond}"
103
+ temp_str = f"{temp}°C"
104
+
105
+ # 2. 查空氣品質 (AQI) & 產生 Emoji
106
+ air = self.weather_service.get_forecast_air_pollution(
107
+ location=point, future_minutes=diff_min
108
+ )
109
+ aqi_val = air.get("aqi", 0) # 1=Good, 5=Very Poor
110
+
111
+ # AQI 映射表
112
+ aqi_map = {
113
+ 1: "🟢", # Good
114
+ 2: "🟡", # Fair
115
+ 3: "🟠", # Moderate
116
+ 4: "🔴", # Poor
117
+ 5: "🟣" # Very Poor
118
+ }
119
+ emoji = aqi_map.get(aqi_val, "⚪")
120
+
121
+ aqi_info = {
122
+ "aqi": aqi_val,
123
+ "label": f"AQI {aqi_val} {emoji}"
124
+ }
125
+
126
+ except Exception as e:
127
+ logger.error(f"Weather/AQI Error: {e}")
128
+ weather_desc = "API Error"
129
+
130
+ # --- 地點名稱解析 ---
131
+ location_name = f"Stop {i + 1}"
132
+ address = ""
133
+ if i < len(route_structure):
134
+ step = route_structure[i]
135
+ step_type = step.get("type")
136
+ if step_type in ["depot", "start"]:
137
+ location_name = "start point"
138
+ elif step.get("task_id"):
139
+ tid = str(step.get("task_id") or step.get("id"))
140
+ if tid in task_map:
141
+ t_info = task_map[tid]
142
+ location_name = t_info.get("description", location_name)
143
+ if t_info.get("candidates"):
144
+ cand = t_info["candidates"][0]
145
+ location_name = cand.get("name", location_name)
146
+ address = cand.get("address", "")
147
+
148
+ # --- 寫入 Timeline ---
149
+ timeline_entry = {
150
+ "stop_index": i,
151
+ "time": local_time.strftime("%H:%M"),
152
+ "location": location_name,
153
+ "address": address,
154
+ "weather": f"{weather_desc}, {temp_str}",
155
+ "aqi": aqi_info,
156
+ "travel_time_from_prev": f"{travel_time} mins",
157
+ "coordinates": point
158
+ }
159
+ final_timeline.append(timeline_entry)
160
+
161
+ # 寫入最終結果
162
+ final_data["timeline"] = final_timeline
163
+
164
+ # 再次確認 global_info 是否存在 (雖然 copy() 應該要有,但保險起見)
165
+ if "global_info" not in final_data:
166
+ final_data["global_info"] = global_info
167
+
168
+ final_ref_id = poi_repo.save(final_data, data_type="final_itinerary")
169
+
170
+ return json.dumps({
171
+ #"status": "SUCCESS",
172
+ "final_ref_id": final_ref_id,
173
+ #"note": "Pass this final_ref_id to the Presenter immediately."
174
+ })
175
+