itinerary.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. from __future__ import annotations
  2. """行程规划结果模型:ItineraryPlan多日行程、FlightSelection航班选择、HotelSelection酒店选择及每日活动。"""
  3. from datetime import date, time
  4. from typing import Literal
  5. from pydantic import Field, field_validator, model_validator
  6. from app.schemas.common import AppModel
  7. ActivityType = Literal[
  8. "flight",
  9. "hotel_check_in",
  10. "hotel_check_out",
  11. "attraction",
  12. "restaurant",
  13. "transport",
  14. "free_time",
  15. "other",
  16. ]
  17. TransportMode = Literal[
  18. "flight",
  19. "walking",
  20. "public_transit",
  21. "driving",
  22. "taxi",
  23. "unknown",
  24. ]
  25. class FlightSelection(AppModel):
  26. """规划角色选择的航班方案。
  27. 这里保存候选ID,后续Validator将检查这些ID
  28. 是否真实存在于候选结果中。
  29. """
  30. flight_type: Literal[
  31. "one_way",
  32. "round_trip",
  33. ]
  34. combination_id: str | None = None
  35. outbound_option_id: str
  36. return_option_id: str | None = None
  37. # 接口报价(不是去程+返程的加总)。
  38. quoted_price: float | None = Field(
  39. default=None,
  40. ge=0,
  41. )
  42. currency: str = "CNY"
  43. outbound_flight_numbers: list[str] = Field(
  44. default_factory=list
  45. )
  46. return_flight_numbers: list[str] = Field(
  47. default_factory=list
  48. )
  49. outbound_departure_time: str | None = None
  50. outbound_arrival_time: str | None = None
  51. return_departure_time: str | None = None
  52. return_arrival_time: str | None = None
  53. selection_reason: str
  54. @model_validator(mode="after")
  55. def validate_round_trip(
  56. self,
  57. ) -> "FlightSelection":
  58. if self.flight_type == "round_trip":
  59. if not self.combination_id:
  60. raise ValueError(
  61. "往返方案必须提供combination_id。"
  62. )
  63. if not self.return_option_id:
  64. raise ValueError(
  65. "往返方案必须提供return_option_id。"
  66. )
  67. return self
  68. class HotelSelection(AppModel):
  69. """规划角色选择的住宿方案。"""
  70. hotel_id: str
  71. name: str
  72. check_in_date: date
  73. check_out_date: date
  74. nights: int = Field(ge=1)
  75. price_per_night: float | None = Field(
  76. default=None,
  77. ge=0,
  78. )
  79. estimated_total_price: float | None = Field(
  80. default=None,
  81. ge=0,
  82. )
  83. currency: str = "CNY"
  84. address: str | None = None
  85. location: str | None = None
  86. selection_reason: str
  87. @field_validator("location", mode="before")
  88. @classmethod
  89. def _coerce_location_to_str(
  90. cls,
  91. value: object,
  92. ) -> object:
  93. """LLM 可能将 coordinates dict 误填到 location,
  94. 自动转换为 "longitude,latitude" 字符串。"""
  95. if isinstance(value, dict):
  96. lat = value.get("latitude")
  97. lng = value.get("longitude")
  98. if lat is not None and lng is not None:
  99. return f"{lng},{lat}"
  100. return value
  101. class ItineraryActivity(AppModel):
  102. """一天中的一个具体活动。"""
  103. sequence: int = Field(ge=1)
  104. activity_type: ActivityType
  105. # 景点或餐厅对应的真实POI ID。
  106. # 航班、入住等活动允许为空。
  107. reference_id: str | None = None
  108. name: str
  109. start_time: time
  110. end_time: time
  111. address: str | None = None
  112. location: str | None = None
  113. transport_mode: TransportMode | None = None
  114. estimated_transport_minutes: int | None = Field(
  115. default=None,
  116. ge=0,
  117. )
  118. estimated_cost: float | None = Field(
  119. default=None,
  120. ge=0,
  121. )
  122. notes: str | None = None
  123. class DailyItinerary(AppModel):
  124. """单日行程。"""
  125. day_index: int = Field(
  126. ge=1,
  127. le=30,
  128. )
  129. date: date
  130. theme: str
  131. activities: list[ItineraryActivity] = Field(
  132. min_length=1
  133. )
  134. daily_notes: list[str] = Field(
  135. default_factory=list
  136. )
  137. class ItineraryPlan(AppModel):
  138. """行程规划角色生成的完整结构化方案。"""
  139. title: str
  140. overview: str
  141. selected_flight: FlightSelection
  142. selected_hotel: HotelSelection
  143. days: list[DailyItinerary] = Field(
  144. min_length=1
  145. )
  146. highlights: list[str] = Field(
  147. default_factory=list
  148. )
  149. assumptions: list[str] = Field(
  150. default_factory=list
  151. )
  152. warnings: list[str] = Field(
  153. default_factory=list
  154. )
  155. @model_validator(mode="after")
  156. def validate_days(
  157. self,
  158. ) -> "ItineraryPlan":
  159. day_indexes = [
  160. item.day_index
  161. for item in self.days
  162. ]
  163. dates = [
  164. item.date
  165. for item in self.days
  166. ]
  167. if len(day_indexes) != len(set(day_indexes)):
  168. raise ValueError(
  169. "每日行程的day_index不能重复。"
  170. )
  171. if len(dates) != len(set(dates)):
  172. raise ValueError(
  173. "每日行程日期不能重复。"
  174. )
  175. return self