finalization_service.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. from __future__ import annotations
  2. """定稿渲染服务:将审核通过的行程方案渲染为面向最终用户的Markdown格式旅行方案文本。"""
  3. from app.schemas.itinerary import (
  4. ItineraryPlan,
  5. )
  6. from app.schemas.review import (
  7. TripReviewResult,
  8. )
  9. from app.schemas.travel_request import (
  10. TravelRequest,
  11. )
  12. from app.schemas.validation import (
  13. PlanValidationResult,
  14. )
  15. ACTIVITY_LABELS = {
  16. "flight": "航班",
  17. "hotel_check_in": "酒店入住",
  18. "hotel_check_out": "酒店退房",
  19. "attraction": "景点",
  20. "restaurant": "餐饮",
  21. "transport": "交通",
  22. "free_time": "自由活动",
  23. "other": "其他",
  24. }
  25. class FinalizationService:
  26. """将已审核的结构化方案渲染为最终Markdown。"""
  27. def render(
  28. self,
  29. *,
  30. request: TravelRequest,
  31. plan: ItineraryPlan,
  32. validation: PlanValidationResult,
  33. review: TripReviewResult,
  34. approved: bool,
  35. planning_attempts: int,
  36. supervisor_reason: str,
  37. ) -> str:
  38. """渲染最终旅行方案:根据请求、方案、校验结果和评审结论,分段生成需求摘要、航班、酒店、每日行程、预算与风险说明。"""
  39. lines: list[str] = []
  40. if approved:
  41. lines.append("# 个性化旅行方案")
  42. else:
  43. lines.append(
  44. "# 个性化旅行方案(存在未解决风险)"
  45. )
  46. lines.extend(
  47. [
  48. "",
  49. "> 当前方案已达到最大修改次数,"
  50. "但尚未完全通过校验或审查。"
  51. "请重点阅读文末风险说明。",
  52. ]
  53. )
  54. lines.extend(
  55. [
  56. "",
  57. f"## {plan.title}",
  58. "",
  59. plan.overview,
  60. "",
  61. "### 需求摘要",
  62. "",
  63. (
  64. f"- 行程:{request.origin_city}"
  65. f" → {request.destination_city}"
  66. ),
  67. (
  68. f"- 日期:{request.departure_date}"
  69. f" 至 {request.return_date}"
  70. ),
  71. (
  72. f"- 人数:{request.adults}位成人,"
  73. f"{request.children}位儿童"
  74. ),
  75. (
  76. f"- 节奏:{request.pace}"
  77. ),
  78. (
  79. "- 总预算:"
  80. + (
  81. f"{request.total_budget:.0f}"
  82. f" {request.currency}"
  83. if request.total_budget
  84. is not None
  85. else "未设置"
  86. )
  87. ),
  88. ]
  89. )
  90. self._append_flight(lines, plan)
  91. self._append_hotel(lines, plan)
  92. self._append_days(lines, plan)
  93. self._append_budget(
  94. lines,
  95. validation,
  96. )
  97. self._append_review(
  98. lines,
  99. review,
  100. )
  101. combined_warnings = self._collect_warnings(
  102. plan=plan,
  103. validation=validation,
  104. review=review,
  105. approved=approved,
  106. )
  107. if combined_warnings:
  108. lines.extend(
  109. [
  110. "",
  111. "### 风险与注意事项",
  112. "",
  113. ]
  114. )
  115. for warning in combined_warnings:
  116. lines.append(f"- {warning}")
  117. if plan.assumptions:
  118. lines.extend(
  119. [
  120. "",
  121. "### 方案假设",
  122. "",
  123. ]
  124. )
  125. for assumption in plan.assumptions:
  126. lines.append(f"- {assumption}")
  127. lines.extend(
  128. [
  129. "",
  130. "### 系统执行信息",
  131. "",
  132. (
  133. f"- Planner执行次数:"
  134. f"{planning_attempts}"
  135. ),
  136. (
  137. "- 最终状态:"
  138. + (
  139. "校验与审查通过"
  140. if approved
  141. else "达到最大修改次数后输出"
  142. )
  143. ),
  144. (
  145. f"- Supervisor说明:"
  146. f"{supervisor_reason}"
  147. ),
  148. ]
  149. )
  150. return "\n".join(lines)
  151. def _append_flight(
  152. self,
  153. lines: list[str],
  154. plan: ItineraryPlan,
  155. ) -> None:
  156. """向输出文本追加航班推荐段落。"""
  157. flight = plan.selected_flight
  158. outbound_numbers = "、".join(
  159. flight.outbound_flight_numbers
  160. ) or "未提供航班号"
  161. return_numbers = "、".join(
  162. flight.return_flight_numbers
  163. ) or "未提供航班号"
  164. lines.extend(
  165. [
  166. "",
  167. "### 推荐航班",
  168. "",
  169. (
  170. f"- 方案ID:"
  171. f"{flight.combination_id or flight.outbound_option_id}"
  172. ),
  173. (
  174. f"- 去程:{outbound_numbers}"
  175. ),
  176. (
  177. "- 去程时间:"
  178. f"{flight.outbound_departure_time or '未知'}"
  179. " → "
  180. f"{flight.outbound_arrival_time or '未知'}"
  181. ),
  182. ]
  183. )
  184. if flight.flight_type == "round_trip":
  185. lines.extend(
  186. [
  187. (
  188. f"- 返程:{return_numbers}"
  189. ),
  190. (
  191. "- 返程时间:"
  192. f"{flight.return_departure_time or '未知'}"
  193. " → "
  194. f"{flight.return_arrival_time or '未知'}"
  195. ),
  196. ]
  197. )
  198. lines.extend(
  199. [
  200. (
  201. "- 接口报价:"
  202. + (
  203. f"{flight.quoted_price:.0f}"
  204. f" {flight.currency}"
  205. if flight.quoted_price
  206. is not None
  207. else "未知"
  208. )
  209. ),
  210. (
  211. f"- 选择原因:"
  212. f"{flight.selection_reason}"
  213. ),
  214. ]
  215. )
  216. def _append_hotel(
  217. self,
  218. lines: list[str],
  219. plan: ItineraryPlan,
  220. ) -> None:
  221. """向输出文本追加酒店推荐段落。"""
  222. hotel = plan.selected_hotel
  223. lines.extend(
  224. [
  225. "",
  226. "### 推荐住宿",
  227. "",
  228. f"- 酒店:{hotel.name}",
  229. f"- 酒店ID:{hotel.hotel_id}",
  230. (
  231. f"- 入住:{hotel.check_in_date}"
  232. f" 至 {hotel.check_out_date}"
  233. f",共{hotel.nights}晚"
  234. ),
  235. (
  236. "- 每晚价格:"
  237. + (
  238. f"{hotel.price_per_night:.0f}"
  239. f" {hotel.currency}"
  240. if hotel.price_per_night
  241. is not None
  242. else "未知"
  243. )
  244. ),
  245. (
  246. "- 酒店估算:"
  247. + (
  248. f"{hotel.estimated_total_price:.0f}"
  249. f" {hotel.currency}"
  250. if hotel.estimated_total_price
  251. is not None
  252. else "未知"
  253. )
  254. ),
  255. (
  256. f"- 地址:"
  257. f"{hotel.address or '未提供'}"
  258. ),
  259. (
  260. f"- 选择原因:"
  261. f"{hotel.selection_reason}"
  262. ),
  263. ]
  264. )
  265. def _append_days(
  266. self,
  267. lines: list[str],
  268. plan: ItineraryPlan,
  269. ) -> None:
  270. """向输出文本追加每日行程段落。"""
  271. lines.extend(
  272. [
  273. "",
  274. "### 每日行程",
  275. ]
  276. )
  277. for day in sorted(
  278. plan.days,
  279. key=lambda item: item.day_index,
  280. ):
  281. lines.extend(
  282. [
  283. "",
  284. (
  285. f"#### 第{day.day_index}天|"
  286. f"{day.date}|{day.theme}"
  287. ),
  288. "",
  289. ]
  290. )
  291. activities = sorted(
  292. day.activities,
  293. key=lambda item: (
  294. item.start_time,
  295. item.sequence,
  296. ),
  297. )
  298. for activity in activities:
  299. activity_label = (
  300. ACTIVITY_LABELS.get(
  301. activity.activity_type,
  302. activity.activity_type,
  303. )
  304. )
  305. activity_line = (
  306. f"- {activity.start_time.strftime('%H:%M')}"
  307. f"–{activity.end_time.strftime('%H:%M')}"
  308. f"|{activity_label}|"
  309. f"{activity.name}"
  310. )
  311. lines.append(activity_line)
  312. details: list[str] = []
  313. if activity.address:
  314. details.append(
  315. f"地址:{activity.address}"
  316. )
  317. if (
  318. activity.transport_mode
  319. and activity.transport_mode
  320. != "unknown"
  321. ):
  322. details.append(
  323. "交通方式:"
  324. f"{activity.transport_mode}"
  325. )
  326. if (
  327. activity
  328. .estimated_transport_minutes
  329. is not None
  330. ):
  331. details.append(
  332. "预计交通:"
  333. f"{activity.estimated_transport_minutes}"
  334. "分钟"
  335. )
  336. if activity.notes:
  337. details.append(
  338. f"说明:{activity.notes}"
  339. )
  340. if details:
  341. lines.append(
  342. " - " + ";".join(details)
  343. )
  344. for note in day.daily_notes:
  345. lines.append(f"- 当日提示:{note}")
  346. def _append_budget(
  347. self,
  348. lines: list[str],
  349. validation: PlanValidationResult,
  350. ) -> None:
  351. """向输出文本追加预算估算段落。"""
  352. budget = validation.budget
  353. lines.extend(
  354. [
  355. "",
  356. "### 已知预算估算",
  357. "",
  358. (
  359. "- 航班接口报价:"
  360. + self._money(
  361. budget.flight_quote,
  362. budget.currency,
  363. )
  364. ),
  365. (
  366. "- 酒店估算:"
  367. + self._money(
  368. budget.hotel_estimate,
  369. budget.currency,
  370. )
  371. ),
  372. (
  373. "- 已明确的活动费用:"
  374. + self._money(
  375. budget.explicit_activity_costs,
  376. budget.currency,
  377. )
  378. ),
  379. (
  380. "- 当前已知费用合计:"
  381. + self._money(
  382. budget.known_total,
  383. budget.currency,
  384. )
  385. ),
  386. (
  387. "- 用户预算:"
  388. + self._money(
  389. budget.user_budget,
  390. budget.currency,
  391. )
  392. ),
  393. (
  394. "- 当前已知预算余额:"
  395. + self._money(
  396. budget.remaining_known_budget,
  397. budget.currency,
  398. )
  399. ),
  400. "",
  401. f"> {budget.coverage_note}",
  402. ]
  403. )
  404. def _append_review(
  405. self,
  406. lines: list[str],
  407. review: TripReviewResult,
  408. ) -> None:
  409. """向输出文本追加独立审查结论段落。"""
  410. lines.extend(
  411. [
  412. "",
  413. "### 独立审查结论",
  414. "",
  415. f"- {review.summary}",
  416. ]
  417. )
  418. if review.strengths:
  419. lines.append("- 方案优点:")
  420. for strength in review.strengths:
  421. lines.append(
  422. f" - {strength}"
  423. )
  424. if review.recommendations:
  425. lines.append("- 出行前建议:")
  426. for recommendation in (
  427. review.recommendations
  428. ):
  429. lines.append(
  430. f" - {recommendation}"
  431. )
  432. def _collect_warnings(
  433. self,
  434. *,
  435. plan: ItineraryPlan,
  436. validation: PlanValidationResult,
  437. review: TripReviewResult,
  438. approved: bool,
  439. ) -> list[str]:
  440. """汇总方案、校验和审查三个阶段的警告信息并去重。"""
  441. warnings: list[str] = []
  442. warnings.extend(plan.warnings)
  443. for issue in validation.issues:
  444. if (
  445. issue.severity == "warning"
  446. or not approved
  447. ):
  448. warnings.append(issue.message)
  449. for issue in review.issues:
  450. if (
  451. issue.severity == "warning"
  452. or not approved
  453. ):
  454. warnings.append(issue.message)
  455. if not approved:
  456. warnings.extend(
  457. review.revision_feedback
  458. )
  459. result: list[str] = []
  460. for warning in warnings:
  461. normalized = warning.strip()
  462. if (
  463. normalized
  464. and normalized not in result
  465. ):
  466. result.append(normalized)
  467. return result
  468. @staticmethod
  469. def _money(
  470. value: float | None,
  471. currency: str,
  472. ) -> str:
  473. """把金额(float|None)和货币代码格式化为"数值 货币"的中文展示文本。"""
  474. if value is None:
  475. return "未知"
  476. return f"{value:.0f} {currency}"