From 406c3877b1f76d46392df23f94bde20278372549 Mon Sep 17 00:00:00 2001 From: liubiren Date: Wed, 5 Aug 2026 20:29:26 +0800 Subject: [PATCH] 1 --- agent/application/agents/__init__.py | 7 - agent/application/agents/chat_agent.py | 39 -- .../agents/generating_prd_agent.py | 39 -- agent/application/domain_models.py | 4 +- agent/application/pages/conversation.py | 2 +- agent/application/states/auth.py | 9 +- agent/application/states/conversation.py | 393 ++++++++---------- agent/application/tasks/__init__.py | 53 +++ .../application/tasks/generating_prd_agent.py | 197 +++++++++ agent/application/tasks/models.py | 15 + agent/database.db | Bin 331776 -> 339968 bytes 11 files changed, 456 insertions(+), 302 deletions(-) delete mode 100644 agent/application/agents/__init__.py delete mode 100644 agent/application/agents/chat_agent.py delete mode 100644 agent/application/agents/generating_prd_agent.py create mode 100644 agent/application/tasks/__init__.py create mode 100644 agent/application/tasks/generating_prd_agent.py create mode 100644 agent/application/tasks/models.py diff --git a/agent/application/agents/__init__.py b/agent/application/agents/__init__.py deleted file mode 100644 index 52476ef..0000000 --- a/agent/application/agents/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# -*- coding: utf-8 -*- -from application.agents.chat_agent import chat_agent - - -__all__ = [ - "chat_agent", -] diff --git a/agent/application/agents/chat_agent.py b/agent/application/agents/chat_agent.py deleted file mode 100644 index 5a908e6..0000000 --- a/agent/application/agents/chat_agent.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8 -*- -""" -开放式对话智能体 -""" -from pydantic_ai import Agent -from pydantic_ai.models.openai import OpenAIChatModel -from pydantic_ai.providers.openai import OpenAIProvider - -instructions: str = """ -# 角色 -专业友好AI助手,结构化解答各类问题。 - -# 输出硬性规则 -1. 全文强制标准Markdown,禁止纯文本;不要额外说明排版格式,直接输出内容; -2. 层级使用 `#/##/###`,列表用 `-` 无序列表或数字有序列表; -3. 代码块用 ```语言名``` 包裹; -4. 重点内容标注 **粗体**/*斜体*; -5. 思考、工具日志仅输出文本,适配前端折叠面板,禁止输出HTML标签; -6. 内容分点拆分,排版整洁适配前端Markdown渲染。 - -# 行文要求 -语言通俗,逻辑完整简洁,无多余废话。 -""" - -# 实例化智能体 -chat_agent: Agent = Agent( - name="chat_agent", - model=OpenAIChatModel( - model_name="deepseek-v4-flash", - provider=OpenAIProvider( - base_url="https://tokenhub.tencentmaas.com/v1", - api_key="sk-D9Y1mCe8VlvNqLuSC4mAjqEwxJ2nW4C0h8a7EPn8kg9RLsHq", - ), - ), - instructions=instructions, - capabilities=None, - output_type=str, - retries=1, -) diff --git a/agent/application/agents/generating_prd_agent.py b/agent/application/agents/generating_prd_agent.py deleted file mode 100644 index 2793f8d..0000000 --- a/agent/application/agents/generating_prd_agent.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8 -*- -""" -生成产品需求文档智能体 -""" -from pydantic_ai import Agent -from pydantic_ai.models.openai import OpenAIChatModel -from pydantic_ai.providers.openai import OpenAIProvider - -instructions: str = """ -# 角色 -专业友好AI助手,结构化解答各类问题。 - -# 输出硬性规则 -1. 全文强制标准Markdown,禁止纯文本;不要额外说明排版格式,直接输出内容; -2. 层级使用 `#/##/###`,列表用 `-` 无序列表或数字有序列表; -3. 代码块用 ```语言名``` 包裹; -4. 重点内容标注 **粗体**/*斜体*; -5. 思考、工具日志仅输出文本,适配前端折叠面板,禁止输出HTML标签; -6. 内容分点拆分,排版整洁适配前端Markdown渲染。 - -# 行文要求 -语言通俗,逻辑完整简洁,无多余废话。 -""" - -# 实例化智能体 -chat_agent: Agent = Agent( - name="chat_agent", - model=OpenAIChatModel( - model_name="deepseek-v4-flash", - provider=OpenAIProvider( - base_url="https://tokenhub.tencentmaas.com/v1", - api_key="sk-D9Y1mCe8VlvNqLuSC4mAjqEwxJ2nW4C0h8a7EPn8kg9RLsHq", - ), - ), - instructions=instructions, - capabilities=None, - output_type=str, - retries=1, -) diff --git a/agent/application/domain_models.py b/agent/application/domain_models.py index d9a6966..ba95753 100644 --- a/agent/application/domain_models.py +++ b/agent/application/domain_models.py @@ -3,9 +3,10 @@ 领域模型 """ from datetime import datetime -from typing import Dict, List +from typing import Dict, List, Optional, Callable, Any from pydantic import BaseModel, Field +from pydantic_ai import Agent, UsageLimits from pydantic_ai._uuid import uuid7 from enum import StrEnum @@ -59,4 +60,3 @@ class TaskType(StrEnum): """ CHAT = "chat" - GENERATING_PRD = "generating_prd" diff --git a/agent/application/pages/conversation.py b/agent/application/pages/conversation.py index 72e92bc..ddef791 100644 --- a/agent/application/pages/conversation.py +++ b/agent/application/pages/conversation.py @@ -578,7 +578,7 @@ def dialog_showing(item: Tuple[str, Dialog]) -> rx.Component: "hr": lambda _: rx.divider( height="1px", margin="12px 0", - background_color="var(--prismui-background-color-6)", + background_color="var(--prismui-background-color-2)", ), "table": lambda children: rx.el.table( children, diff --git a/agent/application/states/auth.py b/agent/application/states/auth.py index fe85e03..9c46736 100644 --- a/agent/application/states/auth.py +++ b/agent/application/states/auth.py @@ -173,7 +173,8 @@ class AuthState(rx.State): self.is_captcha_sent = True # 获取数据库状态 - self._db_state = await self.get_state(DatabaseState) + if not self._db_state: + self._db_state = await self.get_state(DatabaseState) # 将倒计时和发送验证码事件添加至后台任务队列 yield type(self).countdown() @@ -230,11 +231,11 @@ class AuthState(rx.State): self.login_error_message = "请先阅读并同意协议和政策" return - if not self._db_state: - return - self.is_logging_in = True + # 获取数据库状态 + if not self._db_state: + return # 核验验证码 if not await self._db_state.verify_captcha( email=self.email, captcha=self.captcha diff --git a/agent/application/states/conversation.py b/agent/application/states/conversation.py index e217133..6c6b1db 100644 --- a/agent/application/states/conversation.py +++ b/agent/application/states/conversation.py @@ -2,9 +2,7 @@ """ 会话状态 """ -from datetime import datetime -from typing import Any, AsyncGenerator, Dict, List, Optional -from pydantic_ai import Agent, ThinkingPartDelta, ModelMessage +from typing import AsyncGenerator, Dict, List, Optional, Literal from pydantic_ai.messages import ( FunctionToolCallEvent, FunctionToolResultEvent, @@ -15,6 +13,7 @@ from pydantic_ai.messages import ( TextPart, TextPartDelta, ThinkingPart, + ThinkingPartDelta, ToolCallPart, ToolSearchCallPart, ) @@ -30,7 +29,7 @@ from application.domain_models import ( Thought, TaskType, ) -from application.agents import chat_agent +from application.tasks import run_stream_events class ConversationState(rx.State): @@ -58,6 +57,15 @@ class ConversationState(rx.State): # 数据库状态(私有变量,不予序列化) _db_state: Optional[DatabaseState] = None + async def get_db_state(self) -> DatabaseState: + """ + 获取数据库状态 + :return: 数据库状态 + """ + if not self._db_state: + self._db_state = await self.get_state(DatabaseState) + return self._db_state + async def resume(self, user_id: str) -> None: """ 恢复当前用户会话状态 @@ -68,16 +76,14 @@ class ConversationState(rx.State): if not self.user_id: return - self._db_state = await self.get_state(DatabaseState) - + # 获取数据库状态 + db_state = await self.get_db_state() # 获取会话字典 - self.conversations = await self._db_state.get_conversations( - user_id=self.user_id - ) + self.conversations = await db_state.get_conversations(user_id=self.user_id) # 若会话字典为空则先创建会话记录再在会话字典中添加会话实例 if not self.conversations: self.conversations.update( - await self._db_state.create_conversations_record(user_id=self.user_id) + await db_state.create_conversations_record(user_id=self.user_id) ) # 将最后一个会话作为当前会话并更新会话唯一标识 self.conversation_id = next(reversed(self.conversations.keys())) @@ -107,19 +113,16 @@ class ConversationState(rx.State): :param conversation_id: 需删除的会话唯一标识 :return: None """ - if not self._db_state: - return - + # 获取数据库状态 + db_state = await self.get_db_state() # 先设置会话记录为已删除再在会话字典中删除会话实例 - await self._db_state.set_conversations_record_deleted( - conversation_id=conversation_id - ) + await db_state.set_conversations_record_deleted(conversation_id=conversation_id) del self.conversations[conversation_id] # 删除后,若会话字典为空则先创建会话记录再添加会话实例 if not self.conversations: self.conversations.update( - await self._db_state.create_conversations_record(user_id=self.user_id) + await db_state.create_conversations_record(user_id=self.user_id) ) # 删除后,若当前会话不存在则将最后一个会话作为当前会话并更新会话唯一标识 @@ -141,12 +144,11 @@ class ConversationState(rx.State): 创建会话 :return: None """ + # 获取数据库状态 + db_state = await self.get_db_state() # 创建会话记录再添加会话实例 - if not self._db_state: - return - self.conversations.update( - await self._db_state.create_conversations_record( + await db_state.create_conversations_record( user_id=self.user_id, description="新会话" ) ) @@ -198,173 +200,6 @@ class ConversationState(rx.State): return {} return conversation.dialogs - async def run_stream_events( - self, - conversation: Conversation, - dialog: Dialog, - agent: Agent, - message_history: Optional[List[ModelMessage]] = None, - ) -> AsyncGenerator[None]: - """ - 运行并处理流式事件 - :param agent: 代理 - :param conversation: 当前会话 - :param dialog: 当前对话 - :param message_history: 消息历史列表 - :return: AsyncGenerator[None] - """ - if not self._db_state: - return - - # 初始化工具调用唯一标识和片段索引映射字典 - tool_call_ids: Dict[str, int] = {} - async with agent.run_stream_events( - conversation_id=self.conversation_id, - user_prompt=dialog.user_prompt, - message_history=message_history, - ) as events: - async for event in events: - match event: - # ========== 开始事件 ========== - case PartStartEvent( - index=index, - part=part, - previous_part_kind=previous_part_kind, - ): - match part: - # 思考分片开始事件 - case ThinkingPart(content=content): - # 若上一分片种类为空则将正在思考设置为正在思考 - if not previous_part_kind: - dialog.is_thinking = True - dialog.thoughts[index] = Thought( - type="thinking", content=content - ) - - # 工具检索分片开始事件 - case ToolSearchCallPart(tool_call_id=tool_call_id): - # 创建工具调用唯一标识与片段索引映射 - tool_call_ids[tool_call_id] = index - - dialog.thoughts[index] = Thought( - type="tool_search", - content="正在生成检索关键词", - ) - - # 能力加载分片开始事件 - case LoadCapabilityCallPart(tool_call_id=tool_call_id): - tool_call_ids[tool_call_id] = index - - dialog.thoughts[index] = Thought( - type="capability_load", - content="正在生成加载参数", - ) - - # 工具调用分片开始事件 - case ToolCallPart(tool_call_id=tool_call_id): - tool_call_ids[tool_call_id] = index - - dialog.thoughts[index] = Thought( - type="tool_call", - content="正在生成调用参数", - ) - - # 文本分片开始事件 - case TextPart(content=content): - dialog.result_output = content - - # ========== 增量事件 ========== - case PartDeltaEvent(index=index, delta=delta): - match delta: - # 思考分片增量事件 - case ThinkingPartDelta( - content_delta=content_delta, - ): - dialog.thoughts[index].content += content_delta or "" - - # 文本分片增量事件 - case TextPartDelta( - content_delta=content_delta, - ): - dialog.result_output += content_delta - - # ========== 结束事件 ========== - case PartEndEvent( - index=index, - part=part, - next_part_kind=next_part_kind, - ): - match part: - # 思考分片结束事件 - case ThinkingPart(content=content): - # 若下一分片种类为文本则将思考状态设置为思考完成 - if next_part_kind == "text": - dialog.is_thinking = False - - # ========== 函数工具调用事件 ========== - 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 = "正在检索" - - # 能力加载 - case "capability_load": - dialog.thoughts[index].content = ( - f"正在加载能力 {part.tool_name}" - ) - - # 工具调用 - case "tool_call": - dialog.thoughts[index].content = ( - f"正在调用工具 {part.tool_name}" - ) - - # ========== 函数工具结果事件 ========== - 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 = "已加载" - - # 工具调用 - case "tool_call": - dialog.thoughts[index].content = f"已调用" - - # ========== 智能体运行结果事件 ========== - case AgentRunResultEvent(result=result): - # 获取数据库状态 - # 补全对话记录 - await self._db_state.complete_dialog_record( - id=dialog.id, - thoughts=dialog.thoughts, - result_output=dialog.result_output, - ) - # 创建结果记录 - await self._db_state.create_result_record( - conversation_id=self.conversation_id, - dialog_id=dialog.id, - new_messages=result.new_messages(), - ) - # 将正在运行设置为否 - conversation.is_running = False - - # 强制更新会话并推送前端 - self.conversations[self.conversation_id] = conversation - yield - @rx.event async def handle_user_prompt(self) -> AsyncGenerator[None]: """ @@ -374,16 +209,15 @@ class ConversationState(rx.State): if not self.user_prompt: return - if not self._db_state: - return - + # 获取数据库状态 + db_state = await self.get_db_state() # 当前会话 conversation = self.conversations[self.conversation_id] # 将正在运行设置为是 conversation.is_running = True # 创建对话记录再添加对话实例 conversation.dialogs.update( - await self._db_state.create_dialog_record( + await db_state.create_dialog_record( conversation_id=self.conversation_id, user_prompt=self.user_prompt ) ) @@ -395,36 +229,175 @@ class ConversationState(rx.State): self.conversations[self.conversation_id] = conversation yield - match self.task_type: - # 开放式对话 - case TaskType.CHAT: - async for _ in self.run_stream_events( - conversation=conversation, - dialog=dialog, - agent=chat_agent, - message_history=await self._db_state.get_message_history( - conversation_id=self.conversation_id - ), - ): - yield + # 获取消息历史列表 + message_history = await db_state.get_message_history( + conversation_id=self.conversation_id + ) - case TaskType.GENERATING_PRD: - ... + # 初始化工具调用唯一标识和片段索引映射字典 + tool_call_ids: Dict[str, int] = {} + # 获取运行流式输出事件 + async for event in run_stream_events( + task_type=self.task_type, + user_prompt=dialog.user_prompt, + message_history=message_history, + ): + match event: + # ========== 开始事件 ========== + case PartStartEvent( + index=index, + part=part, + previous_part_kind=previous_part_kind, + ): + match part: + # 思考分片开始事件 + case ThinkingPart(content=content): + # 若上一分片种类为空则将正在思考设置为正在思考 + if not previous_part_kind: + dialog.is_thinking = True + dialog.thoughts[index] = Thought( + type="thinking", content=content + ) + + # 工具检索分片开始事件 + case ToolSearchCallPart(tool_call_id=tool_call_id): + # 创建工具调用唯一标识与片段索引映射 + tool_call_ids[tool_call_id] = index + + dialog.thoughts[index] = Thought( + type="tool_search", + content="正在生成检索关键词", + ) + + # 能力加载分片开始事件 + case LoadCapabilityCallPart(tool_call_id=tool_call_id): + tool_call_ids[tool_call_id] = index + + dialog.thoughts[index] = Thought( + type="capability_load", + content="正在生成加载参数", + ) + + # 工具调用分片开始事件 + case ToolCallPart(tool_call_id=tool_call_id): + tool_call_ids[tool_call_id] = index + + dialog.thoughts[index] = Thought( + type="tool_call", + content="正在生成调用参数", + ) + + # 文本分片开始事件 + case TextPart(content=content): + dialog.result_output = content + + # ========== 增量事件 ========== + case PartDeltaEvent(index=index, delta=delta): + match delta: + # 思考分片增量事件 + case ThinkingPartDelta( + content_delta=content_delta, + ): + dialog.thoughts[index].content += content_delta or "" + + # 文本分片增量事件 + case TextPartDelta( + content_delta=content_delta, + ): + dialog.result_output += content_delta + + # ========== 结束事件 ========== + case PartEndEvent( + index=index, + part=part, + next_part_kind=next_part_kind, + ): + match part: + # 思考分片结束事件 + case ThinkingPart(content=content): + # 若下一分片种类为文本则将思考状态设置为思考完成 + if next_part_kind == "text": + dialog.is_thinking = False + + # ========== 函数工具调用事件 ========== + 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 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 AgentRunResultEvent(result=result): + # 获取数据库状态 + # 补全对话记录 + await db_state.complete_dialog_record( + id=dialog.id, + thoughts=dialog.thoughts, + result_output=dialog.result_output, + ) + # 创建结果记录 + await db_state.create_result_record( + conversation_id=self.conversation_id, + dialog_id=dialog.id, + new_messages=result.new_messages(), + ) + # 将正在运行设置为否 + conversation.is_running = False + + # 强制更新会话并推送前端 + self.conversations[self.conversation_id] = conversation + yield @rx.event async def generate_prd(self) -> AsyncGenerator[None]: """ 生成产品需求文档 """ - if not self._db_state: - return - # 当前会话 conversation = self.conversations[self.conversation_id] + # 获取数据库状态 + db_state = await self.get_db_state() # 创建对话记录再添加对话实例 conversation.dialogs.update( - await self._db_state.create_dialog_record( + await db_state.create_dialog_record( conversation_id=self.conversation_id, result_output="请输入产品需求" ) ) diff --git a/agent/application/tasks/__init__.py b/agent/application/tasks/__init__.py new file mode 100644 index 0000000..62878bd --- /dev/null +++ b/agent/application/tasks/__init__.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +""" +任务模块 +""" +from typing import Any, AsyncGenerator, Callable, Dict, Optional +from typing import AsyncGenerator, List + +from pydantic import BaseModel +from pydantic_ai import Agent, ModelMessage +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.providers.openai import OpenAIProvider + +from application.domain_models import Dialog, TaskType +from models import DEEPSEEK_V4_FLASH_MODEL + +instruction = """ +# 角色 +专业友好AI助手,结构化解答各类问题。 + +# 输出硬性规则 +1. 全文强制标准Markdown,禁止纯文本;不要额外说明排版格式,直接输出内容; +2. 层级使用 `#/##/###`,列表用 `-` 无序列表或数字有序列表; +3. 代码块用 ```语言名``` 包裹; +4. 重点内容标注 **粗体**/*斜体*; +5. 思考、工具日志仅输出文本,适配前端折叠面板,禁止输出HTML标签; +6. 内容分点拆分,排版整洁适配前端Markdown渲染。 + +# 行文要求 +语言通俗,逻辑完整简洁,无多余废话。 +""" +async def run_stream_events( + task_type: TaskType, + user_prompt: str, + message_history: List[ModelMessage], +) -> AsyncGenerator: + """ + 以流式事件模式运行 + """ + match task_type: + case TaskType.CHAT: + agent = Agent( + model=DEEPSEEK_V4_FLASH_MODEL, + instructions=instruction, + ) + async with agent.run_stream_events( + user_prompt=user_prompt, + message_history=message_history, + ) as event_stream: + async for event in event_stream: + yield event + + case "flight": + yield "未知任务类型" \ No newline at end of file diff --git a/agent/application/tasks/generating_prd_agent.py b/agent/application/tasks/generating_prd_agent.py new file mode 100644 index 0000000..9522a15 --- /dev/null +++ b/agent/application/tasks/generating_prd_agent.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +""" +生成产品需求文档智能体 +""" +from dataclasses import dataclass +import datetime +from enum import Enum +from typing import Any, AsyncGenerator, Optional, Literal + +from pydantic import Any, BaseModel, Enum, Field, Optional +from pydantic_ai import ( + Agent, + ModelMessage, + ModelRetry, + RunContext, + RunUsage, + UsageLimits, +) +from pydantic_ai.usage import RunUsage + +from models import DEEPSEEK_V4_FLASH_MODEL + +#from application.tasks.agent import Agent +# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured + + +class FlightDetails(BaseModel): + """Details of the most suitable flight.""" + flight_number: str + price: int + origin: str = Field(description='Three-letter airport code') + destination: str = Field(description='Three-letter airport code') + date: datetime.date +class NoFlightFound(BaseModel): + """When no valid flight is found.""" + +@dataclass +class Deps: + web_page_text: str + req_origin: str + req_destination: str + req_date: datetime.date + +# This agent is responsible for controlling the flow of the conversation. +search_agent= Agent[Deps, FlightDetails | NoFlightFound]( + model=DEEPSEEK_V4_FLASH_MODEL, + output_type=FlightDetails | NoFlightFound, + deps_type=Deps, + retries=4, + system_prompt=( + 'Your job is to find the cheapest flight for the user on the given date. ' + ), +) +# This agent is responsible for extracting flight details from web page text. +extraction_agent = Agent( + output_type=list[FlightDetails], + system_prompt='Extract all the flight details from the given text.', +) +@search_agent.tool +async def extract_flights(ctx: RunContext[Deps]) -> list[FlightDetails]: + """Get details of all flights.""" + # we pass the usage to the search agent so requests within this agent are counted + result = await extraction_agent.run(ctx.deps.web_page_text, usage=ctx.usage) + return result.output + +@search_agent.output_validator +async def validate_output( + ctx: RunContext[Deps], output: FlightDetails | NoFlightFound +) -> FlightDetails | NoFlightFound: + """Procedural validation that the flight meets the constraints.""" + if isinstance(output, NoFlightFound): + return output + errors: list[str] = [] + if output.origin != ctx.deps.req_origin: + errors.append( + f'Flight should have origin {ctx.deps.req_origin}, not {output.origin}' + ) + if output.destination != ctx.deps.req_destination: + errors.append( + f'Flight should have destination {ctx.deps.req_destination}, not {output.destination}' + ) + if output.date != ctx.deps.req_date: + errors.append(f'Flight should be on {ctx.deps.req_date}, not {output.date}') + if errors: + raise ModelRetry('\n'.join(errors)) + else: + return output +class SeatPreference(BaseModel): + row: int = Field(ge=1, le=30) + seat: Literal['A', 'B', 'C', 'D', 'E', 'F'] +class Failed(BaseModel): + """Unable to extract a seat selection.""" +# This agent is responsible for extracting the user's seat selection +seat_preference_agent = Agent[object, SeatPreference | Failed]( + 'openai:gpt-5.2', + output_type=SeatPreference | Failed, + system_prompt=( + "Extract the user's seat preference. " + 'Seats A and F are window seats. ' + 'Row 1 is the front row and has extra leg room. ' + 'Rows 14, and 20 also have extra leg room. ' + ), +) +# in reality this would be downloaded from a booking site, +# potentially using another agent to navigate the site +flights_web_page = """ +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 +""" +# restrict how many requests this app can make to the LLM +usage_limits = UsageLimits(request_limit=15) + +async def flight_booking( + state: FlightFlowState, + user_prompt: Optional[str] +) -> AsyncGenerator: + if state.stage == FlowStage.EXEC: + prompt = f'Find me a flight from {state.deps.req_origin} to {state.deps.req_destination} on {state.deps.req_date}' + run_result = None + + # 1. 模型流式查询航班 + async with search_agent.run_stream_events( + user_prompt=prompt, + deps=state.deps, + message_history=state.message_history, + usage_limits=state.usage_limits, + ) as stream: + async for evt in stream: + yield evt + run_result = stream.result + + # 2. 保存本轮模型对话到数据库(核心:上下文持久化,防止断裂) + if run_result is not None: + # 写入对话历史,下一轮get_message_history可以读到航班内容 + await db_state.create_result_record( + conversation_id=state.conversation_id, + dialog_id=state.dialog_id, + new_messages=run_result.new_messages(), + ) + # 用量回填 + state.usage = run_result.usage + + # 3. 判断业务结果分支 + if isinstance(run_result.output, NoFlightFound): + # 无航班场景 + state.stage = FlowStage.FINISH + yield AgentStreamEvent.text_event("未找到符合条件的航班,预订流程结束") + return + else: + # ✅ 查询到航班,推送业务选择提示(前端展示按钮/文字提示) + tip_text = "已查询到航班,请回复 buy 购票 / search 重新查询" + yield AgentStreamEvent.text_event(tip_text) + + # 【可选】如果需要让AI记住选择指令,把提示作为系统消息入库 + # await db_state.append_system_message(conversation_id=state.conversation_id, content=tip_text) + + # 阶段挂起,等待用户输入buy/search,不直接结束流程 + state.stage = FlowStage.WAIT_USER_INPUT + return \ No newline at end of file diff --git a/agent/application/tasks/models.py b/agent/application/tasks/models.py new file mode 100644 index 0000000..a9ad39b --- /dev/null +++ b/agent/application/tasks/models.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +""" +智能体相关模块 +""" +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.providers.openai import OpenAIProvider + + +DEEPSEEK_V4_FLASH_MODEL = OpenAIChatModel( + model_name="deepseek-v4-flash", + provider=OpenAIProvider( + base_url="https://tokenhub.tencentmaas.com/v1", + api_key="sk-D9Y1mCe8VlvNqLuSC4mAjqEwxJ2nW4C0h8a7EPn8kg9RLsHq", + ), +) diff --git a/agent/database.db b/agent/database.db index 9efe3dd30775acf567e6e1bf5100d9a23129d046..cf1a9783c1cdc9f72718b468f360d8fd22151ddc 100644 GIT binary patch delta 1610 zcmbtUOKcle6!qAX+BmjS3#ULrVuPv>1k=v{(@L}wU4f`nN{SF}=!`u^DyRY}L6sni z^LLsAW2WSV#`!iOp@Mws5a(kzAR!egLTxv&XcwsUj2(61E+Zs9cjAJSF47{TnY%c1 z@44rmci%)ty^-I%pH-HmEf&l1xc?BAo+qH(0Ijb~lclE4V-?z0g?d}1{i#d zZGMx~-d(S1Q#QMlO$=UZO4+oH|LU}2ehYRM!cXz|%8Ms_yXxwm^0@C$wBzX7jaz%! zY-UuS%zWH*qIz55j#ik3j(-YMXK&!qmz-`G5Odd9+%+)O)9(EpZ~7ddr0cELmmgIN zay8iYqr-!^YY#_>p+FuD>z|o?a!wA3@ z4yk~sDoBA^5Mc^J*MG5kQLMq{LPsxH-DQjlZCF)>#%3`hN!+_f3=3QG1R&rAQUbD& z%pjS?%Tez~HVT+&1rgwGf6J2^bqEGeYTh8TLm$R#p>~b`_(pkt1L%uOEyKy0Aw8!8pJcV>` zer*6iqb-@A`Pzua^`#kL%THa@JCk}xDx1+Z(9Fj8Kp{Qyygrx7&5Y$|7oZGMO(L|Y zC#JXL44m6KYrDr{_rO`(?T+~Sl_hm`v{Y83~W(k2-NJ5k-h7i~wTxTK3%XA2h z6>RnBuXbxyv)nrC?r_p$JZOG_t(PKSuCjrr8(R^D*yLPu6 zHPqW$njct-z?uKJ6@fGVXD#&Vo!0_=@HQ5<(X@a*d<{R0=BuplVJsST;Rikc9;CRB zg1bnv=#NkEPwn^N{t=AdE6NHdORPkQvMdm+NQ;Cbaq!z#D3W9qo>5sE{csX{?valF GoxcGojjN#m delta 181 zcmZp8AkwfvWP&v74h9B>z=;a>j5{_aELqPX%)byQz~Hx;=Ya*wW(9$6w$0~von>LI zeb%(%>Ewm`lbL0?VI%$;;lyF-viXPG