selection_nodes.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. from __future__ import annotations
  2. """候选筛选节点:调用CandidateSelectionService对航班/酒店/往返组合做确定性排序。"""
  3. import json
  4. from typing import Any, Literal
  5. from langchain_core.messages import ToolMessage
  6. from langchain_core.tools import BaseTool
  7. from app.graph.state import TravelState
  8. from app.schemas.selection import (
  9. CandidateSelectionResult,
  10. )
  11. from app.services.candidate_selection_service import (
  12. CandidateSelectionService,
  13. )
  14. def extract_direct_tool_payload(
  15. result: Any,
  16. ) -> dict[str, Any]:
  17. """提取直接调用LangChain Tool后的结构化结果。"""
  18. if isinstance(result, ToolMessage):
  19. artifact = getattr(
  20. result,
  21. "artifact",
  22. None,
  23. )
  24. if isinstance(artifact, dict):
  25. structured = artifact.get(
  26. "structured_content"
  27. )
  28. if isinstance(structured, dict):
  29. return structured
  30. if "flights" in artifact:
  31. return artifact
  32. result = result.content
  33. if isinstance(result, dict):
  34. structured = result.get(
  35. "structured_content"
  36. )
  37. if isinstance(structured, dict):
  38. return structured
  39. if (
  40. "flights" in result
  41. or "hotels" in result
  42. or "suggestions" in result
  43. ):
  44. return result
  45. if isinstance(result, str):
  46. try:
  47. parsed = json.loads(result)
  48. except json.JSONDecodeError as exc:
  49. raise RuntimeError(
  50. "MCP工具返回了无法解析的文本。"
  51. ) from exc
  52. if isinstance(parsed, dict):
  53. return parsed
  54. if isinstance(result, list):
  55. for block in result:
  56. if not isinstance(block, dict):
  57. continue
  58. text = block.get("text")
  59. if not isinstance(text, str):
  60. continue
  61. try:
  62. parsed = json.loads(text)
  63. except json.JSONDecodeError:
  64. continue
  65. if isinstance(parsed, dict):
  66. return parsed
  67. raise RuntimeError(
  68. "无法从MCP工具结果中提取结构化数据。"
  69. )
  70. def get_return_stops_filter(
  71. max_stops: int | None,
  72. ) -> str | None:
  73. """将内部中转次数转换为MCP查询参数。"""
  74. if max_stops is None:
  75. return None
  76. if max_stops == 0:
  77. return "nonstop"
  78. if max_stops == 1:
  79. return "one_or_fewer"
  80. if max_stops == 2:
  81. return "two_or_fewer"
  82. # 接口没有“三次以内”的精确枚举,
  83. # 留给本地确定性代码过滤。
  84. return None
  85. def route_after_resources_to_selection(
  86. state: TravelState,
  87. ) -> Literal[
  88. "select_candidates",
  89. "end",
  90. ]:
  91. """资源查询后路由:有结果进入候选筛选,无结果结束工作流。"""
  92. if state.get("errors"):
  93. return "end"
  94. if not state.get("flight_search_result"):
  95. return "end"
  96. if not state.get("hotel_search_result"):
  97. return "end"
  98. return "select_candidates"
  99. def make_candidate_selection_node(
  100. return_flight_tool: BaseTool,
  101. *,
  102. selector: CandidateSelectionService
  103. | None = None,
  104. max_outbound_queries: int = 2,
  105. max_returns_per_outbound: int = 5,
  106. ):
  107. """创建航班和酒店候选筛选节点。
  108. 为控制接口调用成本,默认只对排名最高的
  109. 两个去程候选查询返程。
  110. """
  111. selection_service = (
  112. selector
  113. or CandidateSelectionService()
  114. )
  115. async def candidate_selection_node(
  116. state: TravelState,
  117. ) -> dict[str, Any]:
  118. """候选筛选节点:对航班和酒店候选排序,生成排名理由与警告。"""
  119. request = state["travel_request"]
  120. flight_result = state.get(
  121. "flight_search_result",
  122. {},
  123. )
  124. hotel_result = state.get(
  125. "hotel_search_result",
  126. {},
  127. )
  128. raw_flights = flight_result.get(
  129. "flights",
  130. [],
  131. )
  132. raw_hotels = hotel_result.get(
  133. "hotels",
  134. [],
  135. )
  136. if not isinstance(raw_flights, list):
  137. raw_flights = []
  138. if not isinstance(raw_hotels, list):
  139. raw_hotels = []
  140. (
  141. ranked_hotels,
  142. hotel_warnings,
  143. ) = selection_service.rank_hotels(
  144. raw_hotels,
  145. request,
  146. limit=10,
  147. )
  148. warnings = list(hotel_warnings)
  149. # 单程场景不需要调用返程工具。
  150. if request.return_date is None:
  151. (
  152. ranked_flights,
  153. flight_warnings,
  154. ) = selection_service.rank_flights(
  155. raw_flights,
  156. request,
  157. limit=10,
  158. )
  159. warnings.extend(flight_warnings)
  160. result = CandidateSelectionResult(
  161. flight_type="one_way",
  162. outbound_candidates=(
  163. ranked_flights
  164. ),
  165. one_way_options=ranked_flights,
  166. hotels=ranked_hotels,
  167. warnings=warnings,
  168. )
  169. return {
  170. "candidate_selection_result": result,
  171. "selection_warnings": warnings,
  172. "final_answer": (
  173. "候选筛选完成:"
  174. f"保留{len(ranked_flights)}条"
  175. "单程航班和"
  176. f"{len(ranked_hotels)}家酒店。"
  177. ),
  178. }
  179. (
  180. outbound_candidates,
  181. outbound_warnings,
  182. ) = selection_service.rank_flights(
  183. raw_flights,
  184. request,
  185. limit=max_outbound_queries,
  186. )
  187. warnings.extend(outbound_warnings)
  188. origin_airports = state.get(
  189. "origin_airports",
  190. [],
  191. )
  192. destination_airports = state.get(
  193. "destination_airports",
  194. [],
  195. )
  196. combinations: list[
  197. dict[str, dict[str, Any]]
  198. ] = []
  199. stops_filter = get_return_stops_filter(
  200. request.flight_preferences.max_stops
  201. )
  202. for outbound_candidate in (
  203. outbound_candidates
  204. ):
  205. outbound = (
  206. outbound_candidate.flight
  207. )
  208. departure_token = outbound.get(
  209. "departure_token"
  210. )
  211. if not departure_token:
  212. warnings.append(
  213. "去程候选"
  214. f"{outbound_candidate.option_id}"
  215. "缺少departure_token,"
  216. "已跳过返程查询。"
  217. )
  218. continue
  219. tool_args: dict[str, Any] = {
  220. "departure_token": (
  221. departure_token
  222. ),
  223. "departure_airports": (
  224. origin_airports
  225. ),
  226. "arrival_airports": (
  227. destination_airports
  228. ),
  229. "outbound_date": (
  230. request.departure_date
  231. .isoformat()
  232. ),
  233. "return_date": (
  234. request.return_date
  235. .isoformat()
  236. ),
  237. "adults": request.adults,
  238. "children": request.children,
  239. "travel_class": "economy",
  240. "currency": request.currency,
  241. "show_hidden": False,
  242. "deep_search": False,
  243. "no_cache": False,
  244. }
  245. if stops_filter is not None:
  246. tool_args["stops"] = (
  247. stops_filter
  248. )
  249. try:
  250. tool_result = (
  251. await return_flight_tool.ainvoke(
  252. tool_args
  253. )
  254. )
  255. payload = (
  256. extract_direct_tool_payload(
  257. tool_result
  258. )
  259. )
  260. return_flights = payload.get(
  261. "flights",
  262. [],
  263. )
  264. if not isinstance(
  265. return_flights,
  266. list,
  267. ):
  268. return_flights = []
  269. (
  270. ranked_returns,
  271. return_warnings,
  272. ) = (
  273. selection_service.rank_flights(
  274. return_flights,
  275. request,
  276. limit=(
  277. max_returns_per_outbound
  278. ),
  279. reverse_direction=True,
  280. apply_time_preferences=False,
  281. )
  282. )
  283. warnings.extend(return_warnings)
  284. for return_candidate in (
  285. ranked_returns
  286. ):
  287. combinations.append(
  288. {
  289. "outbound": outbound,
  290. "return_flight": (
  291. return_candidate.flight
  292. ),
  293. }
  294. )
  295. except Exception as exc:
  296. warnings.append(
  297. "返程查询失败:"
  298. f"{type(exc).__name__}: {exc}"
  299. )
  300. round_trip_options = (
  301. selection_service
  302. .rank_round_trip_combinations(
  303. combinations,
  304. request,
  305. limit=10,
  306. )
  307. )
  308. result = CandidateSelectionResult(
  309. flight_type="round_trip",
  310. outbound_candidates=(
  311. outbound_candidates
  312. ),
  313. round_trip_options=(
  314. round_trip_options
  315. ),
  316. hotels=ranked_hotels,
  317. warnings=warnings,
  318. )
  319. if not round_trip_options:
  320. message = (
  321. "没有获得有效的往返航班组合。"
  322. )
  323. existing_errors = list(
  324. state.get("errors", [])
  325. )
  326. existing_errors.append(message)
  327. return {
  328. "candidate_selection_result": result,
  329. "selection_warnings": warnings,
  330. "errors": existing_errors,
  331. "final_answer": message,
  332. }
  333. return {
  334. "candidate_selection_result": result,
  335. "selection_warnings": warnings,
  336. "final_answer": (
  337. "候选筛选完成:"
  338. f"查询了"
  339. f"{len(outbound_candidates)}个"
  340. "去程候选,获得"
  341. f"{len(round_trip_options)}个"
  342. "往返组合,并保留"
  343. f"{len(ranked_hotels)}家酒店。"
  344. ),
  345. }
  346. return candidate_selection_node