| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 |
- # 【启动 S2】独立 MCP 工具服务,监听 8003;供 A2A 子助手调用。
- # 本文件负责执行工具,不调用聊天模型来决定下一步;工具名和参数由上游传入。
- # 启动顺序:创建 FastMCP → 创建业务服务 → 注册工具 → 转成 FastAPI 应用 → 监听端口。
- # 主流程 8:收到工具调用 → 进入对应包装函数 → 业务方法访问数据源/生成模拟结果
- # → 返回工具结果 → A2A 执行器交给模型整理 → 主流程 9 返回主助手。
- import json # JSON 处理
- from datetime import date, datetime, timedelta # 时间处理
- from decimal import Decimal # 高精度小数类型
- import mysql.connector # MySQL 数据库驱动
- import requests # HTTP 请求,用于调用 Qwen Embedding API
- import uvicorn # ASGI 服务器,用于运行 FastAPI 应用
- from pymilvus import Collection, connections # Milvus 向量数据库客户端
- from python_a2a import ( # MCP 框架:FastMCP 用于定义工具,create_fastapi_app 转为 FastAPI 服务
- FastMCP,
- create_fastapi_app,
- )
- from config import Config # 项目配置
- from create_logger import logger # 日志模块
- from utils.format import DateEncoder, default_encoder # 日期格式化工具
- conf = Config()
- def get_embedding(text: str) -> list:
- """调用 Qwen Embedding API 生成文本向量"""
- # 【检索内部步骤 2】把查询文本发给向量模型,得到可与套餐向量比较的一组数值。
- # 查询和入库需使用一致的模型与维度;向量接口不同于聊天模型接口。
- # 调用者:search_meeting_packages_in_milvus。输入是一段用户需求文本,输出是浮点数列表。
- # payload.input 虽是列表,此处只放一条 text,因此最后读取 data[0] 的 embedding。
- import requests
- headers = {
- "Authorization": f"Bearer {conf.embedding_api_key}",
- "Content-Type": "application/json"
- }
- payload = {
- "model": "text-embedding-v3",
- "input": [text],
- "dimensions": conf.embedding_dim
- }
- # POST 将 payload 编码为 JSON,Authorization 使用独立的向量服务凭证。
- # 这里的请求发生在真正检索时,创建 TripService 本身不会提前生成这些向量。
- response = requests.post(conf.embedding_url, headers=headers, json=payload, timeout=30)
- response.raise_for_status()
- return response.json()["data"][0]["embedding"]
- def search_meeting_packages_in_milvus(query_text: str, city: str = None, limit: int = 5) -> list:
- """在 Milvus 中进行语义搜索,找到最匹配的会务套餐"""
- # 【检索内部步骤 1】连接并加载已建好的集合;本函数不会自动创建或灌入套餐数据。
- # 输入:query_text 描述套餐需求,city 是可选城市,limit 限制最多返回多少个候选。
- # 输出:普通 Python 字典列表,尚未 json.dumps;外层 query_meeting_package 会再包装状态并序列化。
- # 本函数检索 sql/init_meeting_package_rag.py 事先写入的集合,不从 MySQL 动态同步套餐。
- from pymilvus import connections, Collection
- connections.connect(alias="default", host=conf.milvus_host, port=conf.milvus_port)
- collection = Collection(conf.meeting_package_collection)
- # 把集合准备为可查询状态;Collection(name) 只是取得集合对象,并未创建新套餐数据。
- collection.load()
- # 【检索内部步骤 2 → 3】先求查询向量,再按 COSINE 相似度取前 limit 条。
- # city 有值时额外过滤城市;相似度是检索分数,不能当成事实正确率或预订成功率。
- results = collection.search(
- # search 的 data 是“查询向量列表”;当前只有一个查询向量,所以外层结果通常只有一组命中。
- data=[get_embedding(query_text)],
- # 指定对 embedding 向量字段检索;COSINE 与初始化脚本建立索引时的度量方式保持一致。
- anns_field="embedding",
- param={"metric_type": "COSINE", "params": {"nprobe": 10}},
- limit=limit,
- output_fields=["package_id", "package_name", "city", "days", "price",
- "total_seats", "remaining_seats", "agency", "rating",
- "departure_dates", "highlights"],
- # 城市不为空才追加标量过滤;没有城市就传 None,允许在整个集合内检索。
- # 城市值先做 JSON 字符串转义,避免引号改变过滤表达式的结构。
- expr=f"city == {json.dumps(city, ensure_ascii=False)}" if city else None
- )
- # 【检索内部步骤 4】从命中项选取返回字段;虽然检索请求取了更多字段,实际只输出下列字段。
- package_list = []
- # 外层循环遍历每个查询向量对应的命中组,内层遍历该组候选;hit.entity 读取存储字段。
- for hits in results:
- for hit in hits:
- package_list.append({
- "package_id": hit.entity.get("package_id"),
- "package_name": hit.entity.get("package_name"),
- "city": hit.entity.get("city"),
- "days": hit.entity.get("days"),
- "price": hit.entity.get("price"),
- # COSINE 下 hit.distance 表示该检索度量的分数;四舍五入到四位用于展示,不参与二次筛选。
- "similarity": round(hit.distance, 4),
- })
- return package_list
- # ==================== 差旅服务类 ====================
- class TripService:
- """
- 差旅查询与预订服务类
- 混合数据源架构:
- - 商务用车、保险:从 MySQL 数据库真实查询
- - 会务套餐:从 Milvus 向量数据库语义检索(RAG)
- """
- def __init__(self):
- # 建立 MySQL 数据库连接
- # 【启动 S2.2 内部】建立并保存连接;后续查询复用 self.conn。
- # 构造函数由服务启动函数调用,一次建立数据库连接并放入 self.conn。
- # self 是业务服务对象;MCP 包装函数接收参数后调用的是这个对象的方法。
- # 成功返回连接对象只说明连接建立,具体表是否存在、查询是否有数据还要等 execute。
- self.conn = mysql.connector.connect(
- host=conf.host,
- user=conf.user,
- password=conf.password,
- database=conf.database
- )
- def _execute_query(self, sql: str, params: list = None) -> str:
- """
- 执行 SQL 查询,返回 JSON 格式的结果
- 参数:
- sql (str): SQL 查询语句,使用 %s 占位符
- params (list): SQL 参数列表
- 返回值:
- str: JSON 字符串
- """
- # 【主流程 8.3,数据库内部步骤 1】创建字典游标,SQL 与参数分别传给 execute。
- # %s 是数据库驱动的参数占位符;参数顺序要与 SQL 中占位符顺序一致。
- try:
- # 游标是这一次数据库操作的执行入口;dictionary=True 让每行结果按列名取值。
- # 例:{"city": "北京", "fx_date": 日期对象},比按 results[0][1] 这样的列位置读取更直观。
- cursor = self.conn.cursor(dictionary=True)
- # SQL 字符串描述查哪些表和条件,参数列表只提供值,二者由驱动组合。
- # 不要把这里的 %s 当成 Python 字符串格式化去替换;当前代码没有将用户输入拼进 SQL 语法。
- cursor.execute(sql, params or [])
- # 【主流程 8.3,数据库内部步骤 2】取回全部匹配行,再关闭此次查询的游标。
- results = cursor.fetchall()
- # 关闭当前游标,self.conn 仍保留供下一次工具调用使用;这里没有关闭整条数据库连接。
- cursor.close()
- # 处理特殊类型(日期、Decimal 等)
- # 【主流程 8.3,数据库内部步骤 3】把日期/时间转为字符串,Decimal 转为 float。
- # 这些值随后才能放入 JSON;转换 Decimal 为 float 可能丢失部分精度。
- for result in results:
- # 外层循环逐行,内层循环逐列。items() 每次给出列名 key 和该列的值 value。
- # 给 result[key] 重新赋值是在替换该列的 Python 表示,没有修改数据库里的原始数据。
- for key, value in result.items():
- if isinstance(value, (date, datetime, timedelta, Decimal)):
- result[key] = default_encoder(value)
- if results:
- response = {"status": "success", "data": results}
- else:
- response = {"status": "no_data", "message": "未找到差旅数据,请确认查询条件。"}
- # 【主流程 8.4】把 success/no_data 和数据序列化为 JSON 文本交回工具调用方。
- # 异常分支返回 error 文本;工具成功返回文本本身不代表查询有结果。
- return json.dumps(response, cls=DateEncoder, ensure_ascii=False)
- # 查询、取值或序列化出错时进入异常分支,返回含 status="error" 的 JSON 文本。
- # 由于错误被转换为普通返回值,上层还需要检查内容,不能只根据“函数正常返回”判断业务成功。
- except Exception as e:
- logger.error(f"差旅查询错误: {str(e)}")
- return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)
- # ========== 商务用车查询(MySQL) ==========
- def query_car_rental(self, pickup_city: str, return_city: str, date: str, car_type: str = None) -> str:
- """
- 查询商务用车信息(从 MySQL car_rentals 表查询)
- """
- # 【主流程 8.2】将已传入的参数组织成固定 SQL 和参数列表。
- # 调用者:同名 MCP 包装函数。输入:取车城市、还车城市、取车日期;输出:查询结果的 JSON 字符串。
- # 数据源:MySQL 的 car_rentals 表,SQL 写在代码中,由参数决定筛选值。
- # pickup_city 与 return_city 可以不同;car_type 只有有值时才增加车型筛选。
- logger.info(f"查询商务用车: {pickup_city} -> {return_city}, {date}, {car_type}")
- sql = ("SELECT id, company, pickup_city, return_city, pickup_date, car_type, "
- "car_model, price_per_day, total_available, transmission, seats, deposit "
- "FROM car_rentals "
- "WHERE pickup_city = %s AND return_city = %s AND pickup_date = %s")
- # 参数列表的顺序与 SQL 占位符顺序一致;列表中的日期仍按传入值交给数据库比较。
- params = [pickup_city, return_city, date]
- # 可选条件有值才追加;同时追加参数,保证条件与参数一一对应。
- if car_type:
- sql += " AND car_type = %s"
- # 必须在追加可选 SQL 条件时同时追加对应值,否则占位符数量与参数数量会不一致。
- params.append(car_type)
- # 【主流程 8.3】进入通用查询方法;结果沿当前调用栈原路返回 MCP 包装函数。
- return self._execute_query(sql, params)
- # ========== 会务套餐查询(Milvus RAG) ==========
- def query_meeting_package(self, query_text: str, city: str = None) -> str:
- """
- 查询会务套餐信息(从 Milvus 向量数据库进行语义检索)
- 参数:
- query_text (str): 用户查询描述,如"成都办数字化转型峰会,需要接送和酒店"、"适合销售团队的短途会务套餐"
- city (str, optional): 城市过滤条件
- 返回值:
- str: JSON 字符串,包含匹配的会务套餐列表
- """
- # 【主流程 8.2:会务套餐分支】进入 Milvus 语义检索;用车与保险则走 MySQL。
- # 这里只检索候选套餐,不调用聊天模型生成回答;结果交给 A2A 子助手再整理。
- # 输入 query_text 负责语义相似度,city 负责精确城市过滤,两者用途不同。
- # 固定 limit=5 最多取五个候选;列表非空返回 success,空列表返回 no_data,抛异常返回 error。
- # 本实现没有另外设置相似度阈值,因而返回候选不等于每条都满足用户全部要求。
- logger.info(f"查询会务套餐: query='{query_text}', city='{city}'")
- try:
- package_list = search_meeting_packages_in_milvus(query_text, city, limit=5)
- if package_list:
- return json.dumps({"status": "success", "data": package_list}, ensure_ascii=False)
- else:
- return json.dumps({
- "status": "no_data",
- "message": f"未找到匹配的会务套餐。{'请确认城市名称,' if city else ''}或尝试其他搜索条件。"
- }, ensure_ascii=False)
- except Exception as e:
- logger.error(f"会务套餐 RAG 查询错误: {str(e)}")
- return json.dumps({"status": "error", "message": f"会务套餐查询出错:{str(e)}"}, ensure_ascii=False)
- # ========== 保险查询(MySQL) ==========
- def query_insurance(self, insurance_type: str = None) -> str:
- """
- 查询差旅保险产品信息(从 MySQL insurances 表查询)
- """
- # 【主流程 8.2】将已传入的参数组织成固定 SQL 和参数列表。
- # 调用者:同名 MCP 包装函数。输入:可选保险类型;输出:查询结果的 JSON 字符串。
- # 数据源:MySQL 的 insurances 表,SQL 写在代码中,由参数决定筛选值。
- # 无类型参数时查询表内所有产品;有类型时追加 WHERE insurance_type = %s。
- logger.info(f"查询保险: {insurance_type}")
- sql = ("SELECT id, insurance_type, name, company, coverage, price, "
- "duration_days, max_coverage, medical_coverage, baggage_coverage, flight_delay "
- "FROM insurances")
- params = []
- # 可选条件有值才追加;同时追加参数,保证条件与参数一一对应。
- if insurance_type:
- sql += " WHERE insurance_type = %s"
- # 必须在追加可选 SQL 条件时同时追加对应值,否则占位符数量与参数数量会不一致。
- params.append(insurance_type)
- # 【主流程 8.3】进入通用查询方法;结果沿当前调用栈原路返回 MCP 包装函数。
- return self._execute_query(sql, params)
- # ========== 预订方法 ==========
- def order_car_rental(self, date: str, car_type: str, number: int) -> str:
- """预订商务用车"""
- # 【主流程 8.2:模拟预订分支】只构造演示文本(部分方法同时记日志)。
- # 这里没有写订单、扣库存、支付、出票或生成保单的动作。
- # 参数用于填充下方 f-string,例如日期、名称、类型和数量会直接显示在演示回复里。
- # 返回字符串后本方法立即结束;它没有再调用查询方法核对库存,也没有生成可持久化的订单对象。
- logger.info(f"预订商务用车: {date}, {car_type}, {number}辆")
- # 【新改动2】明确返回模拟结果,避免被解释为真实下单或库存扣减。
- return f"模拟预订成功,未实际下单,未创建订单,未扣减库存。日期:{date},车型:{car_type},数量:{number}辆。"
- def order_meeting_package(self, date: str, package_name: str, number: int) -> str:
- """预订会务套餐"""
- # 【主流程 8.2:模拟预订分支】只构造演示文本(部分方法同时记日志)。
- # 这里没有写订单、扣库存、支付、出票或生成保单的动作。
- # 参数用于填充下方 f-string,例如日期、名称、类型和数量会直接显示在演示回复里。
- # 返回字符串后本方法立即结束;它没有再调用查询方法核对库存,也没有生成可持久化的订单对象。
- logger.info(f"预订会务套餐: {date}, {package_name}, {number}人")
- return f"会务套餐预订模拟成功,未实际下单、未创建订单、未扣减库存、未出票或生成保单。套餐名称:{package_name},日期:{date},人数:{number}人。"
- def order_insurance(self, insurance_type: str, date: str, number: int) -> str:
- """购买差旅保险"""
- # 【主流程 8.2:模拟预订分支】只构造演示文本(部分方法同时记日志)。
- # 这里没有写订单、扣库存、支付、出票或生成保单的动作。
- # 参数用于填充下方 f-string,例如日期、名称、类型和数量会直接显示在演示回复里。
- # 返回字符串后本方法立即结束;它没有再调用查询方法核对库存,也没有生成可持久化的订单对象。
- logger.info(f"购买保险: {insurance_type}, {date}, {number}份")
- return f"差旅保险购买模拟成功,未实际下单、未创建订单、未扣减库存、未出票或生成保单。类型:{insurance_type},日期:{date},份数:{number}份。"
- # ========== 注册查询工具 ==========
- def create_trip_mcp_server():
- """
- 创建并启动差旅管家 MCP 服务器
- """
- # 【启动 S2.1】创建工具注册表。下面的 @tool 在定义包装函数时完成注册。
- # 注册时不会运行包装函数体;只有收到该工具的调用请求才执行函数体。
- # 这是服务启动函数。内部定义的工具包装函数会记住当前的 service 对象,后续每次调用可继续使用它。
- # 这叫闭包:包装函数虽然在外层函数内部定义,却能读取外层的 service。
- # FastMCP 注册这些函数后,HTTP 请求按工具名选择其中一个,不按源代码排列顺序逐个执行。
- trip_mcp = FastMCP(name="TripTools")
- # 【启动 S2.2】创建可被所有工具包装函数引用的业务服务,并建立 MySQL 连接。
- service = TripService()
- @trip_mcp.tool(
- name="query_car_rental",
- description="查询商务用车信息,参数:pickup_city(取车城市), return_city(还车城市), date(日期,格式YYYY-MM-DD), car_type(车型类型,可选:经济型/SUV/豪华型/MPV)"
- )
- def query_car_rental(pickup_city: str, return_city: str, date: str, car_type: str = None) -> str:
- # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
- # 调用者:MCP 框架按工具名 query_car_rental 分派到这里。
- # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
- # 同名的 TripService.query_car_rental 是业务方法;当前嵌套函数只是对外暴露的工具入口。
- return service.query_car_rental(pickup_city, return_city, date, car_type)
- @trip_mcp.tool(
- name="query_meeting_package",
- description="查询会务套餐信息(语义搜索),参数:query_text(查询描述,如'成都办数字化转型峰会,需要接送和酒店'),city(城市过滤,可选)"
- )
- def query_meeting_package(query_text: str, city: str = None) -> str:
- # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
- # 调用者:MCP 框架按工具名 query_meeting_package 分派到这里。
- # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
- # 同名的 TripService.query_meeting_package 是业务方法;当前嵌套函数只是对外暴露的工具入口。
- return service.query_meeting_package(query_text, city)
- @trip_mcp.tool(
- name="query_insurance",
- description="查询差旅保险产品,参数:insurance_type(保险类型,可选:综合型/意外型/医疗型/境外型)"
- )
- def query_insurance(insurance_type: str = None) -> str:
- # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
- # 调用者:MCP 框架按工具名 query_insurance 分派到这里。
- # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
- # 同名的 TripService.query_insurance 是业务方法;当前嵌套函数只是对外暴露的工具入口。
- return service.query_insurance(insurance_type)
- # ========== 注册预订工具 ==========
- # 【新改动2】向智能体说明该工具仅用于模拟测试,可以响应模拟预订请求。
- @trip_mcp.tool(
- name="order_car_rental",
- description="根据日期、车型和数量模拟预订商务用车,仅用于测试,不实际下单、不创建订单、不扣减库存。参数:date(商务用车日期,格式YYYY-MM-DD), car_type(车型,如经济型/SUV/豪华型/MPV), number(商务用车数量)"
- )
- def order_car_rental(date: str, car_type: str, number: int) -> str:
- # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
- # 调用者:MCP 框架按工具名 order_car_rental 分派到这里。
- # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
- # 同名的 TripService.order_car_rental 是业务方法;当前嵌套函数只是对外暴露的工具入口。
- logger.info(f"正在预订商务用车: {date}, {car_type}, {number}辆")
- return service.order_car_rental(date, car_type, number)
- @trip_mcp.tool(
- name="order_meeting_package",
- description="仅模拟演示,不实际下单、不创建订单、不扣减库存、不出票或生成保单。根据出发日期、会务套餐名称和人数预订会务套餐。参数:date(出发日期,格式YYYY-MM-DD), package_name(会务套餐名称,需与查询结果中的套餐名称一致), number(预订人数)"
- )
- def order_meeting_package(date: str, package_name: str, number: int) -> str:
- # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
- # 调用者:MCP 框架按工具名 order_meeting_package 分派到这里。
- # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
- # 同名的 TripService.order_meeting_package 是业务方法;当前嵌套函数只是对外暴露的工具入口。
- logger.info(f"正在预订会务套餐: {date}, {package_name}, {number}人")
- return service.order_meeting_package(date, package_name, number)
- @trip_mcp.tool(
- name="order_insurance",
- description="仅模拟演示,不实际下单、不创建订单、不扣减库存、不出票或生成保单。根据保险类型、生效日期和份数购买差旅保险。参数:insurance_type(保险类型,如综合型/意外型/医疗型/境外型), date(生效日期,格式YYYY-MM-DD), number(购买份数)"
- )
- def order_insurance(insurance_type: str, date: str, number: int) -> str:
- # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
- # 调用者:MCP 框架按工具名 order_insurance 分派到这里。
- # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
- # 同名的 TripService.order_insurance 是业务方法;当前嵌套函数只是对外暴露的工具入口。
- logger.info(f"正在购买保险: {insurance_type}, {date}, {number}份")
- return service.order_insurance(insurance_type, date, number)
- # 【启动 S2.3】工具注册结束后构建 HTTP 应用;下一行 uvicorn.run 开始监听并等待请求。
- app = create_fastapi_app(mcp_server=trip_mcp)
- uvicorn.run(app, host="0.0.0.0", port=8003)
- if __name__ == '__main__':
- create_trip_mcp_server()
|