# -*- coding: utf-8 -*- """ 预定航班(范式) """ import asyncio import datetime from typing import AsyncGenerator from logfire_api.variables import ValueDoesNotEqual from pydantic import BaseModel, Field, field_validator, TypeAdapter from pydantic_ai import ( Agent, ApprovalRequired, DeferredToolRequests, DeferredToolResults, ModelMessage, ModelRetry, ModelSettings, RunContext, RunUsage, UsageLimits, ) from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.providers.openai import OpenAIProvider from sqlalchemy.sql.dml import ReturningDelete from pydantic_ai.run import AgentRunResultEvent DEEPSEEK_V4_FLASH_MODEL = OpenAIChatModel( model_name="deepseek-v4-flash", provider=OpenAIProvider( base_url="https://tokenhub.tencentmaas.com/v1", api_key="sk-D9Y1mCe8VlvNqLuSC4mAjqEwxJ2nW4C0h8a7EPn8kg9RLsHq", ), ) MODEL_SETTINGS = ModelSettings( temperature=0, extra_body={"thinking": {"type": "disabled"}} # 温度控制 ) # 禁用思考模式 class Flight(BaseModel): """ 航班类 """ number: str = Field(..., description="航班号") date: datetime.date = Field(..., description="日期") origin_airport_code: str = Field(..., description="出发机场代码") destination_airport_code: str = Field(..., description="到达机场代码") airfare: int = Field(..., description="机票价格") @field_validator("origin_airport_code", "destination_airport_code") @classmethod def validate_airport_code(cls, airport_code: str) -> str: """校验机场代码""" if ( not airport_code.isalpha() or not airport_code.isupper() or len(airport_code) != 3 ): raise ValueError(f"机场代码须为英文、大写、3位字符串") return airport_code class FlightNoFound(BaseModel): """ 未查询到航班类 """ class Deps(BaseModel): """ 依赖类 """ date: datetime.date = Field(..., description="日期") origin_airport_code: str = Field(..., description="出发机场代码") destination_airport_code: str = Field(..., description="到达机场代码") source_material: str = Field(..., description="来源资料") extracted_flights: list[Flight] | None = Field( default=None, description="提取到的所有航班信息" ) selected_flight: Flight | FlightNoFound | None = Field( default=None, description="查询到的航班" ) # 航班查询与预定智能体 agent = Agent[Deps, Flight | FlightNoFound | DeferredToolRequests]( model=DEEPSEEK_V4_FLASH_MODEL, model_settings=MODEL_SETTINGS, deps_type=Deps, output_type=Flight | FlightNoFound | DeferredToolRequests, system_prompt=( "你的任务是帮助用户查询并预定航班,**必须按照下述步骤执行**:", "1. 使用 extract_flights 提取所有航班信息;", "2. 使用 select_flight 查询航班;", "3. 若查询到航班则使用 book_flight 预定航班;若未查询到航班则返回 FlightNoFound。", ), ) # 航班信息提取智能体 extraction_agent = Agent( model=DEEPSEEK_V4_FLASH_MODEL, model_settings=MODEL_SETTINGS, output_type=list[Flight], system_prompt=( "你的任务是根据提供的来源资料提取出所有航班信息,包括航班号、日期、出发机场代码、到达机场代码和机票价格。", "其中,出发机场代码和到达机场代码须为英文、大写、3位字符串,例如 San Francisco International Airport (SFO) 的机场代码为 SFO。", "若某航班信息不全则跳过该航班,若没有任何航班信息则返回空列表。", "禁止编造。", ), ) @agent.tool async def extract_flights(ctx: RunContext[Deps]) -> list[Flight]: """ 提取所有航班信息 **如何提高提取准确率** 1. 系统提示词规则约束 2. 降低模型温度 3. 输出类型约定和输出模型校验 4. 业务规则约束(例如,输出不可为空列表) """ result = await extraction_agent.run( ctx.deps.source_material, usage=ctx.usage, usage_limits=UsageLimits(request_limit=10), ) if (flight_counts := len(output := result.output)) != 8: # 模拟业务规则约束 raise ModelRetry(f"所有航班信息数量应为 8 ,不是 {flight_counts}") # 更新提取到的航班信息 ctx.deps.extracted_flights = output return output # 航班查询智能体 selection_agent = Agent[Deps, Flight | FlightNoFound]( model=DEEPSEEK_V4_FLASH_MODEL, model_settings=MODEL_SETTINGS, deps_type=Deps, output_type=Flight | FlightNoFound, system_prompt=( "你的任务是在提取到的所有航班信息中查询匹配用户提供的日期、出发机场代码和到达机场代码的机票价格最低的航班。", "若没有符合要求的航班则返回 FlightNoFound。", ), ) @agent.tool async def select_flight(ctx: RunContext[Deps]) -> Flight | FlightNoFound: """ 查询航班 """ extracted_flights = ctx.deps.extracted_flights if not extracted_flights: raise ModelRetry("须先使用 extract_flights 提取所有航班信息") result = await selection_agent.run( ( f"{ctx.prompt}", f"所有航班信息:\n{TypeAdapter(list[Flight]).dump_json(extracted_flights).decode('utf-8')}", ), deps=ctx.deps, usage=ctx.usage, usage_limits=UsageLimits(request_limit=10), ) ctx.deps.selected_flight = (output := result.output) return output @selection_agent.output_validator async def validate_output( ctx: RunContext[Deps], output: Flight | FlightNoFound ) -> Flight | FlightNoFound: """ 航班查询智能体输出校验 """ # 不校验未查询到航班 if isinstance(output, FlightNoFound): return output errors = [] if output.date != ctx.deps.date: errors.append(f"航班日期应为 {ctx.deps.date}, 不是 {output.date}") if output.origin_airport_code != ctx.deps.origin_airport_code: errors.append( f"航班出发机场应为 {ctx.deps.origin_airport_code}, 不是 {output.origin_airport_code}" ) if output.destination_airport_code != ctx.deps.destination_airport_code: errors.append( f"航班到达机场应为 {ctx.deps.destination_airport_code}, 不是 {output.destination_airport_code}" ) if errors: raise ModelRetry("\n".join(errors)) return output @agent.tool async def book_flight(ctx: RunContext[Deps]) -> Flight | FlightNoFound: """ 预定航班 """ if not (selected_flight := ctx.deps.selected_flight): raise ModelRetry("须先使用 select_flight 查询航班") if isinstance(selected_flight, FlightNoFound): return selected_flight else: if not ctx.tool_call_approved: raise ApprovalRequired( metadata={ "reason": f"需要用户确认是否预定 {selected_flight.number} 在 {selected_flight.date.strftime('%Y-%m-%d')} 从 {selected_flight.origin_airport_code} 到 {selected_flight.destination_airport_code} 的航班" } ) return selected_flight source_material = """ 1. Flight SFO-AK123 - Price: $350 - Origin: San Francisco International Airport (SFO) - Destination: Ted Stevens Anchorage International Airport (ANC) - Date: January 10, 2025 2. Flight SFO-AK456 - Price: $370 - Origin: San Francisco International Airport (SFO) - Destination: Fairbanks International Airport (FAI) - Date: January 10, 2025 3. Flight SFO-AK789 - Price: $400 - Origin: San Francisco International Airport (SFO) - Destination: Juneau International Airport (JNU) - Date: January 20, 2025 4. Flight NYC-LA101 - Price: $250 - Origin: San Francisco International Airport (SFO) - Destination: Ted Stevens Anchorage International Airport (ANC) - Date: January 10, 2025 5. Flight CHI-MIA202 - Price: $200 - Origin: Chicago O'Hare International Airport (ORD) - Destination: Miami International Airport (MIA) - Date: January 12, 2025 6. Flight BOS-SEA303 - Price: $120 - Origin: Boston Logan International Airport (BOS) - Destination: Ted Stevens Anchorage International Airport (ANC) - Date: January 12, 2025 7. Flight DFW-DEN404 - Price: $150 - Origin: Dallas/Fort Worth International Airport (DFW) - Destination: Denver International Airport (DEN) - Date: January 10, 2025 8. Flight ATL-HOU505 - Price: $180 - Origin: Hartsfield-Jackson Atlanta International Airport (ATL) - Destination: George Bush Intercontinental Airport (IAH) - Date: January 10, 2025 """ async def run_stream_events( deps: Deps, user_prompt: str, message_history: list[ModelMessage], usage: RunUsage, deferred_tool_results: DeferredToolResults | None = None, ) -> AsyncGenerator: async with agent.run_stream_events( user_prompt=user_prompt, deps=deps, message_history=message_history, usage=usage, deferred_tool_results=deferred_tool_results, ) as events: async for event in events: yield event async def main(): # 实例化依赖 deps = Deps( source_material=source_material, date=datetime.date(2025, 1, 10), origin_airport_code="SFO", destination_airport_code="ANC", selected_flight=None, ) user_prompt = f"帮我查询并预定在 {deps.date.strftime('%Y-%m-%d')} 从 {deps.origin_airport_code} 到 {deps.destination_airport_code} 的航班" message_history: list[ModelMessage] = [] events = run_stream_events( deps=deps, user_prompt=user_prompt, message_history=message_history, usage=RunUsage(), ) # 消费异步生成器 async for event in events: print(event) if isinstance(event, AgentRunResultEvent): # 保存本次运行新增消息 message_history.extend(event.result.new_messages()) asyncio.run(main()) """ @selection_agent.tool async def select_flight(ctx: RunContext[Deps]) -> Flight | FlightNoFound: result = await selection_agent.run(ctx.deps.source_material, usage=ctx.usage) if isinstance(result.output, Flight): if not ctx.tool_call_approved: raise ApprovalRequired(metadata={"reason": "protected"}) return result.output if not isinstance(event, AgentRunResultEvent): yield event else: result = event.result # 更新任务使用量 task.usage = usage_to_dict(result.usage) if isinstance(result.output, FlightDetail): content = "\n---\n已查询到航班,请回复 buy 购票 或 search 重新查询\n" # 更新任务节点为提取座位偏好 task.node = "seat_preference_extraction" else: content = ( "\n---\n未查询到满足您需求的航班,流程结束!\n" ) # 更新任务为空 task = None # 返回任务节点结果事件 yield TaskNodeResultEvent( task=task, content=content, ) yield event return # 提取座位偏好 case "seat_preference_extraction": if user_prompt == "buy": # 返回任务节点结果事件 yield TaskNodeResultEvent( task=task, content="请和我说下您的座位偏好:\nA、F 座位是靠窗位;1 排、14 排、20 排腿部空间更大、更舒展,你更想要靠窗座位,宽敞大空间座位", ) return elif user_prompt == "search": # 更新任务节点为航班查询 task.node = "flight_search" else: async with seat_preference_extraction_agent.run_stream_events( user_prompt=user_prompt, message_history=message_history, usage=usage_to_object(dict(task.usage)), usage_limits=usage_limits_to_object(dict(task.usage_limits)), ) as events: async for event in events: if not isinstance(event, AgentRunResultEvent): yield event else: result = event.result # 更新任务使用量 task.usage = usage_to_dict(result.usage) # 更新任务为空 task = None # 返回任务节点结果事件 yield TaskNodeResultEvent( task=task, content="已帮您定好座位,流程结束!", ) yield event return # 工具调用分片开始事件 case ToolCallPart( tool_name=tool_name, tool_call_id=tool_call_id ): # 构建消息实例 message = Message( type=MessageType.TOOL_CALL, title=tool_name, content=",", is_running=True, ) tool_call_ids.add(tool_call_id) # 添加至消息字典 dialog.thoughts[index] = Thought( type="tool_call", content="正在生成调用参数", ) # ========== 函数工具调用事件 ========== case FunctionToolCallEvent(tool_call_id=tool_call_id, part=part): # 获取分片索引 index = tool_call_ids[tool_call_id] match dialog.thoughts[index].type: # 工具检索 case "tool_search": dialog.thoughts[index].content = ( f"正在检索 {part.args_as_json_str()}" ) # 能力加载 case "capability_load": dialog.thoughts[index].content = ( f"正在加载能力 {part.tool_name}" ) # 工具调用 case "tool_call": dialog.thoughts[index].content = ( f"正在调用工具 {part.tool_name}" ) case _: continue # ========== 函数工具结果事件 ========== case FunctionToolResultEvent( tool_call_id=tool_call_id, content=content, ): index = tool_call_ids[tool_call_id] match dialog.thoughts[index].type: # 工具检索 case "tool_search": dialog.thoughts[index].content = ( content if isinstance(content, str) else "" ) # 暂仅考虑文本内容 # 能力加载 case "capability_load": dialog.thoughts[index].content = f"已加载 {content}" # 工具调用 case "tool_call": dialog.thoughts[index].content = f"已调用 {content}" case _: continue def init_work_flow() -> WorkFlow: return WorkFlow( type=WorkFlowType.BOOK_FLIGHT, deps=Dependences( source_material=source_material, date=datetime.date(2025, 1, 10), origin="SFO", destination="ANC", ), ) from application.states.models import ( WorkFlow, WorkFlowType, ) from application.workshop.models import ( DEEPSEEK_V4_FLASH_MODEL, MODEL_SETTINGS_DISABLED_THINKING, ) """