472 lines
17 KiB
Python
472 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
预定航班工作流(范式)
|
||
"""
|
||
import asyncio
|
||
import datetime
|
||
from typing import AsyncGenerator, cast
|
||
|
||
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 pydantic_ai.run import AgentRunResultEvent, AgentRunResult
|
||
from enum import StrEnum
|
||
from pydantic_ai._uuid import uuid7
|
||
from pydantic_ai.messages import (
|
||
FunctionToolCallEvent,
|
||
FunctionToolResultEvent,
|
||
LoadCapabilityCallPart,
|
||
PartDeltaEvent,
|
||
PartEndEvent,
|
||
PartStartEvent,
|
||
TextPart,
|
||
TextPartDelta,
|
||
ThinkingPart,
|
||
ThinkingPartDelta,
|
||
ToolCallPart,
|
||
ToolSearchCallPart,
|
||
ToolReturnPart,
|
||
)
|
||
|
||
|
||
class TaskStatus(StrEnum):
|
||
"""
|
||
任务状态枚举
|
||
"""
|
||
|
||
RUNNING = "running"
|
||
DONE = "done"
|
||
ERROR = "error"
|
||
PENDING_APPROVAL = "pending_approval"
|
||
|
||
|
||
class Task(BaseModel):
|
||
"""
|
||
任务类
|
||
"""
|
||
|
||
tool_name: str = Field(..., description="工具名称")
|
||
tool_call_id: str = Field(..., description="工具调用唯一标识")
|
||
status: TaskStatus = Field(default=TaskStatus.RUNNING, description="任务状态")
|
||
title: str = Field(default="", description="任务标题")
|
||
content: str = Field(default="", description="任务内容")
|
||
|
||
|
||
class MessageType(StrEnum):
|
||
"""
|
||
消息类型枚举
|
||
"""
|
||
|
||
USER_PROMPT = "user_prompt"
|
||
THINKING = "thinking"
|
||
WORK_OUTPUT = "work_output"
|
||
RESULT_OUTPUT = "result_output"
|
||
|
||
|
||
class Message(BaseModel):
|
||
"""
|
||
消息类
|
||
"""
|
||
|
||
id: str = Field(default_factory=lambda: str(uuid7()), description="消息唯一标识")
|
||
type: MessageType = Field(..., description="消息类型")
|
||
title: str = Field(default="", description="消息标题")
|
||
content: str = Field(default="", description="消息内容")
|
||
tasks: dict[str, Task] = Field(
|
||
default_factory=dict, description="任务字典,键为工具调用唯一标识"
|
||
)
|
||
is_running: bool = Field(
|
||
default=False, description="正在运行,True 表示正在运行,False 表示运行完成"
|
||
)
|
||
is_shown: bool = Field(
|
||
default=False, description="展示组件,True 表示展示,False 表示隐藏"
|
||
)
|
||
|
||
|
||
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 NoSelectedFlight(BaseModel):
|
||
"""
|
||
未选择到航班
|
||
"""
|
||
|
||
|
||
class Deps(BaseModel):
|
||
"""
|
||
依赖类
|
||
"""
|
||
|
||
date: datetime.date = Field(..., description="日期")
|
||
origin_airport_code: str = Field(..., description="出发机场代码")
|
||
destination_airport_code: str = Field(..., description="到达机场代码")
|
||
searched_flights: list[Flight] | None = Field(
|
||
default=None, description="查询到的航班"
|
||
)
|
||
recommended_flight: Flight | NoSelectedFlight | None = Field(
|
||
default=None, description="推荐航班"
|
||
)
|
||
|
||
|
||
# 主智能体
|
||
agent = Agent[Deps, Flight | NoSelectedFlight | DeferredToolRequests](
|
||
model=DEEPSEEK_V4_FLASH_MODEL,
|
||
model_settings=MODEL_SETTINGS,
|
||
deps_type=Deps,
|
||
output_type=Flight | NoSelectedFlight | DeferredToolRequests,
|
||
system_prompt=(
|
||
"你的任务是帮助用户查询并预定航班,**必须按照下述步骤执行**:",
|
||
"1. 使用 match_flight 匹配满足用户需求的航班;",
|
||
"2. 使用 select_flight 选择航班。若未选择到航班则返回 NoSelectedFlight;",
|
||
"3. 若选择到航班则使用 book_flight 预定航班。由用户确认后预定。",
|
||
),
|
||
)
|
||
|
||
|
||
# 提取智能体
|
||
extraction_agent = Agent[Deps, list[Flight]](
|
||
model=DEEPSEEK_V4_FLASH_MODEL,
|
||
model_settings=MODEL_SETTINGS,
|
||
deps_type=Deps,
|
||
output_type=list[Flight],
|
||
system_prompt=(
|
||
"你的任务是在来源资料中提取所有航班信息,包括航班号、日期、出发机场代码、到达机场代码和机票价格。",
|
||
"其中,出发机场代码和到达机场代码须为英文、大写、3位字符串,例如 San Francisco International Airport (SFO) 的机场代码为 SFO。",
|
||
"若某航班信息不全则跳过该航班,若没有任何航班信息则返回空列表。",
|
||
"禁止编造。",
|
||
),
|
||
)
|
||
|
||
|
||
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
|
||
"""
|
||
|
||
|
||
@agent.tool
|
||
async def search_flights(ctx: RunContext[Deps]) -> list[Flight]:
|
||
"""
|
||
查询航班
|
||
**如何提高提取准确率**
|
||
1. 系统提示词规则约束
|
||
2. 降低模型温度
|
||
3. 输出类型约定和输出模型校验
|
||
4. 业务规则约束(例如,输出不可为空列表)
|
||
|
||
**设计工具需先设计业务流程**
|
||
本示例按照 查询航班 -> 推荐航班 -> 预定航班
|
||
"""
|
||
extraction_agent_result = await extraction_agent.run(
|
||
f"来源资料:\n{source_material}",
|
||
deps=ctx.deps,
|
||
usage=ctx.usage,
|
||
usage_limits=UsageLimits(request_limit=10),
|
||
)
|
||
if (
|
||
extracted_flight_counts := len(
|
||
extracted_flights := extraction_agent_result.output
|
||
)
|
||
) != 8: # 模拟业务规则约束
|
||
raise ModelRetry(
|
||
f"提取到的所有航班信息数量应为 8 ,不是 {extracted_flight_counts}"
|
||
)
|
||
# 查询到的航班
|
||
ctx.deps.searched_flights = [
|
||
flight
|
||
for flight in extracted_flights
|
||
if flight.date == ctx.deps.date
|
||
and flight.origin_airport_code == ctx.deps.origin_airport_code
|
||
and flight.destination_airport_code == ctx.deps.destination_airport_code
|
||
]
|
||
return ctx.deps.searched_flights
|
||
|
||
|
||
@agent.tool
|
||
async def recommend_flight(ctx: RunContext[Deps]) -> Flight | NoSelectedFlight:
|
||
"""
|
||
推荐航班
|
||
**如何提高提取准确率**
|
||
1. 系统提示词规则约束
|
||
2. 降低模型温度
|
||
3. 输出类型约定和输出模型校验
|
||
4. 业务规则约束(例如,输出不可为空列表)
|
||
"""
|
||
if (searched_flights := ctx.deps.searched_flights) is None:
|
||
raise ModelRetry("必须先使用 search_flights 查询航班")
|
||
ctx.deps.recommended_flight = (
|
||
min(searched_flights, key=lambda flight: flight.airfare)
|
||
if searched_flights
|
||
else NoSelectedFlight()
|
||
)
|
||
return ctx.deps.recommended_flight
|
||
|
||
|
||
@agent.tool
|
||
async def book_flight(ctx: RunContext[Deps]) -> Flight | NoSelectedFlight:
|
||
"""
|
||
预定航班
|
||
"""
|
||
if (recommended_flight := ctx.deps.recommended_flight) is None:
|
||
raise ModelRetry("必须先使用 recommend_flight 推荐航班")
|
||
|
||
if isinstance(recommended_flight, NoSelectedFlight):
|
||
return recommended_flight
|
||
else:
|
||
if not ctx.tool_call_approved:
|
||
raise ApprovalRequired()
|
||
return recommended_flight
|
||
|
||
|
||
@agent.output_validator
|
||
async def validate_output(
|
||
ctx: RunContext[Deps], output: Flight | NoSelectedFlight | DeferredToolRequests
|
||
) -> Flight | NoSelectedFlight | DeferredToolRequests:
|
||
"""
|
||
输出校验
|
||
"""
|
||
# 不校验未选择到航班或延迟工具请求
|
||
if isinstance(output, NoSelectedFlight | DeferredToolRequests):
|
||
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
|
||
|
||
|
||
async def run_stream_events(
|
||
deps: Deps,
|
||
user_prompt: str,
|
||
message_history: list[ModelMessage],
|
||
usage: RunUsage,
|
||
deferred_tool_results: DeferredToolResults | None = None,
|
||
) -> AsyncGenerator:
|
||
# 构建消息
|
||
message = Message(
|
||
type=MessageType.WORK_OUTPUT,
|
||
title="预定航班",
|
||
is_running=True,
|
||
)
|
||
yield message
|
||
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:
|
||
print(event)
|
||
match event:
|
||
case PartStartEvent(
|
||
part=part,
|
||
):
|
||
match part:
|
||
case ToolCallPart(
|
||
tool_name=tool_name, tool_call_id=tool_call_id
|
||
):
|
||
match tool_name:
|
||
case "search_flights":
|
||
if tool_name not in message.tasks:
|
||
message.tasks[tool_name] = Task(
|
||
tool_name=tool_name,
|
||
tool_call_id=tool_call_id,
|
||
title="正在查询航班",
|
||
)
|
||
yield message
|
||
|
||
case "recommend_flight":
|
||
if tool_name not in message.tasks:
|
||
message.tasks[tool_name] = Task(
|
||
tool_name=tool_name,
|
||
tool_call_id=tool_call_id,
|
||
title="正在推荐航班",
|
||
)
|
||
yield message
|
||
|
||
case "book_flight":
|
||
if tool_name not in message.tasks:
|
||
message.tasks[tool_name] = Task(
|
||
tool_name=tool_name,
|
||
tool_call_id=tool_call_id,
|
||
title="正在预定航班",
|
||
)
|
||
yield message
|
||
|
||
case FunctionToolResultEvent(part):
|
||
match part:
|
||
case ToolReturnPart(
|
||
tool_name=tool_name,
|
||
tool_call_id=tool_call_id,
|
||
content=content,
|
||
):
|
||
match tool_name:
|
||
case "search_flights":
|
||
task = message.tasks[tool_name]
|
||
searched_flights = cast(list[Flight], content)
|
||
task.title = (
|
||
f"已查询到 {len(searched_flights)} 班航班"
|
||
)
|
||
task.content = "\n".join(
|
||
[
|
||
f"{searched_flight.number} - {searched_flight.date.strftime('%Y-%m-%d')} 从 {searched_flight.origin_airport_code} 飞往 {searched_flight.destination_airport_code} - {searched_flight.airfare}"
|
||
for searched_flight in searched_flights
|
||
]
|
||
)
|
||
task.status = TaskStatus.DONE
|
||
yield message
|
||
|
||
case "recommend_flight":
|
||
task = message.tasks[tool_name]
|
||
recommended_flight = cast(
|
||
Flight | NoSelectedFlight, content
|
||
)
|
||
if isinstance(recommended_flight, Flight):
|
||
task.title = "为您推荐"
|
||
task.content = f"{recommended_flight.number} - {recommended_flight.date.strftime('%Y-%m-%d')} 从 {recommended_flight.origin_airport_code} 飞往 {recommended_flight.destination_airport_code} - {recommended_flight.airfare}"
|
||
else:
|
||
task.title = "为您推荐"
|
||
|
||
task.status = TaskStatus.DONE
|
||
yield message
|
||
|
||
case AgentRunResultEvent(
|
||
result=result,
|
||
):
|
||
match result:
|
||
case AgentRunResult(output=output):
|
||
match output:
|
||
case DeferredToolRequests(approvals=approvals):
|
||
for approval in approvals:
|
||
match approval:
|
||
case ToolCallPart(tool_name=tool_name):
|
||
task = message.tasks[tool_name]
|
||
task.content = "请确认是否预定 "
|
||
task.status = (
|
||
TaskStatus.PENDING_APPROVAL
|
||
)
|
||
yield message
|
||
|
||
|
||
async def main():
|
||
# 实例化依赖
|
||
deps = Deps(
|
||
date=datetime.date(2025, 1, 10),
|
||
origin_airport_code="SFO",
|
||
destination_airport_code="ANC",
|
||
)
|
||
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()
|
||
if isinstance(event, AgentRunResultEvent):
|
||
# 保存本次运行新增消息
|
||
message_history.extend(event.result.new_messages())
|
||
if isinstance(event, Message):
|
||
print(event)
|
||
|
||
|
||
asyncio.run(main())
|