route_nodes.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. from __future__ import annotations
  2. """路线评估节点:组合候选航班与酒店结果,调用高德距离工具评估酒店与景点的实际交通距离。"""
  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.route import (
  9. RouteEvaluationResult,
  10. RouteLeg,
  11. )
  12. from app.services.route_evaluation_service import (
  13. RouteEvaluationService,
  14. )
  15. def _to_mapping(
  16. value: Any,
  17. ) -> dict[str, Any] | None:
  18. """把dict或Pydantic模型统一转换为dict,方便取structuredContent。"""
  19. if isinstance(value, dict):
  20. return value
  21. model_dump = getattr(
  22. value,
  23. "model_dump",
  24. None,
  25. )
  26. if callable(model_dump):
  27. dumped = model_dump()
  28. if isinstance(dumped, dict):
  29. return dumped
  30. return None
  31. def extract_mcp_payload(
  32. result: Any,
  33. ) -> dict[str, Any]:
  34. """提取直接调用MCP工具后的结构化内容。
  35. LangChain Adapter可能把structuredContent
  36. 放入ToolMessage.artifact。
  37. """
  38. if isinstance(result, ToolMessage):
  39. artifact = getattr(
  40. result,
  41. "artifact",
  42. None,
  43. )
  44. artifact_mapping = _to_mapping(
  45. artifact
  46. )
  47. if artifact_mapping:
  48. for key in (
  49. "structured_content",
  50. "structuredContent",
  51. ):
  52. structured = (
  53. artifact_mapping.get(key)
  54. )
  55. if isinstance(
  56. structured,
  57. dict,
  58. ):
  59. return structured
  60. return artifact_mapping
  61. structured_attr = getattr(
  62. artifact,
  63. "structured_content",
  64. None,
  65. )
  66. if isinstance(
  67. structured_attr,
  68. dict,
  69. ):
  70. return structured_attr
  71. result = result.content
  72. mapping = _to_mapping(result)
  73. if mapping:
  74. for key in (
  75. "structured_content",
  76. "structuredContent",
  77. ):
  78. structured = mapping.get(key)
  79. if isinstance(structured, dict):
  80. return structured
  81. return mapping
  82. if isinstance(result, str):
  83. parsed = json.loads(result)
  84. if isinstance(parsed, dict):
  85. return parsed
  86. if isinstance(result, list):
  87. for block in result:
  88. if not isinstance(block, dict):
  89. continue
  90. text = block.get("text")
  91. if not isinstance(text, str):
  92. continue
  93. try:
  94. parsed = json.loads(text)
  95. except json.JSONDecodeError:
  96. continue
  97. if isinstance(parsed, dict):
  98. return parsed
  99. raise RuntimeError(
  100. "无法从高德MCP结果中提取结构化数据。"
  101. )
  102. def find_numeric_values(
  103. value: Any,
  104. target_key: str,
  105. ) -> list[float]:
  106. """递归提取指定字段中的数字。"""
  107. results: list[float] = []
  108. if isinstance(value, dict):
  109. for key, item in value.items():
  110. if key == target_key:
  111. if (
  112. isinstance(
  113. item,
  114. (int, float, str),
  115. )
  116. and not isinstance(item, bool)
  117. ):
  118. try:
  119. results.append(
  120. float(item)
  121. )
  122. except ValueError:
  123. pass
  124. results.extend(
  125. find_numeric_values(
  126. item,
  127. target_key,
  128. )
  129. )
  130. elif isinstance(value, list):
  131. for item in value:
  132. results.extend(
  133. find_numeric_values(
  134. item,
  135. target_key,
  136. )
  137. )
  138. return results
  139. def get_hotel_location(
  140. hotel: dict[str, Any],
  141. ) -> str | None:
  142. """从标准化酒店结果提取高德坐标字符串。"""
  143. coordinates = hotel.get("coordinates")
  144. if not isinstance(coordinates, dict):
  145. return None
  146. longitude = coordinates.get("longitude")
  147. latitude = coordinates.get("latitude")
  148. if longitude is None or latitude is None:
  149. return None
  150. return f"{longitude},{latitude}"
  151. def route_after_selection(
  152. state: TravelState,
  153. ) -> Literal[
  154. "research_destination",
  155. "end",
  156. ]:
  157. """筛选完成后路由:有结果进入地图研究,无结果结束。"""
  158. if state.get("errors"):
  159. return "end"
  160. if not state.get(
  161. "candidate_selection_result"
  162. ):
  163. return "end"
  164. return "research_destination"
  165. def route_after_map_research(
  166. state: TravelState,
  167. ) -> Literal[
  168. "evaluate_routes",
  169. "end",
  170. ]:
  171. """地图研究完成后路由:有结果进入路线评估,无结果结束。"""
  172. if state.get("errors"):
  173. return "end"
  174. if not state.get(
  175. "map_research_result"
  176. ):
  177. return "end"
  178. if not state.get(
  179. "candidate_selection_result"
  180. ):
  181. return "end"
  182. return "evaluate_routes"
  183. def make_route_evaluation_node(
  184. distance_tool: BaseTool,
  185. around_search_tool: BaseTool | None,
  186. *,
  187. service: RouteEvaluationService
  188. | None = None,
  189. max_hotels: int = 3,
  190. max_attractions: int = 4,
  191. ):
  192. """创建酒店位置与路线评估节点。
  193. 为控制调用量,仅评估前3家酒店和前4个景点。
  194. """
  195. route_service = (
  196. service
  197. or RouteEvaluationService()
  198. )
  199. async def route_evaluation_node(
  200. state: TravelState,
  201. ) -> dict[str, Any]:
  202. """路线评估节点:调用RouteEvaluationService评估酒店位置并计算景点间路线距离。"""
  203. request = state["travel_request"]
  204. selection = state[
  205. "candidate_selection_result"
  206. ]
  207. map_result = state[
  208. "map_research_result"
  209. ]
  210. hotel_candidates = (
  211. selection.hotels[:max_hotels]
  212. )
  213. attractions = [
  214. attraction
  215. for attraction
  216. in map_result.attractions
  217. if attraction.location
  218. ][:max_attractions]
  219. warnings: list[str] = []
  220. if not hotel_candidates:
  221. message = "没有可用于路线评估的酒店。"
  222. return {
  223. "errors": [message],
  224. "route_warnings": [message],
  225. "final_answer": message,
  226. }
  227. if not attractions:
  228. message = (
  229. "没有带有效坐标的景点候选。"
  230. )
  231. return {
  232. "errors": [message],
  233. "route_warnings": [message],
  234. "final_answer": message,
  235. }
  236. legs_by_hotel: dict[
  237. str,
  238. list[RouteLeg],
  239. ] = {}
  240. subway_distance_by_hotel: dict[
  241. str,
  242. float | None,
  243. ] = {}
  244. distance_call_count = 0
  245. around_search_call_count = 0
  246. for hotel_candidate in hotel_candidates:
  247. hotel = hotel_candidate.hotel
  248. hotel_id = hotel_candidate.hotel_id
  249. hotel_name = str(
  250. hotel.get("name")
  251. or hotel_id
  252. )
  253. hotel_location = (
  254. get_hotel_location(hotel)
  255. )
  256. hotel_legs: list[RouteLeg] = []
  257. if hotel_location is None:
  258. warnings.append(
  259. f"{hotel_name}缺少经纬度,"
  260. "无法执行真实距离查询。"
  261. )
  262. legs_by_hotel[hotel_id] = []
  263. subway_distance_by_hotel[
  264. hotel_id
  265. ] = None
  266. continue
  267. for attraction in attractions:
  268. attraction_location = (
  269. attraction.location
  270. )
  271. if attraction_location is None:
  272. continue
  273. try:
  274. distance_call_count += 1
  275. tool_result = (
  276. await distance_tool.ainvoke(
  277. {
  278. "origins": (
  279. hotel_location
  280. ),
  281. "destination": (
  282. attraction_location
  283. ),
  284. # 1代表驾车距离。
  285. "type": "1",
  286. }
  287. )
  288. )
  289. payload = extract_mcp_payload(
  290. tool_result
  291. )
  292. distances = (
  293. find_numeric_values(
  294. payload,
  295. "distance",
  296. )
  297. )
  298. durations = (
  299. find_numeric_values(
  300. payload,
  301. "duration",
  302. )
  303. )
  304. if not distances:
  305. raise RuntimeError(
  306. "距离结果中没有distance字段"
  307. )
  308. hotel_legs.append(
  309. RouteLeg(
  310. origin_name=hotel_name,
  311. origin_location=(
  312. hotel_location
  313. ),
  314. destination_name=(
  315. attraction.name
  316. ),
  317. destination_location=(
  318. attraction_location
  319. ),
  320. distance_meters=(
  321. distances[0]
  322. ),
  323. duration_seconds=(
  324. durations[0]
  325. if durations
  326. else None
  327. ),
  328. source="amap",
  329. )
  330. )
  331. except Exception as exc:
  332. warnings.append(
  333. f"{hotel_name} → "
  334. f"{attraction.name}:"
  335. "高德距离查询失败,"
  336. "已使用直线距离降级。"
  337. )
  338. try:
  339. fallback_leg = (
  340. route_service
  341. .build_haversine_leg(
  342. origin_name=(
  343. hotel_name
  344. ),
  345. origin_location=(
  346. hotel_location
  347. ),
  348. destination_name=(
  349. attraction.name
  350. ),
  351. destination_location=(
  352. attraction_location
  353. ),
  354. )
  355. )
  356. hotel_legs.append(
  357. fallback_leg
  358. )
  359. except Exception as fallback_exc:
  360. warnings.append(
  361. "直线距离降级也失败:"
  362. f"{type(fallback_exc).__name__}: "
  363. f"{fallback_exc}"
  364. )
  365. legs_by_hotel[
  366. hotel_id
  367. ] = hotel_legs
  368. subway_distance: float | None = None
  369. if (
  370. request
  371. .hotel_preferences
  372. .near_subway
  373. and around_search_tool
  374. is not None
  375. ):
  376. try:
  377. around_search_call_count += 1
  378. tool_result = (
  379. await around_search_tool.ainvoke(
  380. {
  381. "location": (
  382. hotel_location
  383. ),
  384. "radius": "1500",
  385. "keywords": "地铁站",
  386. }
  387. )
  388. )
  389. payload = extract_mcp_payload(
  390. tool_result
  391. )
  392. subway_distances = (
  393. find_numeric_values(
  394. payload,
  395. "distance",
  396. )
  397. )
  398. if subway_distances:
  399. subway_distance = min(
  400. subway_distances
  401. )
  402. else:
  403. warnings.append(
  404. f"{hotel_name}周边搜索"
  405. "没有返回可解析的地铁距离。"
  406. )
  407. except Exception as exc:
  408. warnings.append(
  409. f"{hotel_name}周边地铁查询失败:"
  410. f"{type(exc).__name__}: {exc}"
  411. )
  412. subway_distance_by_hotel[
  413. hotel_id
  414. ] = subway_distance
  415. evaluated_hotels = (
  416. route_service.rank_hotels(
  417. hotel_candidates,
  418. legs_by_hotel,
  419. subway_distance_by_hotel,
  420. require_near_subway=bool(
  421. request
  422. .hotel_preferences
  423. .near_subway
  424. ),
  425. limit=max_hotels,
  426. )
  427. )
  428. selected_hotel_id = (
  429. evaluated_hotels[0].hotel_id
  430. if evaluated_hotels
  431. else None
  432. )
  433. result = RouteEvaluationResult(
  434. evaluated_hotels=evaluated_hotels,
  435. reference_attractions=[
  436. attraction.name
  437. for attraction in attractions
  438. ],
  439. selected_hotel_id=selected_hotel_id,
  440. distance_call_count=(
  441. distance_call_count
  442. ),
  443. around_search_call_count=(
  444. around_search_call_count
  445. ),
  446. warnings=warnings,
  447. )
  448. if not evaluated_hotels:
  449. message = (
  450. "路线评估完成,但没有得到"
  451. "有效酒店排序结果。"
  452. )
  453. return {
  454. "route_evaluation_result": result,
  455. "route_warnings": warnings,
  456. "errors": [message],
  457. "final_answer": message,
  458. }
  459. selected_hotel = evaluated_hotels[0]
  460. return {
  461. "route_evaluation_result": result,
  462. "route_warnings": warnings,
  463. "final_answer": (
  464. "酒店路线评估完成:"
  465. f"评估{len(evaluated_hotels)}家酒店,"
  466. f"参考{len(attractions)}个景点;"
  467. "当前综合排名第一为"
  468. f"“{selected_hotel.hotel_name}”,"
  469. f"综合得分"
  470. f"{selected_hotel.final_score:.2f}。"
  471. ),
  472. }
  473. return route_evaluation_node