candidate_selection_service.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134
  1. from __future__ import annotations
  2. """候选筛选服务:对航班/酒店/往返组合做确定性规则筛选与加权评分排序,不调用大模型或外部接口。"""
  3. import hashlib
  4. from datetime import datetime, time
  5. from typing import Any, Iterable
  6. from app.schemas.selection import (
  7. RankedFlightCandidate,
  8. RankedHotelCandidate,
  9. RoundTripOption,
  10. )
  11. from app.schemas.travel_request import (
  12. TravelRequest,
  13. )
  14. class CandidateSelectionService:
  15. """使用确定性规则筛选和排序旅行候选。
  16. 该服务不调用大模型,也不调用外部接口。
  17. """
  18. def rank_flights(
  19. self,
  20. flights: list[dict[str, Any]],
  21. request: TravelRequest,
  22. *,
  23. limit: int = 10,
  24. reverse_direction: bool = False,
  25. apply_time_preferences: bool = True,
  26. ) -> tuple[
  27. list[RankedFlightCandidate],
  28. list[str],
  29. ]:
  30. """对单程方向的航班候选进行排序。"""
  31. valid_flights = [
  32. flight
  33. for flight in flights
  34. if isinstance(flight, dict)
  35. and flight.get("option_id")
  36. ]
  37. warnings: list[str] = []
  38. if not valid_flights:
  39. return [], ["没有有效航班候选。"]
  40. filtered_flights = self._filter_flights(
  41. valid_flights,
  42. request,
  43. )
  44. if not filtered_flights:
  45. filtered_flights = valid_flights
  46. warnings.append(
  47. "没有航班完全满足中转限制,"
  48. "已回退到全部候选并进行排序。"
  49. )
  50. prices = [
  51. price
  52. for flight in filtered_flights
  53. if (
  54. price := self._to_float(
  55. flight.get("price")
  56. )
  57. )
  58. is not None
  59. ]
  60. durations = [
  61. duration
  62. for flight in filtered_flights
  63. if (
  64. duration := self._to_float(
  65. flight.get(
  66. "duration_minutes"
  67. )
  68. )
  69. )
  70. is not None
  71. ]
  72. priority = (
  73. request.flight_preferences.priority
  74. )
  75. weights = self._flight_weights(priority)
  76. ranked: list[RankedFlightCandidate] = []
  77. for flight in filtered_flights:
  78. price = self._to_float(
  79. flight.get("price")
  80. )
  81. duration = self._to_float(
  82. flight.get("duration_minutes")
  83. )
  84. stop_count = self._to_int(
  85. flight.get("stop_count")
  86. )
  87. price_score = self._inverse_score(
  88. price,
  89. prices,
  90. )
  91. duration_score = self._inverse_score(
  92. duration,
  93. durations,
  94. )
  95. stop_score = self._stop_score(
  96. stop_count
  97. )
  98. airport_score = (
  99. self._airport_match_score(
  100. flight,
  101. request,
  102. reverse_direction=(
  103. reverse_direction
  104. ),
  105. )
  106. )
  107. time_score = 100.0
  108. candidate_warnings: list[str] = []
  109. if apply_time_preferences:
  110. (
  111. time_score,
  112. time_warnings,
  113. ) = self._time_match_score(
  114. flight,
  115. request,
  116. )
  117. candidate_warnings.extend(
  118. time_warnings
  119. )
  120. score = (
  121. price_score * weights["price"]
  122. + duration_score
  123. * weights["duration"]
  124. + stop_score
  125. * weights["stops"]
  126. + airport_score
  127. * weights["airport"]
  128. + time_score
  129. * weights["time"]
  130. )
  131. reasons = self._flight_reasons(
  132. flight=flight,
  133. price_score=price_score,
  134. duration_score=duration_score,
  135. airport_score=airport_score,
  136. time_score=time_score,
  137. apply_time_preferences=(
  138. apply_time_preferences
  139. ),
  140. )
  141. ranked.append(
  142. RankedFlightCandidate(
  143. option_id=str(
  144. flight["option_id"]
  145. ),
  146. score=round(score, 2),
  147. reasons=reasons,
  148. warnings=candidate_warnings,
  149. flight=flight,
  150. )
  151. )
  152. ranked.sort(
  153. key=lambda candidate: (
  154. -candidate.score,
  155. self._to_float(
  156. candidate.flight.get(
  157. "price"
  158. )
  159. )
  160. or float("inf"),
  161. )
  162. )
  163. return ranked[:limit], warnings
  164. def rank_hotels(
  165. self,
  166. hotels: list[dict[str, Any]],
  167. request: TravelRequest,
  168. *,
  169. limit: int = 10,
  170. ) -> tuple[
  171. list[RankedHotelCandidate],
  172. list[str],
  173. ]:
  174. """根据酒店预算、评分和客观属性排序。"""
  175. valid_hotels = [
  176. hotel
  177. for hotel in hotels
  178. if isinstance(hotel, dict)
  179. and hotel.get("hotel_id")
  180. ]
  181. warnings: list[str] = []
  182. if not valid_hotels:
  183. return [], ["没有有效酒店候选。"]
  184. filtered_hotels = self._filter_hotels(
  185. valid_hotels,
  186. request,
  187. )
  188. if not filtered_hotels:
  189. filtered_hotels = valid_hotels
  190. warnings.append(
  191. "没有酒店同时满足全部预算、"
  192. "评分或星级限制,"
  193. "已回退到全部候选。"
  194. )
  195. prices = [
  196. price
  197. for hotel in filtered_hotels
  198. if (
  199. price := self._to_float(
  200. hotel.get("price_per_night")
  201. )
  202. )
  203. is not None
  204. ]
  205. ranked: list[RankedHotelCandidate] = []
  206. hotel_preferences = (
  207. request.hotel_preferences
  208. )
  209. for hotel in filtered_hotels:
  210. price = self._to_float(
  211. hotel.get("price_per_night")
  212. )
  213. rating = self._to_float(
  214. hotel.get("overall_rating")
  215. )
  216. location_rating = self._to_float(
  217. hotel.get("location_rating")
  218. )
  219. price_score = self._inverse_score(
  220. price,
  221. prices,
  222. )
  223. rating_score = self._rating_score(
  224. rating
  225. )
  226. location_score = self._rating_score(
  227. location_rating
  228. )
  229. cancellation_bonus = (
  230. 5.0
  231. if hotel.get(
  232. "free_cancellation"
  233. )
  234. is True
  235. else 0.0
  236. )
  237. score = (
  238. price_score * 0.45
  239. + rating_score * 0.35
  240. + location_score * 0.20
  241. + cancellation_bonus
  242. )
  243. score = min(score, 100.0)
  244. reasons: list[str] = []
  245. candidate_warnings: list[str] = []
  246. if price_score >= 70:
  247. reasons.append(
  248. "每晚价格在候选中相对较低"
  249. )
  250. if rating is not None and rating >= 4.5:
  251. reasons.append(
  252. f"用户评分较高({rating:.1f})"
  253. )
  254. if (
  255. location_rating is not None
  256. and location_rating >= 4.0
  257. ):
  258. reasons.append(
  259. "位置评分较高"
  260. )
  261. if hotel.get("free_cancellation"):
  262. reasons.append(
  263. "支持免费取消"
  264. )
  265. if hotel.get("deal"):
  266. reasons.append(
  267. "接口返回优惠信息"
  268. )
  269. requested_amenities = {
  270. item.strip().lower()
  271. for item in hotel_preferences.amenities
  272. if item.strip()
  273. }
  274. available_amenities = {
  275. str(item).strip().lower()
  276. for item in hotel.get(
  277. "amenities",
  278. []
  279. )
  280. if str(item).strip()
  281. }
  282. if requested_amenities:
  283. matched = (
  284. requested_amenities
  285. & available_amenities
  286. )
  287. if matched:
  288. reasons.append(
  289. "匹配部分指定设施"
  290. )
  291. missing = (
  292. requested_amenities
  293. - available_amenities
  294. )
  295. if missing:
  296. candidate_warnings.append(
  297. "未确认全部指定设施:"
  298. + "、".join(sorted(missing))
  299. )
  300. if not reasons:
  301. reasons.append(
  302. "综合价格、评分和位置排序"
  303. )
  304. ranked.append(
  305. RankedHotelCandidate(
  306. hotel_id=str(
  307. hotel["hotel_id"]
  308. ),
  309. score=round(score, 2),
  310. reasons=reasons,
  311. warnings=candidate_warnings,
  312. hotel=hotel,
  313. )
  314. )
  315. ranked.sort(
  316. key=lambda candidate: (
  317. -candidate.score,
  318. self._to_float(
  319. candidate.hotel.get(
  320. "price_per_night"
  321. )
  322. )
  323. or float("inf"),
  324. )
  325. )
  326. if hotel_preferences.near_subway:
  327. warnings.append(
  328. "酒店靠近地铁的要求尚未完成"
  329. "真实路线校验,将在高德路线阶段处理。"
  330. )
  331. if hotel_preferences.preferred_areas:
  332. warnings.append(
  333. "酒店商圈偏好将在地图距离阶段"
  334. "进一步验证。"
  335. )
  336. return ranked[:limit], warnings
  337. def rank_round_trip_combinations(
  338. self,
  339. combinations: list[
  340. dict[str, dict[str, Any]]
  341. ],
  342. request: TravelRequest,
  343. *,
  344. limit: int = 10,
  345. ) -> list[RoundTripOption]:
  346. """对完整往返组合进行统一排序。"""
  347. valid_combinations = [
  348. combination
  349. for combination in combinations
  350. if isinstance(
  351. combination.get("outbound"),
  352. dict,
  353. )
  354. and isinstance(
  355. combination.get(
  356. "return_flight"
  357. ),
  358. dict,
  359. )
  360. ]
  361. prices: list[float] = []
  362. durations: list[float] = []
  363. for combination in valid_combinations:
  364. outbound = combination["outbound"]
  365. return_flight = combination[
  366. "return_flight"
  367. ]
  368. # 返程查询中的price通常代表选择该去程后
  369. # 得到的完整接口报价,因此不与去程相加。
  370. price = self._to_float(
  371. return_flight.get("price")
  372. )
  373. if price is None:
  374. price = self._to_float(
  375. outbound.get("price")
  376. )
  377. if price is not None:
  378. prices.append(price)
  379. total_duration = (
  380. (
  381. self._to_float(
  382. outbound.get(
  383. "duration_minutes"
  384. )
  385. )
  386. or 0.0
  387. )
  388. + (
  389. self._to_float(
  390. return_flight.get(
  391. "duration_minutes"
  392. )
  393. )
  394. or 0.0
  395. )
  396. )
  397. durations.append(total_duration)
  398. weights = self._flight_weights(
  399. request.flight_preferences.priority
  400. )
  401. results: list[RoundTripOption] = []
  402. seen_ids: set[str] = set()
  403. for combination in valid_combinations:
  404. outbound = combination["outbound"]
  405. return_flight = combination[
  406. "return_flight"
  407. ]
  408. outbound_id = str(
  409. outbound.get("option_id", "")
  410. )
  411. return_id = str(
  412. return_flight.get(
  413. "option_id",
  414. "",
  415. )
  416. )
  417. combination_id = self._stable_id(
  418. outbound_id,
  419. return_id,
  420. )
  421. if combination_id in seen_ids:
  422. continue
  423. seen_ids.add(combination_id)
  424. quoted_price = self._to_float(
  425. return_flight.get("price")
  426. )
  427. if quoted_price is None:
  428. quoted_price = self._to_float(
  429. outbound.get("price")
  430. )
  431. outbound_duration = (
  432. self._to_float(
  433. outbound.get(
  434. "duration_minutes"
  435. )
  436. )
  437. or 0.0
  438. )
  439. return_duration = (
  440. self._to_float(
  441. return_flight.get(
  442. "duration_minutes"
  443. )
  444. )
  445. or 0.0
  446. )
  447. total_duration = (
  448. outbound_duration
  449. + return_duration
  450. )
  451. outbound_stops = (
  452. self._to_int(
  453. outbound.get("stop_count")
  454. )
  455. or 0
  456. )
  457. return_stops = (
  458. self._to_int(
  459. return_flight.get(
  460. "stop_count"
  461. )
  462. )
  463. or 0
  464. )
  465. total_stops = (
  466. outbound_stops
  467. + return_stops
  468. )
  469. price_score = self._inverse_score(
  470. quoted_price,
  471. prices,
  472. )
  473. duration_score = (
  474. self._inverse_score(
  475. total_duration,
  476. durations,
  477. )
  478. )
  479. stop_score = max(
  480. 0.0,
  481. 100.0 - total_stops * 25.0,
  482. )
  483. outbound_airport_score = (
  484. self._airport_match_score(
  485. outbound,
  486. request,
  487. reverse_direction=False,
  488. )
  489. )
  490. return_airport_score = (
  491. self._airport_match_score(
  492. return_flight,
  493. request,
  494. reverse_direction=True,
  495. )
  496. )
  497. airport_score = (
  498. outbound_airport_score
  499. + return_airport_score
  500. ) / 2.0
  501. # 往返组合中不再次使用去程时间偏好
  502. # 评价返程,因为当前模型没有独立返程时间字段。
  503. score = (
  504. price_score * weights["price"]
  505. + duration_score
  506. * weights["duration"]
  507. + stop_score
  508. * weights["stops"]
  509. + airport_score
  510. * (
  511. weights["airport"]
  512. + weights["time"]
  513. )
  514. )
  515. reasons: list[str] = []
  516. if price_score >= 70:
  517. reasons.append(
  518. "往返接口报价在组合中相对较低"
  519. )
  520. if total_stops == 0:
  521. reasons.append(
  522. "去程和返程均为直飞"
  523. )
  524. elif total_stops <= 2:
  525. reasons.append(
  526. "往返中转次数较少"
  527. )
  528. if duration_score >= 70:
  529. reasons.append(
  530. "往返总飞行时长相对较短"
  531. )
  532. if airport_score >= 90:
  533. reasons.append(
  534. "匹配用户机场偏好"
  535. )
  536. if not reasons:
  537. reasons.append(
  538. "综合价格、时长和中转次数排序"
  539. )
  540. currency = str(
  541. return_flight.get("currency")
  542. or outbound.get("currency")
  543. or request.currency
  544. )
  545. results.append(
  546. RoundTripOption(
  547. combination_id=combination_id,
  548. score=round(score, 2),
  549. quoted_price=quoted_price,
  550. currency=currency,
  551. outbound=outbound,
  552. return_flight=return_flight,
  553. reasons=reasons,
  554. warnings=[
  555. "组合价格采用返程查询返回的"
  556. "接口报价,未将两个方向报价相加。"
  557. ],
  558. )
  559. )
  560. results.sort(
  561. key=lambda option: (
  562. -option.score,
  563. option.quoted_price
  564. if option.quoted_price is not None
  565. else float("inf"),
  566. )
  567. )
  568. return results[:limit]
  569. def _filter_flights(
  570. self,
  571. flights: list[dict[str, Any]],
  572. request: TravelRequest,
  573. ) -> list[dict[str, Any]]:
  574. """按中转次数上限过滤航班列表。"""
  575. max_stops = (
  576. request.flight_preferences.max_stops
  577. )
  578. if max_stops is None:
  579. return flights
  580. return [
  581. flight
  582. for flight in flights
  583. if (
  584. self._to_int(
  585. flight.get("stop_count")
  586. )
  587. is not None
  588. and self._to_int(
  589. flight.get("stop_count")
  590. )
  591. <= max_stops
  592. )
  593. ]
  594. def _filter_hotels(
  595. self,
  596. hotels: list[dict[str, Any]],
  597. request: TravelRequest,
  598. ) -> list[dict[str, Any]]:
  599. """按价格上限、最低评分、星级过滤酒店列表。"""
  600. preferences = request.hotel_preferences
  601. result: list[dict[str, Any]] = []
  602. for hotel in hotels:
  603. price = self._to_float(
  604. hotel.get("price_per_night")
  605. )
  606. rating = self._to_float(
  607. hotel.get("overall_rating")
  608. )
  609. hotel_class = self._to_float(
  610. hotel.get("hotel_class")
  611. )
  612. if (
  613. preferences.max_price_per_night
  614. is not None
  615. and (
  616. price is None
  617. or price
  618. > preferences.max_price_per_night
  619. )
  620. ):
  621. continue
  622. if (
  623. preferences.minimum_rating
  624. is not None
  625. and (
  626. rating is None
  627. or rating
  628. < preferences.minimum_rating
  629. )
  630. ):
  631. continue
  632. if preferences.hotel_classes:
  633. allowed_classes = {
  634. float(value)
  635. for value
  636. in preferences.hotel_classes
  637. }
  638. if (
  639. hotel_class is None
  640. or hotel_class
  641. not in allowed_classes
  642. ):
  643. continue
  644. result.append(hotel)
  645. return result
  646. def _flight_weights(
  647. self,
  648. priority: str,
  649. ) -> dict[str, float]:
  650. """根据用户偏好优先级返回各评分维度的权重字典。"""
  651. if priority == "price":
  652. return {
  653. "price": 0.60,
  654. "duration": 0.15,
  655. "stops": 0.15,
  656. "airport": 0.05,
  657. "time": 0.05,
  658. }
  659. if priority == "convenience":
  660. return {
  661. "price": 0.15,
  662. "duration": 0.30,
  663. "stops": 0.25,
  664. "airport": 0.15,
  665. "time": 0.15,
  666. }
  667. return {
  668. "price": 0.35,
  669. "duration": 0.25,
  670. "stops": 0.20,
  671. "airport": 0.10,
  672. "time": 0.10,
  673. }
  674. def _flight_reasons(
  675. self,
  676. *,
  677. flight: dict[str, Any],
  678. price_score: float,
  679. duration_score: float,
  680. airport_score: float,
  681. time_score: float,
  682. apply_time_preferences: bool,
  683. ) -> list[str]:
  684. """根据各维度得分生成中文排序理由片段列表。"""
  685. reasons: list[str] = []
  686. stop_count = (
  687. self._to_int(
  688. flight.get("stop_count")
  689. )
  690. or 0
  691. )
  692. if stop_count == 0:
  693. reasons.append("直飞")
  694. elif stop_count == 1:
  695. reasons.append("仅中转一次")
  696. if price_score >= 70:
  697. reasons.append(
  698. "报价在候选中相对较低"
  699. )
  700. if duration_score >= 70:
  701. reasons.append(
  702. "飞行时长相对较短"
  703. )
  704. if airport_score >= 90:
  705. reasons.append(
  706. "匹配用户机场偏好"
  707. )
  708. if (
  709. apply_time_preferences
  710. and time_score >= 90
  711. ):
  712. reasons.append(
  713. "起降时间符合用户偏好"
  714. )
  715. if not reasons:
  716. reasons.append(
  717. "综合价格、时长和中转次数排序"
  718. )
  719. return reasons
  720. def _airport_match_score(
  721. self,
  722. flight: dict[str, Any],
  723. request: TravelRequest,
  724. *,
  725. reverse_direction: bool,
  726. ) -> float:
  727. """计算机场偏好匹配得分:命中偏好机场满分,否则低分。"""
  728. preferences = request.flight_preferences
  729. if reverse_direction:
  730. preferred_departure = (
  731. preferences
  732. .preferred_arrival_airports
  733. )
  734. preferred_arrival = (
  735. preferences
  736. .preferred_departure_airports
  737. )
  738. else:
  739. preferred_departure = (
  740. preferences
  741. .preferred_departure_airports
  742. )
  743. preferred_arrival = (
  744. preferences
  745. .preferred_arrival_airports
  746. )
  747. scores: list[float] = []
  748. departure_airport = str(
  749. flight.get("departure_airport", "")
  750. ).upper()
  751. arrival_airport = str(
  752. flight.get("arrival_airport", "")
  753. ).upper()
  754. if preferred_departure:
  755. scores.append(
  756. 100.0
  757. if departure_airport
  758. in preferred_departure
  759. else 20.0
  760. )
  761. if preferred_arrival:
  762. scores.append(
  763. 100.0
  764. if arrival_airport
  765. in preferred_arrival
  766. else 20.0
  767. )
  768. if not scores:
  769. return 100.0
  770. return sum(scores) / len(scores)
  771. def _time_match_score(
  772. self,
  773. flight: dict[str, Any],
  774. request: TravelRequest,
  775. ) -> tuple[float, list[str]]:
  776. """计算出发/到达时间窗匹配得分,返回得分与警告列表。"""
  777. preferences = request.flight_preferences
  778. departure_datetime = (
  779. self._parse_datetime(
  780. flight.get("departure_time")
  781. )
  782. )
  783. arrival_datetime = (
  784. self._parse_datetime(
  785. flight.get("arrival_time")
  786. )
  787. )
  788. scores: list[float] = []
  789. warnings: list[str] = []
  790. if (
  791. preferences.earliest_departure_time
  792. is not None
  793. or preferences.latest_departure_time
  794. is not None
  795. ):
  796. if departure_datetime is None:
  797. scores.append(0.0)
  798. warnings.append(
  799. "无法解析航班起飞时间。"
  800. )
  801. elif self._within_time_window(
  802. departure_datetime.time(),
  803. preferences
  804. .earliest_departure_time,
  805. preferences
  806. .latest_departure_time,
  807. ):
  808. scores.append(100.0)
  809. else:
  810. scores.append(20.0)
  811. warnings.append(
  812. "起飞时间不在用户偏好范围内。"
  813. )
  814. if (
  815. preferences.earliest_arrival_time
  816. is not None
  817. or preferences.latest_arrival_time
  818. is not None
  819. ):
  820. if arrival_datetime is None:
  821. scores.append(0.0)
  822. warnings.append(
  823. "无法解析航班到达时间。"
  824. )
  825. elif self._within_time_window(
  826. arrival_datetime.time(),
  827. preferences
  828. .earliest_arrival_time,
  829. preferences
  830. .latest_arrival_time,
  831. ):
  832. scores.append(100.0)
  833. else:
  834. scores.append(20.0)
  835. warnings.append(
  836. "到达时间不在用户偏好范围内。"
  837. )
  838. if not scores:
  839. return 100.0, warnings
  840. return sum(scores) / len(scores), warnings
  841. @staticmethod
  842. def _within_time_window(
  843. value: time,
  844. earliest: time | None,
  845. latest: time | None,
  846. ) -> bool:
  847. """判断给定时间是否落在可选的起止时间窗口内。"""
  848. if earliest is not None and value < earliest:
  849. return False
  850. if latest is not None and value > latest:
  851. return False
  852. return True
  853. @staticmethod
  854. def _stop_score(
  855. stop_count: int | None,
  856. ) -> float:
  857. """经停次数得分:0经停=100分,每多1次减40分。"""
  858. if stop_count is None:
  859. return 0.0
  860. return max(
  861. 0.0,
  862. 100.0 - stop_count * 40.0,
  863. )
  864. @staticmethod
  865. def _rating_score(
  866. value: float | None,
  867. ) -> float:
  868. """将5分制评分/星级线性映射为百分制得分。"""
  869. if value is None:
  870. return 0.0
  871. return max(
  872. 0.0,
  873. min(100.0, value / 5.0 * 100.0),
  874. )
  875. @staticmethod
  876. def _inverse_score(
  877. value: float | None,
  878. values: Iterable[float],
  879. ) -> float:
  880. """将成本类数值反转为得分(值越小得分越高),在候选中做min-max归一化。"""
  881. if value is None:
  882. return 0.0
  883. valid_values = list(values)
  884. if not valid_values:
  885. return 0.0
  886. minimum = min(valid_values)
  887. maximum = max(valid_values)
  888. if maximum == minimum:
  889. return 100.0
  890. score = (
  891. maximum - value
  892. ) / (
  893. maximum - minimum
  894. ) * 100.0
  895. return max(0.0, min(100.0, score))
  896. @staticmethod
  897. def _parse_datetime(
  898. value: object,
  899. ) -> datetime | None:
  900. """安全解析ISO格式时间字符串,解析失败返回None。"""
  901. if value is None:
  902. return None
  903. try:
  904. return datetime.fromisoformat(
  905. str(value)
  906. )
  907. except ValueError:
  908. return None
  909. @staticmethod
  910. def _to_float(
  911. value: object,
  912. ) -> float | None:
  913. """安全转换为float,bool或不可转换值返回None。"""
  914. if value is None:
  915. return None
  916. if isinstance(value, bool):
  917. return None
  918. try:
  919. return float(value)
  920. except (TypeError, ValueError):
  921. return None
  922. @staticmethod
  923. def _to_int(
  924. value: object,
  925. ) -> int | None:
  926. """安全转换为int,bool或不可转换值返回None。"""
  927. if value is None:
  928. return None
  929. if isinstance(value, bool):
  930. return None
  931. try:
  932. return int(value)
  933. except (TypeError, ValueError):
  934. return None
  935. @staticmethod
  936. def _stable_id(
  937. *parts: str,
  938. ) -> str:
  939. """基于不定个字符串片段生成稳定的SHA256派生ID。"""
  940. raw_value = "|".join(parts)
  941. digest = hashlib.sha256(
  942. raw_value.encode("utf-8")
  943. ).hexdigest()
  944. return f"round-{digest[:16]}"