| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494 |
- from __future__ import annotations
- """行程规划Agent:基于候选资源(航班/酒店/景点)与地图研究结果,生成包含每日活动安排的结构化多日行程。"""
- import json
- from typing import Any
- from langchain_core.language_models.chat_models import (
- BaseChatModel,
- )
- from langchain_core.messages import (
- HumanMessage,
- SystemMessage,
- )
- from app.llm import get_chat_model
- from app.schemas.itinerary import ItineraryPlan
- from app.schemas.place import MapResearchResult
- from app.schemas.route import (
- RouteEvaluationResult,
- )
- from app.schemas.selection import (
- CandidateSelectionResult,
- )
- from app.schemas.travel_request import (
- TravelRequest,
- )
- # ITINERARY_PLANNER_PROMPT:行程规划的系统提示词,定义每日行程的JSON输出结构与修改要求。
- ITINERARY_PLANNER_PROMPT = """
- 你是多角色旅行规划系统中的行程规划专家。
- 上游角色已经完成:
- 1. Requirement Analyst:解析用户需求。
- 2. Resource Search Agent:查询真实航班和酒店。
- 3. Destination Research Agent:查询真实景点、
- 餐厅和天气。
- 4. Candidate Selector:筛选航班和酒店。
- 5. Route Evaluator:评估酒店到景点的距离。
- 你的职责是基于上游结果,选择一个航班方案、
- 一家酒店,并生成逐日旅行安排。
- 严格规则:
- 一、真实性
- 1. 只能选择输入候选中存在的航班、酒店、
- 景点和餐厅。
- 2. 不得编造候选中不存在的名称、ID、地址、
- 坐标、航班号或价格。
- 3. 景点和餐厅的reference_id使用输入中的poi_id。
- 4. 如果poi_id为空,可以保留reference_id=null,
- 但名称必须和输入候选完全一致。
- 5. 不确定的信息放入assumptions或warnings,
- 不得伪装为确定事实。
- 二、航班
- 1. 往返旅行必须选择一个完整round_trip_options。
- 2. combination_id、outbound_option_id和
- return_option_id必须复制候选中的真实ID。
- 3. quoted_price采用候选中的接口报价。
- 4. 不得把去程和返程价格再次相加。
- 5. 到达日需要考虑航班到达时间。
- 6. 返程日必须为前往机场预留合理时间。
- 三、酒店
- 1. 优先使用Route Evaluator排名第一的酒店。
- 2. 如选择其他酒店,必须给出明确原因。
- 3. check_in_date必须等于用户的departure_date
- (不是航班实际到达日期)。
- 4. check_out_date必须等于用户的return_date。
- 5. nights必须等于
- (check_out_date - check_in_date)的天数。
- 例如:8月10日入住、8月14日退房
- → nights = 4。
- 6. price_per_night从酒店候选复制。
- 7. estimated_total_price = price_per_night × nights;
- 该值后续还会由Validator重新计算。
- 四、每日行程
- 1. 必须覆盖旅行期间的每一个自然日。
- 2. 日期必须连续。
- 3. 到达日和返程日不能安排过满。
- 4. 同一个景点不得重复安排。
- 5. 单个活动的结束时间必须晚于开始时间。
- 6. 景点尽量按照位置和主题组合。
- 7. 仅可使用输入中存在的餐厅。
- 8. 活动名称必须与候选名称完全一致,
- 不得添加"早餐:"、"午餐:"、"晚餐:"等前缀。
- 9. 不需要强行为每一餐指定餐厅。
- 10. 未查询到真实交通时间时,
- 不得编造精确交通时间。
- 五、节奏
- - relaxed:
- 每天原则上不超过2个主要景点,
- 保留休息和自由活动时间。
- - normal:
- 每天原则上2至3个主要景点。
- - intensive:
- 每天可安排3至4个主要景点,
- 但仍需考虑交通和开放时间风险。
- 六、天气
- 1. 有真实天气数据时,可以据此安排室内外活动。
- 2. 没有天气数据时,不得编造天气。
- 3. 在warnings中提示用户出发前再次确认天气。
- 七、费用
- 1. 只能使用已有的航班和酒店报价。
- 2. 未查询门票或餐饮价格时,
- estimated_cost应为null。
- 3. 总预算是否超支由后续Validator负责。
- 八、输出格式(必须严格遵守字段名)
- 你必须返回一个 JSON 对象,顶层字段如下:
- {
- "title": "行程标题(字符串)",
- "overview": "行程概览(字符串)",
- "selected_flight": {
- "flight_type": "round_trip 或 one_way",
- "combination_id": "来自 round_trip_options 的组合ID(仅往返)",
- "outbound_option_id": "去程航班ID(必填)",
- "return_option_id": "返程航班ID(往返必填,单程为null)",
- "quoted_price": 接口报价数字,
- "currency": "CNY",
- "outbound_flight_numbers": ["MU001"],
- "return_flight_numbers": ["MU002"],
- "outbound_departure_time": "去程出发时间字符串",
- "outbound_arrival_time": "去程到达时间字符串",
- "return_departure_time": "返程出发时间字符串",
- "return_arrival_time": "返程到达时间字符串",
- "selection_reason": "选择理由"
- },
- "selected_hotel": {
- "hotel_id": "来自候选的酒店ID",
- "name": "酒店名称",
- "check_in_date": "入住日期 YYYY-MM-DD(必须等于 departure_date)",
- "check_out_date": "退房日期 YYYY-MM-DD(必须等于 return_date)",
- "nights": "check_out_date - check_in_date 的天数,如 8/10→8/14 为 4",
- "price_per_night": 每晚价格,
- "estimated_total_price": price_per_night × nights,
- "currency": "CNY",
- "address": "地址文本(可选)",
- "location": "经纬度字符串如 \"104.07,30.66\"(可选,不是对象)",
- "selection_reason": "选择理由"
- },
- "days": [
- {
- "day_index": 从1开始的整数,
- "date": "YYYY-MM-DD",
- "theme": "当日主题",
- "activities": [
- {
- "sequence": 从1开始递增的整数,
- "activity_type": "flight|hotel_check_in|hotel_check_out|attraction|restaurant|transport|free_time|other",
- "reference_id": "景点或餐厅的poi_id(航班/入住等为null)",
- "name": "活动名称",
- "start_time": "HH:MM",
- "end_time": "HH:MM",
- "address": "地址(可选)",
- "location": "经纬度(可选)",
- "transport_mode": "walking|public_transit|driving|taxi|unknown(可选)",
- "estimated_transport_minutes": 预估交通分钟(可选),
- "estimated_cost": 预估费用(可选),
- "notes": "备注(可选)"
- }
- ],
- "daily_notes": ["当日备注(可选)"]
- }
- ],
- "highlights": ["行程亮点"],
- "assumptions": ["不确定的假设"],
- "warnings": ["需要用户注意的点"]
- }
- 注意:
- - 不要在最外层包裹 {"itineraryPlan": {...}},直接输出上述 JSON。
- - 所有日期格式为 YYYY-MM-DD,时间格式为 HH:MM。
- - 必须从输入候选中复制真实数据,不得编造。
- """
- def _compact_flight(
- flight: dict[str, Any],
- ) -> dict[str, Any]:
- """保留Planner真正需要的航班字段。"""
- return {
- "option_id": flight.get("option_id"),
- "flight_numbers": flight.get(
- "flight_numbers",
- [],
- ),
- "airlines": flight.get("airlines", []),
- "departure_airport": flight.get(
- "departure_airport"
- ),
- "arrival_airport": flight.get(
- "arrival_airport"
- ),
- "departure_time": flight.get(
- "departure_time"
- ),
- "arrival_time": flight.get(
- "arrival_time"
- ),
- "duration_minutes": flight.get(
- "duration_minutes"
- ),
- "stop_count": flight.get("stop_count"),
- "price": flight.get("price"),
- "currency": flight.get("currency"),
- }
- def build_planner_context(
- request: TravelRequest,
- selection: CandidateSelectionResult,
- map_result: MapResearchResult,
- route_result: RouteEvaluationResult,
- revision_feedback: list[str] | None = None,
- ) -> dict[str, Any]:
- """把共享State压缩为Planner需要的上下文。
- 避免将几十条原始航班和酒店全部放入Prompt。
- """
- round_trip_options: list[
- dict[str, Any]
- ] = []
- for option in selection.round_trip_options[:5]:
- round_trip_options.append(
- {
- "combination_id": (
- option.combination_id
- ),
- "score": option.score,
- "quoted_price": option.quoted_price,
- "currency": option.currency,
- "outbound": _compact_flight(
- option.outbound
- ),
- "return_flight": _compact_flight(
- option.return_flight
- ),
- "reasons": option.reasons,
- "warnings": option.warnings,
- }
- )
- one_way_options: list[
- dict[str, Any]
- ] = []
- for candidate in selection.one_way_options[:5]:
- one_way_options.append(
- {
- "score": candidate.score,
- "flight": _compact_flight(
- candidate.flight
- ),
- "reasons": candidate.reasons,
- "warnings": candidate.warnings,
- }
- )
- evaluated_hotels: list[
- dict[str, Any]
- ] = []
- for evaluated in (
- route_result.evaluated_hotels[:3]
- ):
- hotel = evaluated.hotel
- evaluated_hotels.append(
- {
- "hotel_id": evaluated.hotel_id,
- "name": evaluated.hotel_name,
- "final_score": (
- evaluated.final_score
- ),
- "base_hotel_score": (
- evaluated.base_hotel_score
- ),
- "average_distance_meters": (
- evaluated
- .average_distance_meters
- ),
- "average_duration_seconds": (
- evaluated
- .average_duration_seconds
- ),
- "nearest_subway_distance_meters": (
- evaluated
- .nearest_subway_distance_meters
- ),
- "price_per_night": hotel.get(
- "price_per_night"
- ),
- "total_price": hotel.get(
- "total_price"
- ),
- "currency": hotel.get("currency"),
- "coordinates": hotel.get(
- "coordinates"
- ),
- "reasons": evaluated.reasons,
- "warnings": evaluated.warnings,
- "route_legs": [
- {
- "destination_name": (
- leg.destination_name
- ),
- "distance_meters": (
- leg.distance_meters
- ),
- "duration_seconds": (
- leg.duration_seconds
- ),
- "source": leg.source,
- }
- for leg in evaluated.route_legs
- ],
- }
- )
- attractions = [
- place.model_dump(mode="json")
- for place in map_result.attractions[:10]
- ]
- restaurants = [
- place.model_dump(mode="json")
- for place in map_result.restaurants[:6]
- ]
- weather = [
- item.model_dump(mode="json")
- for item in map_result.weather
- ]
- return {
- "travel_request": request.model_dump(
- mode="json"
- ),
- "flight_candidates": {
- "flight_type": selection.flight_type,
- "round_trip_options": (
- round_trip_options
- ),
- "one_way_options": one_way_options,
- "warnings": selection.warnings,
- },
- "evaluated_hotels": evaluated_hotels,
- "selected_hotel_id_from_route_evaluator": (
- route_result.selected_hotel_id
- ),
- "attractions": attractions,
- "restaurants": restaurants,
- "weather": weather,
- "map_notes": map_result.notes,
- "route_warnings": route_result.warnings,
- "revision_feedback": (
- revision_feedback or []
- ),
- }
- class ItineraryPlannerAgent:
- """负责综合上游角色结果生成逐日行程。"""
- def __init__(
- self,
- model: BaseChatModel | None = None,
- ) -> None:
- """初始化行程规划Agent,支持注入自定义模型。"""
- base_model = model or get_chat_model(
- timeout=180.0
- )
- # json_mode 通过 response_format 约束模型输出 JSON,
- # 对 DeepSeek 等推理模型的兼容性更好。
- self._structured_model = (
- base_model.with_structured_output(
- ItineraryPlan,
- method="json_mode",
- )
- )
- async def plan(
- self,
- request: TravelRequest,
- selection: CandidateSelectionResult,
- map_result: MapResearchResult,
- route_result: RouteEvaluationResult,
- revision_feedback: list[str] | None = None,
- ) -> dict[str, Any]:
- """生成结构化旅行方案。"""
- context = build_planner_context(
- request=request,
- selection=selection,
- map_result=map_result,
- route_result=route_result,
- revision_feedback=revision_feedback,
- )
- context_json = json.dumps(
- context,
- ensure_ascii=False,
- indent=2,
- )
- feedback_instruction = ""
- if revision_feedback:
- feedback_instruction = (
- "\n这是审查角色上一轮提出的修改要求,"
- "新方案必须逐项处理:\n"
- + "\n".join(
- f"- {item}"
- for item in revision_feedback
- )
- )
- user_message = f"""
- 请根据以下上游角色提供的真实数据生成旅行方案。
- {context_json}
- {feedback_instruction}
- 最终必须以 JSON 格式返回 ItineraryPlan 结构化结果。
- 不要输出结构化结果之外的解释。
- """
- response = await self._structured_model.ainvoke(
- [
- SystemMessage(
- content=(
- ITINERARY_PLANNER_PROMPT
- )
- ),
- HumanMessage(
- content=user_message
- ),
- ]
- )
- if isinstance(response, ItineraryPlan):
- structured = response
- else:
- # 兼容 LLM 可能包裹在 {"itineraryPlan": {...}} 中的情况。
- if (
- isinstance(response, dict)
- and "itineraryPlan" in response
- and len(response) == 1
- ):
- response = response["itineraryPlan"]
- # 兼容 LLM 将 coordinates 对象误填到
- # selected_hotel.location 的情况。
- if isinstance(response, dict):
- hotel = response.get("selected_hotel")
- if isinstance(hotel, dict):
- loc = hotel.get("location")
- if isinstance(loc, dict):
- lat = loc.get("latitude")
- lng = loc.get("longitude")
- if lat is not None and lng is not None:
- hotel["location"] = (
- f"{lng},{lat}"
- )
- structured = ItineraryPlan.model_validate(response)
- return {
- "structured_response": structured,
- "messages": [],
- }
|