itinerary_agent.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. from __future__ import annotations
  2. """行程规划Agent:基于候选资源(航班/酒店/景点)与地图研究结果,生成包含每日活动安排的结构化多日行程。"""
  3. import json
  4. from typing import Any
  5. from langchain_core.language_models.chat_models import (
  6. BaseChatModel,
  7. )
  8. from langchain_core.messages import (
  9. HumanMessage,
  10. SystemMessage,
  11. )
  12. from app.llm import get_chat_model
  13. from app.schemas.itinerary import ItineraryPlan
  14. from app.schemas.place import MapResearchResult
  15. from app.schemas.route import (
  16. RouteEvaluationResult,
  17. )
  18. from app.schemas.selection import (
  19. CandidateSelectionResult,
  20. )
  21. from app.schemas.travel_request import (
  22. TravelRequest,
  23. )
  24. # ITINERARY_PLANNER_PROMPT:行程规划的系统提示词,定义每日行程的JSON输出结构与修改要求。
  25. ITINERARY_PLANNER_PROMPT = """
  26. 你是多角色旅行规划系统中的行程规划专家。
  27. 上游角色已经完成:
  28. 1. Requirement Analyst:解析用户需求。
  29. 2. Resource Search Agent:查询真实航班和酒店。
  30. 3. Destination Research Agent:查询真实景点、
  31. 餐厅和天气。
  32. 4. Candidate Selector:筛选航班和酒店。
  33. 5. Route Evaluator:评估酒店到景点的距离。
  34. 你的职责是基于上游结果,选择一个航班方案、
  35. 一家酒店,并生成逐日旅行安排。
  36. 严格规则:
  37. 一、真实性
  38. 1. 只能选择输入候选中存在的航班、酒店、
  39. 景点和餐厅。
  40. 2. 不得编造候选中不存在的名称、ID、地址、
  41. 坐标、航班号或价格。
  42. 3. 景点和餐厅的reference_id使用输入中的poi_id。
  43. 4. 如果poi_id为空,可以保留reference_id=null,
  44. 但名称必须和输入候选完全一致。
  45. 5. 不确定的信息放入assumptions或warnings,
  46. 不得伪装为确定事实。
  47. 二、航班
  48. 1. 往返旅行必须选择一个完整round_trip_options。
  49. 2. combination_id、outbound_option_id和
  50. return_option_id必须复制候选中的真实ID。
  51. 3. quoted_price采用候选中的接口报价。
  52. 4. 不得把去程和返程价格再次相加。
  53. 5. 到达日需要考虑航班到达时间。
  54. 6. 返程日必须为前往机场预留合理时间。
  55. 三、酒店
  56. 1. 优先使用Route Evaluator排名第一的酒店。
  57. 2. 如选择其他酒店,必须给出明确原因。
  58. 3. check_in_date必须等于用户的departure_date
  59. (不是航班实际到达日期)。
  60. 4. check_out_date必须等于用户的return_date。
  61. 5. nights必须等于
  62. (check_out_date - check_in_date)的天数。
  63. 例如:8月10日入住、8月14日退房
  64. → nights = 4。
  65. 6. price_per_night从酒店候选复制。
  66. 7. estimated_total_price = price_per_night × nights;
  67. 该值后续还会由Validator重新计算。
  68. 四、每日行程
  69. 1. 必须覆盖旅行期间的每一个自然日。
  70. 2. 日期必须连续。
  71. 3. 到达日和返程日不能安排过满。
  72. 4. 同一个景点不得重复安排。
  73. 5. 单个活动的结束时间必须晚于开始时间。
  74. 6. 景点尽量按照位置和主题组合。
  75. 7. 仅可使用输入中存在的餐厅。
  76. 8. 活动名称必须与候选名称完全一致,
  77. 不得添加"早餐:"、"午餐:"、"晚餐:"等前缀。
  78. 9. 不需要强行为每一餐指定餐厅。
  79. 10. 未查询到真实交通时间时,
  80. 不得编造精确交通时间。
  81. 五、节奏
  82. - relaxed:
  83. 每天原则上不超过2个主要景点,
  84. 保留休息和自由活动时间。
  85. - normal:
  86. 每天原则上2至3个主要景点。
  87. - intensive:
  88. 每天可安排3至4个主要景点,
  89. 但仍需考虑交通和开放时间风险。
  90. 六、天气
  91. 1. 有真实天气数据时,可以据此安排室内外活动。
  92. 2. 没有天气数据时,不得编造天气。
  93. 3. 在warnings中提示用户出发前再次确认天气。
  94. 七、费用
  95. 1. 只能使用已有的航班和酒店报价。
  96. 2. 未查询门票或餐饮价格时,
  97. estimated_cost应为null。
  98. 3. 总预算是否超支由后续Validator负责。
  99. 八、输出格式(必须严格遵守字段名)
  100. 你必须返回一个 JSON 对象,顶层字段如下:
  101. {
  102. "title": "行程标题(字符串)",
  103. "overview": "行程概览(字符串)",
  104. "selected_flight": {
  105. "flight_type": "round_trip 或 one_way",
  106. "combination_id": "来自 round_trip_options 的组合ID(仅往返)",
  107. "outbound_option_id": "去程航班ID(必填)",
  108. "return_option_id": "返程航班ID(往返必填,单程为null)",
  109. "quoted_price": 接口报价数字,
  110. "currency": "CNY",
  111. "outbound_flight_numbers": ["MU001"],
  112. "return_flight_numbers": ["MU002"],
  113. "outbound_departure_time": "去程出发时间字符串",
  114. "outbound_arrival_time": "去程到达时间字符串",
  115. "return_departure_time": "返程出发时间字符串",
  116. "return_arrival_time": "返程到达时间字符串",
  117. "selection_reason": "选择理由"
  118. },
  119. "selected_hotel": {
  120. "hotel_id": "来自候选的酒店ID",
  121. "name": "酒店名称",
  122. "check_in_date": "入住日期 YYYY-MM-DD(必须等于 departure_date)",
  123. "check_out_date": "退房日期 YYYY-MM-DD(必须等于 return_date)",
  124. "nights": "check_out_date - check_in_date 的天数,如 8/10→8/14 为 4",
  125. "price_per_night": 每晚价格,
  126. "estimated_total_price": price_per_night × nights,
  127. "currency": "CNY",
  128. "address": "地址文本(可选)",
  129. "location": "经纬度字符串如 \"104.07,30.66\"(可选,不是对象)",
  130. "selection_reason": "选择理由"
  131. },
  132. "days": [
  133. {
  134. "day_index": 从1开始的整数,
  135. "date": "YYYY-MM-DD",
  136. "theme": "当日主题",
  137. "activities": [
  138. {
  139. "sequence": 从1开始递增的整数,
  140. "activity_type": "flight|hotel_check_in|hotel_check_out|attraction|restaurant|transport|free_time|other",
  141. "reference_id": "景点或餐厅的poi_id(航班/入住等为null)",
  142. "name": "活动名称",
  143. "start_time": "HH:MM",
  144. "end_time": "HH:MM",
  145. "address": "地址(可选)",
  146. "location": "经纬度(可选)",
  147. "transport_mode": "walking|public_transit|driving|taxi|unknown(可选)",
  148. "estimated_transport_minutes": 预估交通分钟(可选),
  149. "estimated_cost": 预估费用(可选),
  150. "notes": "备注(可选)"
  151. }
  152. ],
  153. "daily_notes": ["当日备注(可选)"]
  154. }
  155. ],
  156. "highlights": ["行程亮点"],
  157. "assumptions": ["不确定的假设"],
  158. "warnings": ["需要用户注意的点"]
  159. }
  160. 注意:
  161. - 不要在最外层包裹 {"itineraryPlan": {...}},直接输出上述 JSON。
  162. - 所有日期格式为 YYYY-MM-DD,时间格式为 HH:MM。
  163. - 必须从输入候选中复制真实数据,不得编造。
  164. """
  165. def _compact_flight(
  166. flight: dict[str, Any],
  167. ) -> dict[str, Any]:
  168. """保留Planner真正需要的航班字段。"""
  169. return {
  170. "option_id": flight.get("option_id"),
  171. "flight_numbers": flight.get(
  172. "flight_numbers",
  173. [],
  174. ),
  175. "airlines": flight.get("airlines", []),
  176. "departure_airport": flight.get(
  177. "departure_airport"
  178. ),
  179. "arrival_airport": flight.get(
  180. "arrival_airport"
  181. ),
  182. "departure_time": flight.get(
  183. "departure_time"
  184. ),
  185. "arrival_time": flight.get(
  186. "arrival_time"
  187. ),
  188. "duration_minutes": flight.get(
  189. "duration_minutes"
  190. ),
  191. "stop_count": flight.get("stop_count"),
  192. "price": flight.get("price"),
  193. "currency": flight.get("currency"),
  194. }
  195. def build_planner_context(
  196. request: TravelRequest,
  197. selection: CandidateSelectionResult,
  198. map_result: MapResearchResult,
  199. route_result: RouteEvaluationResult,
  200. revision_feedback: list[str] | None = None,
  201. ) -> dict[str, Any]:
  202. """把共享State压缩为Planner需要的上下文。
  203. 避免将几十条原始航班和酒店全部放入Prompt。
  204. """
  205. round_trip_options: list[
  206. dict[str, Any]
  207. ] = []
  208. for option in selection.round_trip_options[:5]:
  209. round_trip_options.append(
  210. {
  211. "combination_id": (
  212. option.combination_id
  213. ),
  214. "score": option.score,
  215. "quoted_price": option.quoted_price,
  216. "currency": option.currency,
  217. "outbound": _compact_flight(
  218. option.outbound
  219. ),
  220. "return_flight": _compact_flight(
  221. option.return_flight
  222. ),
  223. "reasons": option.reasons,
  224. "warnings": option.warnings,
  225. }
  226. )
  227. one_way_options: list[
  228. dict[str, Any]
  229. ] = []
  230. for candidate in selection.one_way_options[:5]:
  231. one_way_options.append(
  232. {
  233. "score": candidate.score,
  234. "flight": _compact_flight(
  235. candidate.flight
  236. ),
  237. "reasons": candidate.reasons,
  238. "warnings": candidate.warnings,
  239. }
  240. )
  241. evaluated_hotels: list[
  242. dict[str, Any]
  243. ] = []
  244. for evaluated in (
  245. route_result.evaluated_hotels[:3]
  246. ):
  247. hotel = evaluated.hotel
  248. evaluated_hotels.append(
  249. {
  250. "hotel_id": evaluated.hotel_id,
  251. "name": evaluated.hotel_name,
  252. "final_score": (
  253. evaluated.final_score
  254. ),
  255. "base_hotel_score": (
  256. evaluated.base_hotel_score
  257. ),
  258. "average_distance_meters": (
  259. evaluated
  260. .average_distance_meters
  261. ),
  262. "average_duration_seconds": (
  263. evaluated
  264. .average_duration_seconds
  265. ),
  266. "nearest_subway_distance_meters": (
  267. evaluated
  268. .nearest_subway_distance_meters
  269. ),
  270. "price_per_night": hotel.get(
  271. "price_per_night"
  272. ),
  273. "total_price": hotel.get(
  274. "total_price"
  275. ),
  276. "currency": hotel.get("currency"),
  277. "coordinates": hotel.get(
  278. "coordinates"
  279. ),
  280. "reasons": evaluated.reasons,
  281. "warnings": evaluated.warnings,
  282. "route_legs": [
  283. {
  284. "destination_name": (
  285. leg.destination_name
  286. ),
  287. "distance_meters": (
  288. leg.distance_meters
  289. ),
  290. "duration_seconds": (
  291. leg.duration_seconds
  292. ),
  293. "source": leg.source,
  294. }
  295. for leg in evaluated.route_legs
  296. ],
  297. }
  298. )
  299. attractions = [
  300. place.model_dump(mode="json")
  301. for place in map_result.attractions[:10]
  302. ]
  303. restaurants = [
  304. place.model_dump(mode="json")
  305. for place in map_result.restaurants[:6]
  306. ]
  307. weather = [
  308. item.model_dump(mode="json")
  309. for item in map_result.weather
  310. ]
  311. return {
  312. "travel_request": request.model_dump(
  313. mode="json"
  314. ),
  315. "flight_candidates": {
  316. "flight_type": selection.flight_type,
  317. "round_trip_options": (
  318. round_trip_options
  319. ),
  320. "one_way_options": one_way_options,
  321. "warnings": selection.warnings,
  322. },
  323. "evaluated_hotels": evaluated_hotels,
  324. "selected_hotel_id_from_route_evaluator": (
  325. route_result.selected_hotel_id
  326. ),
  327. "attractions": attractions,
  328. "restaurants": restaurants,
  329. "weather": weather,
  330. "map_notes": map_result.notes,
  331. "route_warnings": route_result.warnings,
  332. "revision_feedback": (
  333. revision_feedback or []
  334. ),
  335. }
  336. class ItineraryPlannerAgent:
  337. """负责综合上游角色结果生成逐日行程。"""
  338. def __init__(
  339. self,
  340. model: BaseChatModel | None = None,
  341. ) -> None:
  342. """初始化行程规划Agent,支持注入自定义模型。"""
  343. base_model = model or get_chat_model(
  344. timeout=180.0
  345. )
  346. # json_mode 通过 response_format 约束模型输出 JSON,
  347. # 对 DeepSeek 等推理模型的兼容性更好。
  348. self._structured_model = (
  349. base_model.with_structured_output(
  350. ItineraryPlan,
  351. method="json_mode",
  352. )
  353. )
  354. async def plan(
  355. self,
  356. request: TravelRequest,
  357. selection: CandidateSelectionResult,
  358. map_result: MapResearchResult,
  359. route_result: RouteEvaluationResult,
  360. revision_feedback: list[str] | None = None,
  361. ) -> dict[str, Any]:
  362. """生成结构化旅行方案。"""
  363. context = build_planner_context(
  364. request=request,
  365. selection=selection,
  366. map_result=map_result,
  367. route_result=route_result,
  368. revision_feedback=revision_feedback,
  369. )
  370. context_json = json.dumps(
  371. context,
  372. ensure_ascii=False,
  373. indent=2,
  374. )
  375. feedback_instruction = ""
  376. if revision_feedback:
  377. feedback_instruction = (
  378. "\n这是审查角色上一轮提出的修改要求,"
  379. "新方案必须逐项处理:\n"
  380. + "\n".join(
  381. f"- {item}"
  382. for item in revision_feedback
  383. )
  384. )
  385. user_message = f"""
  386. 请根据以下上游角色提供的真实数据生成旅行方案。
  387. {context_json}
  388. {feedback_instruction}
  389. 最终必须以 JSON 格式返回 ItineraryPlan 结构化结果。
  390. 不要输出结构化结果之外的解释。
  391. """
  392. response = await self._structured_model.ainvoke(
  393. [
  394. SystemMessage(
  395. content=(
  396. ITINERARY_PLANNER_PROMPT
  397. )
  398. ),
  399. HumanMessage(
  400. content=user_message
  401. ),
  402. ]
  403. )
  404. if isinstance(response, ItineraryPlan):
  405. structured = response
  406. else:
  407. # 兼容 LLM 可能包裹在 {"itineraryPlan": {...}} 中的情况。
  408. if (
  409. isinstance(response, dict)
  410. and "itineraryPlan" in response
  411. and len(response) == 1
  412. ):
  413. response = response["itineraryPlan"]
  414. # 兼容 LLM 将 coordinates 对象误填到
  415. # selected_hotel.location 的情况。
  416. if isinstance(response, dict):
  417. hotel = response.get("selected_hotel")
  418. if isinstance(hotel, dict):
  419. loc = hotel.get("location")
  420. if isinstance(loc, dict):
  421. lat = loc.get("latitude")
  422. lng = loc.get("longitude")
  423. if lat is not None and lng is not None:
  424. hotel["location"] = (
  425. f"{lng},{lat}"
  426. )
  427. structured = ItineraryPlan.model_validate(response)
  428. return {
  429. "structured_response": structured,
  430. "messages": [],
  431. }