route_evaluation_service.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. from __future__ import annotations
  2. """路线评估服务:对酒店按距离/地铁/评分做加权排序,使用高德距离或球面余弦公式计算路线衔接。不调用大模型。"""
  3. import math
  4. from statistics import mean
  5. from typing import Iterable
  6. from app.schemas.route import (
  7. HotelRouteEvaluation,
  8. RouteLeg,
  9. )
  10. from app.schemas.selection import (
  11. RankedHotelCandidate,
  12. )
  13. class RouteEvaluationService:
  14. """根据真实距离重新评价酒店候选。
  15. 本服务不调用大模型和外部接口。
  16. """
  17. def rank_hotels(
  18. self,
  19. hotels: list[RankedHotelCandidate],
  20. legs_by_hotel: dict[
  21. str,
  22. list[RouteLeg],
  23. ],
  24. subway_distance_by_hotel: dict[
  25. str,
  26. float | None,
  27. ],
  28. *,
  29. require_near_subway: bool,
  30. limit: int = 3,
  31. ) -> list[HotelRouteEvaluation]:
  32. """综合原酒店得分、景点距离和地铁距离。"""
  33. average_distances: list[float] = []
  34. average_distance_by_hotel: dict[
  35. str,
  36. float | None,
  37. ] = {}
  38. for hotel_candidate in hotels:
  39. hotel_id = hotel_candidate.hotel_id
  40. legs = legs_by_hotel.get(
  41. hotel_id,
  42. [],
  43. )
  44. distances = [
  45. leg.distance_meters
  46. for leg in legs
  47. ]
  48. average_distance = (
  49. mean(distances)
  50. if distances
  51. else None
  52. )
  53. average_distance_by_hotel[
  54. hotel_id
  55. ] = average_distance
  56. if average_distance is not None:
  57. average_distances.append(
  58. average_distance
  59. )
  60. results: list[HotelRouteEvaluation] = []
  61. for hotel_candidate in hotels:
  62. hotel_id = hotel_candidate.hotel_id
  63. hotel = hotel_candidate.hotel
  64. legs = legs_by_hotel.get(
  65. hotel_id,
  66. [],
  67. )
  68. distances = [
  69. leg.distance_meters
  70. for leg in legs
  71. ]
  72. durations = [
  73. leg.duration_seconds
  74. for leg in legs
  75. if leg.duration_seconds is not None
  76. ]
  77. average_distance = (
  78. mean(distances)
  79. if distances
  80. else None
  81. )
  82. maximum_distance = (
  83. max(distances)
  84. if distances
  85. else None
  86. )
  87. average_duration = (
  88. mean(durations)
  89. if durations
  90. else None
  91. )
  92. centrality_score = self._inverse_score(
  93. average_distance,
  94. average_distances,
  95. )
  96. subway_distance = (
  97. subway_distance_by_hotel.get(
  98. hotel_id
  99. )
  100. )
  101. subway_score = self._subway_score(
  102. subway_distance
  103. )
  104. base_score = hotel_candidate.score
  105. if require_near_subway:
  106. final_score = (
  107. base_score * 0.45
  108. + centrality_score * 0.40
  109. + subway_score * 0.15
  110. )
  111. else:
  112. final_score = (
  113. base_score * 0.55
  114. + centrality_score * 0.45
  115. )
  116. reasons = list(
  117. hotel_candidate.reasons
  118. )
  119. warnings = list(
  120. hotel_candidate.warnings
  121. )
  122. if (
  123. average_distance is not None
  124. and centrality_score >= 70
  125. ):
  126. reasons.append(
  127. "到主要景点的平均距离较短"
  128. )
  129. if (
  130. average_duration is not None
  131. and centrality_score >= 70
  132. ):
  133. reasons.append(
  134. "前往主要景点的预计耗时较低"
  135. )
  136. if require_near_subway:
  137. if (
  138. subway_distance is not None
  139. and subway_distance <= 800
  140. ):
  141. reasons.append(
  142. "周边800米内查询到地铁站"
  143. )
  144. elif subway_distance is None:
  145. warnings.append(
  146. "未获得可解析的地铁站距离"
  147. )
  148. else:
  149. warnings.append(
  150. "距离最近地铁站超过"
  151. f"{subway_distance:.0f}米"
  152. )
  153. if not legs:
  154. warnings.append(
  155. "酒店缺少有效路线距离结果"
  156. )
  157. hotel_name = str(
  158. hotel.get("name")
  159. or hotel_candidate.hotel_id
  160. )
  161. results.append(
  162. HotelRouteEvaluation(
  163. hotel_id=hotel_id,
  164. hotel_name=hotel_name,
  165. final_score=round(
  166. min(final_score, 100.0),
  167. 2,
  168. ),
  169. base_hotel_score=(
  170. hotel_candidate.score
  171. ),
  172. centrality_score=round(
  173. centrality_score,
  174. 2,
  175. ),
  176. subway_score=round(
  177. subway_score,
  178. 2,
  179. ),
  180. average_distance_meters=(
  181. round(average_distance, 2)
  182. if average_distance
  183. is not None
  184. else None
  185. ),
  186. maximum_distance_meters=(
  187. round(maximum_distance, 2)
  188. if maximum_distance
  189. is not None
  190. else None
  191. ),
  192. average_duration_seconds=(
  193. round(average_duration, 2)
  194. if average_duration
  195. is not None
  196. else None
  197. ),
  198. nearest_subway_distance_meters=(
  199. round(subway_distance, 2)
  200. if subway_distance
  201. is not None
  202. else None
  203. ),
  204. route_legs=legs,
  205. reasons=self._unique(reasons),
  206. warnings=self._unique(
  207. warnings
  208. ),
  209. hotel=hotel,
  210. )
  211. )
  212. results.sort(
  213. key=lambda item: (
  214. -item.final_score,
  215. (
  216. item.average_distance_meters
  217. if item.average_distance_meters
  218. is not None
  219. else float("inf")
  220. ),
  221. )
  222. )
  223. return results[:limit]
  224. def build_haversine_leg(
  225. self,
  226. *,
  227. origin_name: str,
  228. origin_location: str,
  229. destination_name: str,
  230. destination_location: str,
  231. ) -> RouteLeg:
  232. """高德距离查询失败时的直线距离降级。"""
  233. distance = self.haversine_distance_meters(
  234. origin_location,
  235. destination_location,
  236. )
  237. return RouteLeg(
  238. origin_name=origin_name,
  239. origin_location=origin_location,
  240. destination_name=destination_name,
  241. destination_location=(
  242. destination_location
  243. ),
  244. distance_meters=distance,
  245. duration_seconds=None,
  246. source="haversine_fallback",
  247. )
  248. @classmethod
  249. def haversine_distance_meters(
  250. cls,
  251. first_location: str,
  252. second_location: str,
  253. ) -> float:
  254. """根据两组经纬度计算球面直线距离。"""
  255. first_lon, first_lat = (
  256. cls.parse_location(first_location)
  257. )
  258. second_lon, second_lat = (
  259. cls.parse_location(second_location)
  260. )
  261. earth_radius = 6_371_000.0
  262. lat1 = math.radians(first_lat)
  263. lat2 = math.radians(second_lat)
  264. delta_lat = math.radians(
  265. second_lat - first_lat
  266. )
  267. delta_lon = math.radians(
  268. second_lon - first_lon
  269. )
  270. value = (
  271. math.sin(delta_lat / 2) ** 2
  272. + math.cos(lat1)
  273. * math.cos(lat2)
  274. * math.sin(delta_lon / 2) ** 2
  275. )
  276. central_angle = 2 * math.atan2(
  277. math.sqrt(value),
  278. math.sqrt(1 - value),
  279. )
  280. return round(
  281. earth_radius * central_angle,
  282. 2,
  283. )
  284. @staticmethod
  285. def parse_location(
  286. location: str,
  287. ) -> tuple[float, float]:
  288. """解析高德使用的 经度,纬度 格式。"""
  289. parts = [
  290. part.strip()
  291. for part in location.split(",")
  292. ]
  293. if len(parts) != 2:
  294. raise ValueError(
  295. "坐标必须使用“经度,纬度”格式:"
  296. f"{location}"
  297. )
  298. longitude = float(parts[0])
  299. latitude = float(parts[1])
  300. if not -180 <= longitude <= 180:
  301. raise ValueError(
  302. f"经度超出范围:{longitude}"
  303. )
  304. if not -90 <= latitude <= 90:
  305. raise ValueError(
  306. f"纬度超出范围:{latitude}"
  307. )
  308. return longitude, latitude
  309. @staticmethod
  310. def _inverse_score(
  311. value: float | None,
  312. values: Iterable[float],
  313. ) -> float:
  314. """将距离类数值反转为得分(越近得分越高)。"""
  315. if value is None:
  316. return 0.0
  317. valid_values = list(values)
  318. if not valid_values:
  319. return 0.0
  320. minimum = min(valid_values)
  321. maximum = max(valid_values)
  322. if maximum == minimum:
  323. return 100.0
  324. score = (
  325. maximum - value
  326. ) / (
  327. maximum - minimum
  328. ) * 100.0
  329. return max(
  330. 0.0,
  331. min(100.0, score),
  332. )
  333. @staticmethod
  334. def _subway_score(
  335. distance_meters: float | None,
  336. ) -> float:
  337. """根据酒店到最近地铁站的步行距离计算地铁便利得分。"""
  338. if distance_meters is None:
  339. return 0.0
  340. if distance_meters <= 500:
  341. return 100.0
  342. if distance_meters <= 800:
  343. return 85.0
  344. if distance_meters <= 1200:
  345. return 60.0
  346. if distance_meters <= 2000:
  347. return 30.0
  348. return 10.0
  349. @staticmethod
  350. def _unique(
  351. values: list[str],
  352. ) -> list[str]:
  353. """按元素值去重并保持首次出现顺序。"""
  354. result: list[str] = []
  355. for value in values:
  356. if value and value not in result:
  357. result.append(value)
  358. return result