Spaces:
Running
Running
buildup agent system
Browse files- src/__init__.py +0 -0
- src/agent/base.py +80 -0
- src/agent/core_team.py +278 -0
- src/agent/planner.py +76 -0
- src/agent/setting/__init__.py +0 -0
- src/agent/setting/navigator.py +18 -0
- src/agent/setting/optimizer.py +20 -0
- src/agent/setting/planner.py +78 -0
- src/agent/setting/presenter.py +52 -0
- src/agent/setting/scout.py +20 -0
- src/agent/setting/team.py +65 -0
- src/agent/setting/team.py.backup.backup +56 -0
- src/agent/setting/team.py.backup.backup.backup +60 -0
- src/agent/setting/weatherman.py +19 -0
- src/agent/test.py +207 -0
- src/infra/config.py +2 -0
- src/infra/context.py +9 -0
- src/infra/offload_manager.py +47 -0
- src/infra/poi_repository.py +65 -0
- src/optimization/__init__.py +27 -0
- src/optimization/graph/__init__.py +7 -0
- src/optimization/graph/graph_builder.py +170 -0
- src/optimization/graph/time_window_handler.py +302 -0
- src/optimization/models/__init__.py +30 -0
- src/optimization/models/converters.py +173 -0
- src/optimization/models/internal_models.py +312 -0
- src/optimization/solver/__init__.py +8 -0
- src/optimization/solver/ortools_solver.py +274 -0
- src/optimization/solver/solution_extractor.py +438 -0
- src/optimization/test/__init__.py +0 -0
- src/optimization/test/_solver_test.py +410 -0
- src/optimization/test/_test_convertes.py +255 -0
- src/optimization/test/_time_time_windows.py +296 -0
- src/optimization/tsptw_solver.py +301 -0
- src/services/googlemap_api_service.py +165 -116
- src/tools/__init__.py +5 -0
- src/tools/navigation_toolkit.py +129 -0
- src/tools/optimizer_toolkit.py +93 -0
- src/tools/reader_toolkit.py +68 -0
- src/tools/scout_toolkit.py +234 -0
- 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
|
| 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] =
|
| 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"]
|
| 278 |
}
|
| 279 |
}
|
| 280 |
},
|
| 281 |
"destination": {
|
| 282 |
"location": {
|
|
|
|
|
|
|
| 283 |
"latLng": {
|
| 284 |
"latitude": destination["lat"],
|
| 285 |
-
"longitude": destination["lng"]
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 413 |
# ========================================================================
|
| 414 |
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 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 |
-
|
| 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 |
-
#
|
| 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 |
-
|
| 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 |
-
|
| 508 |
else:
|
| 509 |
-
|
|
|
|
|
|
|
| 510 |
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
else:
|
| 515 |
-
|
| 516 |
|
| 517 |
-
|
|
|
|
| 518 |
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 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 |
-
|
| 531 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
|
| 533 |
-
|
| 534 |
-
total_duration += leg_duration
|
| 535 |
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
|
|
|
|
|
|
|
|
|
| 545 |
|
| 546 |
-
|
|
|
|
|
|
|
| 547 |
|
| 548 |
-
|
|
|
|
| 549 |
|
| 550 |
-
|
|
|
|
|
|
|
| 551 |
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|