mcp_trip_server.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. # 【启动 S2】独立 MCP 工具服务,监听 8003;供 A2A 子助手调用。
  2. # 本文件负责执行工具,不调用聊天模型来决定下一步;工具名和参数由上游传入。
  3. # 启动顺序:创建 FastMCP → 创建业务服务 → 注册工具 → 转成 FastAPI 应用 → 监听端口。
  4. # 主流程 8:收到工具调用 → 进入对应包装函数 → 业务方法访问数据源/生成模拟结果
  5. # → 返回工具结果 → A2A 执行器交给模型整理 → 主流程 9 返回主助手。
  6. import json # JSON 处理
  7. from datetime import date, datetime, timedelta # 时间处理
  8. from decimal import Decimal # 高精度小数类型
  9. import mysql.connector # MySQL 数据库驱动
  10. import requests # HTTP 请求,用于调用 Qwen Embedding API
  11. import uvicorn # ASGI 服务器,用于运行 FastAPI 应用
  12. from pymilvus import Collection, connections # Milvus 向量数据库客户端
  13. from python_a2a import ( # MCP 框架:FastMCP 用于定义工具,create_fastapi_app 转为 FastAPI 服务
  14. FastMCP,
  15. create_fastapi_app,
  16. )
  17. from config import Config # 项目配置
  18. from create_logger import logger # 日志模块
  19. from utils.format import DateEncoder, default_encoder # 日期格式化工具
  20. conf = Config()
  21. def get_embedding(text: str) -> list:
  22. """调用 Qwen Embedding API 生成文本向量"""
  23. # 【检索内部步骤 2】把查询文本发给向量模型,得到可与套餐向量比较的一组数值。
  24. # 查询和入库需使用一致的模型与维度;向量接口不同于聊天模型接口。
  25. # 调用者:search_meeting_packages_in_milvus。输入是一段用户需求文本,输出是浮点数列表。
  26. # payload.input 虽是列表,此处只放一条 text,因此最后读取 data[0] 的 embedding。
  27. import requests
  28. headers = {
  29. "Authorization": f"Bearer {conf.embedding_api_key}",
  30. "Content-Type": "application/json"
  31. }
  32. payload = {
  33. "model": "text-embedding-v3",
  34. "input": [text],
  35. "dimensions": conf.embedding_dim
  36. }
  37. # POST 将 payload 编码为 JSON,Authorization 使用独立的向量服务凭证。
  38. # 这里的请求发生在真正检索时,创建 TripService 本身不会提前生成这些向量。
  39. response = requests.post(conf.embedding_url, headers=headers, json=payload, timeout=30)
  40. response.raise_for_status()
  41. return response.json()["data"][0]["embedding"]
  42. def search_meeting_packages_in_milvus(query_text: str, city: str = None, limit: int = 5) -> list:
  43. """在 Milvus 中进行语义搜索,找到最匹配的会务套餐"""
  44. # 【检索内部步骤 1】连接并加载已建好的集合;本函数不会自动创建或灌入套餐数据。
  45. # 输入:query_text 描述套餐需求,city 是可选城市,limit 限制最多返回多少个候选。
  46. # 输出:普通 Python 字典列表,尚未 json.dumps;外层 query_meeting_package 会再包装状态并序列化。
  47. # 本函数检索 sql/init_meeting_package_rag.py 事先写入的集合,不从 MySQL 动态同步套餐。
  48. from pymilvus import connections, Collection
  49. connections.connect(alias="default", host=conf.milvus_host, port=conf.milvus_port)
  50. collection = Collection(conf.meeting_package_collection)
  51. # 把集合准备为可查询状态;Collection(name) 只是取得集合对象,并未创建新套餐数据。
  52. collection.load()
  53. # 【检索内部步骤 2 → 3】先求查询向量,再按 COSINE 相似度取前 limit 条。
  54. # city 有值时额外过滤城市;相似度是检索分数,不能当成事实正确率或预订成功率。
  55. results = collection.search(
  56. # search 的 data 是“查询向量列表”;当前只有一个查询向量,所以外层结果通常只有一组命中。
  57. data=[get_embedding(query_text)],
  58. # 指定对 embedding 向量字段检索;COSINE 与初始化脚本建立索引时的度量方式保持一致。
  59. anns_field="embedding",
  60. param={"metric_type": "COSINE", "params": {"nprobe": 10}},
  61. limit=limit,
  62. output_fields=["package_id", "package_name", "city", "days", "price",
  63. "total_seats", "remaining_seats", "agency", "rating",
  64. "departure_dates", "highlights"],
  65. # 城市不为空才追加标量过滤;没有城市就传 None,允许在整个集合内检索。
  66. # 城市值先做 JSON 字符串转义,避免引号改变过滤表达式的结构。
  67. expr=f"city == {json.dumps(city, ensure_ascii=False)}" if city else None
  68. )
  69. # 【检索内部步骤 4】从命中项选取返回字段;虽然检索请求取了更多字段,实际只输出下列字段。
  70. package_list = []
  71. # 外层循环遍历每个查询向量对应的命中组,内层遍历该组候选;hit.entity 读取存储字段。
  72. for hits in results:
  73. for hit in hits:
  74. package_list.append({
  75. "package_id": hit.entity.get("package_id"),
  76. "package_name": hit.entity.get("package_name"),
  77. "city": hit.entity.get("city"),
  78. "days": hit.entity.get("days"),
  79. "price": hit.entity.get("price"),
  80. # COSINE 下 hit.distance 表示该检索度量的分数;四舍五入到四位用于展示,不参与二次筛选。
  81. "similarity": round(hit.distance, 4),
  82. })
  83. return package_list
  84. # ==================== 差旅服务类 ====================
  85. class TripService:
  86. """
  87. 差旅查询与预订服务类
  88. 混合数据源架构:
  89. - 商务用车、保险:从 MySQL 数据库真实查询
  90. - 会务套餐:从 Milvus 向量数据库语义检索(RAG)
  91. """
  92. def __init__(self):
  93. # 建立 MySQL 数据库连接
  94. # 【启动 S2.2 内部】建立并保存连接;后续查询复用 self.conn。
  95. # 构造函数由服务启动函数调用,一次建立数据库连接并放入 self.conn。
  96. # self 是业务服务对象;MCP 包装函数接收参数后调用的是这个对象的方法。
  97. # 成功返回连接对象只说明连接建立,具体表是否存在、查询是否有数据还要等 execute。
  98. self.conn = mysql.connector.connect(
  99. host=conf.host,
  100. user=conf.user,
  101. password=conf.password,
  102. database=conf.database
  103. )
  104. def _execute_query(self, sql: str, params: list = None) -> str:
  105. """
  106. 执行 SQL 查询,返回 JSON 格式的结果
  107. 参数:
  108. sql (str): SQL 查询语句,使用 %s 占位符
  109. params (list): SQL 参数列表
  110. 返回值:
  111. str: JSON 字符串
  112. """
  113. # 【主流程 8.3,数据库内部步骤 1】创建字典游标,SQL 与参数分别传给 execute。
  114. # %s 是数据库驱动的参数占位符;参数顺序要与 SQL 中占位符顺序一致。
  115. try:
  116. # 游标是这一次数据库操作的执行入口;dictionary=True 让每行结果按列名取值。
  117. # 例:{"city": "北京", "fx_date": 日期对象},比按 results[0][1] 这样的列位置读取更直观。
  118. cursor = self.conn.cursor(dictionary=True)
  119. # SQL 字符串描述查哪些表和条件,参数列表只提供值,二者由驱动组合。
  120. # 不要把这里的 %s 当成 Python 字符串格式化去替换;当前代码没有将用户输入拼进 SQL 语法。
  121. cursor.execute(sql, params or [])
  122. # 【主流程 8.3,数据库内部步骤 2】取回全部匹配行,再关闭此次查询的游标。
  123. results = cursor.fetchall()
  124. # 关闭当前游标,self.conn 仍保留供下一次工具调用使用;这里没有关闭整条数据库连接。
  125. cursor.close()
  126. # 处理特殊类型(日期、Decimal 等)
  127. # 【主流程 8.3,数据库内部步骤 3】把日期/时间转为字符串,Decimal 转为 float。
  128. # 这些值随后才能放入 JSON;转换 Decimal 为 float 可能丢失部分精度。
  129. for result in results:
  130. # 外层循环逐行,内层循环逐列。items() 每次给出列名 key 和该列的值 value。
  131. # 给 result[key] 重新赋值是在替换该列的 Python 表示,没有修改数据库里的原始数据。
  132. for key, value in result.items():
  133. if isinstance(value, (date, datetime, timedelta, Decimal)):
  134. result[key] = default_encoder(value)
  135. if results:
  136. response = {"status": "success", "data": results}
  137. else:
  138. response = {"status": "no_data", "message": "未找到差旅数据,请确认查询条件。"}
  139. # 【主流程 8.4】把 success/no_data 和数据序列化为 JSON 文本交回工具调用方。
  140. # 异常分支返回 error 文本;工具成功返回文本本身不代表查询有结果。
  141. return json.dumps(response, cls=DateEncoder, ensure_ascii=False)
  142. # 查询、取值或序列化出错时进入异常分支,返回含 status="error" 的 JSON 文本。
  143. # 由于错误被转换为普通返回值,上层还需要检查内容,不能只根据“函数正常返回”判断业务成功。
  144. except Exception as e:
  145. logger.error(f"差旅查询错误: {str(e)}")
  146. return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)
  147. # ========== 商务用车查询(MySQL) ==========
  148. def query_car_rental(self, pickup_city: str, return_city: str, date: str, car_type: str = None) -> str:
  149. """
  150. 查询商务用车信息(从 MySQL car_rentals 表查询)
  151. """
  152. # 【主流程 8.2】将已传入的参数组织成固定 SQL 和参数列表。
  153. # 调用者:同名 MCP 包装函数。输入:取车城市、还车城市、取车日期;输出:查询结果的 JSON 字符串。
  154. # 数据源:MySQL 的 car_rentals 表,SQL 写在代码中,由参数决定筛选值。
  155. # pickup_city 与 return_city 可以不同;car_type 只有有值时才增加车型筛选。
  156. logger.info(f"查询商务用车: {pickup_city} -> {return_city}, {date}, {car_type}")
  157. sql = ("SELECT id, company, pickup_city, return_city, pickup_date, car_type, "
  158. "car_model, price_per_day, total_available, transmission, seats, deposit "
  159. "FROM car_rentals "
  160. "WHERE pickup_city = %s AND return_city = %s AND pickup_date = %s")
  161. # 参数列表的顺序与 SQL 占位符顺序一致;列表中的日期仍按传入值交给数据库比较。
  162. params = [pickup_city, return_city, date]
  163. # 可选条件有值才追加;同时追加参数,保证条件与参数一一对应。
  164. if car_type:
  165. sql += " AND car_type = %s"
  166. # 必须在追加可选 SQL 条件时同时追加对应值,否则占位符数量与参数数量会不一致。
  167. params.append(car_type)
  168. # 【主流程 8.3】进入通用查询方法;结果沿当前调用栈原路返回 MCP 包装函数。
  169. return self._execute_query(sql, params)
  170. # ========== 会务套餐查询(Milvus RAG) ==========
  171. def query_meeting_package(self, query_text: str, city: str = None) -> str:
  172. """
  173. 查询会务套餐信息(从 Milvus 向量数据库进行语义检索)
  174. 参数:
  175. query_text (str): 用户查询描述,如"成都办数字化转型峰会,需要接送和酒店"、"适合销售团队的短途会务套餐"
  176. city (str, optional): 城市过滤条件
  177. 返回值:
  178. str: JSON 字符串,包含匹配的会务套餐列表
  179. """
  180. # 【主流程 8.2:会务套餐分支】进入 Milvus 语义检索;用车与保险则走 MySQL。
  181. # 这里只检索候选套餐,不调用聊天模型生成回答;结果交给 A2A 子助手再整理。
  182. # 输入 query_text 负责语义相似度,city 负责精确城市过滤,两者用途不同。
  183. # 固定 limit=5 最多取五个候选;列表非空返回 success,空列表返回 no_data,抛异常返回 error。
  184. # 本实现没有另外设置相似度阈值,因而返回候选不等于每条都满足用户全部要求。
  185. logger.info(f"查询会务套餐: query='{query_text}', city='{city}'")
  186. try:
  187. package_list = search_meeting_packages_in_milvus(query_text, city, limit=5)
  188. if package_list:
  189. return json.dumps({"status": "success", "data": package_list}, ensure_ascii=False)
  190. else:
  191. return json.dumps({
  192. "status": "no_data",
  193. "message": f"未找到匹配的会务套餐。{'请确认城市名称,' if city else ''}或尝试其他搜索条件。"
  194. }, ensure_ascii=False)
  195. except Exception as e:
  196. logger.error(f"会务套餐 RAG 查询错误: {str(e)}")
  197. return json.dumps({"status": "error", "message": f"会务套餐查询出错:{str(e)}"}, ensure_ascii=False)
  198. # ========== 保险查询(MySQL) ==========
  199. def query_insurance(self, insurance_type: str = None) -> str:
  200. """
  201. 查询差旅保险产品信息(从 MySQL insurances 表查询)
  202. """
  203. # 【主流程 8.2】将已传入的参数组织成固定 SQL 和参数列表。
  204. # 调用者:同名 MCP 包装函数。输入:可选保险类型;输出:查询结果的 JSON 字符串。
  205. # 数据源:MySQL 的 insurances 表,SQL 写在代码中,由参数决定筛选值。
  206. # 无类型参数时查询表内所有产品;有类型时追加 WHERE insurance_type = %s。
  207. logger.info(f"查询保险: {insurance_type}")
  208. sql = ("SELECT id, insurance_type, name, company, coverage, price, "
  209. "duration_days, max_coverage, medical_coverage, baggage_coverage, flight_delay "
  210. "FROM insurances")
  211. params = []
  212. # 可选条件有值才追加;同时追加参数,保证条件与参数一一对应。
  213. if insurance_type:
  214. sql += " WHERE insurance_type = %s"
  215. # 必须在追加可选 SQL 条件时同时追加对应值,否则占位符数量与参数数量会不一致。
  216. params.append(insurance_type)
  217. # 【主流程 8.3】进入通用查询方法;结果沿当前调用栈原路返回 MCP 包装函数。
  218. return self._execute_query(sql, params)
  219. # ========== 预订方法 ==========
  220. def order_car_rental(self, date: str, car_type: str, number: int) -> str:
  221. """预订商务用车"""
  222. # 【主流程 8.2:模拟预订分支】只构造演示文本(部分方法同时记日志)。
  223. # 这里没有写订单、扣库存、支付、出票或生成保单的动作。
  224. # 参数用于填充下方 f-string,例如日期、名称、类型和数量会直接显示在演示回复里。
  225. # 返回字符串后本方法立即结束;它没有再调用查询方法核对库存,也没有生成可持久化的订单对象。
  226. logger.info(f"预订商务用车: {date}, {car_type}, {number}辆")
  227. # 【新改动2】明确返回模拟结果,避免被解释为真实下单或库存扣减。
  228. return f"模拟预订成功,未实际下单,未创建订单,未扣减库存。日期:{date},车型:{car_type},数量:{number}辆。"
  229. def order_meeting_package(self, date: str, package_name: str, number: int) -> str:
  230. """预订会务套餐"""
  231. # 【主流程 8.2:模拟预订分支】只构造演示文本(部分方法同时记日志)。
  232. # 这里没有写订单、扣库存、支付、出票或生成保单的动作。
  233. # 参数用于填充下方 f-string,例如日期、名称、类型和数量会直接显示在演示回复里。
  234. # 返回字符串后本方法立即结束;它没有再调用查询方法核对库存,也没有生成可持久化的订单对象。
  235. logger.info(f"预订会务套餐: {date}, {package_name}, {number}人")
  236. return f"会务套餐预订模拟成功,未实际下单、未创建订单、未扣减库存、未出票或生成保单。套餐名称:{package_name},日期:{date},人数:{number}人。"
  237. def order_insurance(self, insurance_type: str, date: str, number: int) -> str:
  238. """购买差旅保险"""
  239. # 【主流程 8.2:模拟预订分支】只构造演示文本(部分方法同时记日志)。
  240. # 这里没有写订单、扣库存、支付、出票或生成保单的动作。
  241. # 参数用于填充下方 f-string,例如日期、名称、类型和数量会直接显示在演示回复里。
  242. # 返回字符串后本方法立即结束;它没有再调用查询方法核对库存,也没有生成可持久化的订单对象。
  243. logger.info(f"购买保险: {insurance_type}, {date}, {number}份")
  244. return f"差旅保险购买模拟成功,未实际下单、未创建订单、未扣减库存、未出票或生成保单。类型:{insurance_type},日期:{date},份数:{number}份。"
  245. # ========== 注册查询工具 ==========
  246. def create_trip_mcp_server():
  247. """
  248. 创建并启动差旅管家 MCP 服务器
  249. """
  250. # 【启动 S2.1】创建工具注册表。下面的 @tool 在定义包装函数时完成注册。
  251. # 注册时不会运行包装函数体;只有收到该工具的调用请求才执行函数体。
  252. # 这是服务启动函数。内部定义的工具包装函数会记住当前的 service 对象,后续每次调用可继续使用它。
  253. # 这叫闭包:包装函数虽然在外层函数内部定义,却能读取外层的 service。
  254. # FastMCP 注册这些函数后,HTTP 请求按工具名选择其中一个,不按源代码排列顺序逐个执行。
  255. trip_mcp = FastMCP(name="TripTools")
  256. # 【启动 S2.2】创建可被所有工具包装函数引用的业务服务,并建立 MySQL 连接。
  257. service = TripService()
  258. @trip_mcp.tool(
  259. name="query_car_rental",
  260. description="查询商务用车信息,参数:pickup_city(取车城市), return_city(还车城市), date(日期,格式YYYY-MM-DD), car_type(车型类型,可选:经济型/SUV/豪华型/MPV)"
  261. )
  262. def query_car_rental(pickup_city: str, return_city: str, date: str, car_type: str = None) -> str:
  263. # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
  264. # 调用者:MCP 框架按工具名 query_car_rental 分派到这里。
  265. # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
  266. # 同名的 TripService.query_car_rental 是业务方法;当前嵌套函数只是对外暴露的工具入口。
  267. return service.query_car_rental(pickup_city, return_city, date, car_type)
  268. @trip_mcp.tool(
  269. name="query_meeting_package",
  270. description="查询会务套餐信息(语义搜索),参数:query_text(查询描述,如'成都办数字化转型峰会,需要接送和酒店'),city(城市过滤,可选)"
  271. )
  272. def query_meeting_package(query_text: str, city: str = None) -> str:
  273. # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
  274. # 调用者:MCP 框架按工具名 query_meeting_package 分派到这里。
  275. # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
  276. # 同名的 TripService.query_meeting_package 是业务方法;当前嵌套函数只是对外暴露的工具入口。
  277. return service.query_meeting_package(query_text, city)
  278. @trip_mcp.tool(
  279. name="query_insurance",
  280. description="查询差旅保险产品,参数:insurance_type(保险类型,可选:综合型/意外型/医疗型/境外型)"
  281. )
  282. def query_insurance(insurance_type: str = None) -> str:
  283. # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
  284. # 调用者:MCP 框架按工具名 query_insurance 分派到这里。
  285. # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
  286. # 同名的 TripService.query_insurance 是业务方法;当前嵌套函数只是对外暴露的工具入口。
  287. return service.query_insurance(insurance_type)
  288. # ========== 注册预订工具 ==========
  289. # 【新改动2】向智能体说明该工具仅用于模拟测试,可以响应模拟预订请求。
  290. @trip_mcp.tool(
  291. name="order_car_rental",
  292. description="根据日期、车型和数量模拟预订商务用车,仅用于测试,不实际下单、不创建订单、不扣减库存。参数:date(商务用车日期,格式YYYY-MM-DD), car_type(车型,如经济型/SUV/豪华型/MPV), number(商务用车数量)"
  293. )
  294. def order_car_rental(date: str, car_type: str, number: int) -> str:
  295. # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
  296. # 调用者:MCP 框架按工具名 order_car_rental 分派到这里。
  297. # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
  298. # 同名的 TripService.order_car_rental 是业务方法;当前嵌套函数只是对外暴露的工具入口。
  299. logger.info(f"正在预订商务用车: {date}, {car_type}, {number}辆")
  300. return service.order_car_rental(date, car_type, number)
  301. @trip_mcp.tool(
  302. name="order_meeting_package",
  303. description="仅模拟演示,不实际下单、不创建订单、不扣减库存、不出票或生成保单。根据出发日期、会务套餐名称和人数预订会务套餐。参数:date(出发日期,格式YYYY-MM-DD), package_name(会务套餐名称,需与查询结果中的套餐名称一致), number(预订人数)"
  304. )
  305. def order_meeting_package(date: str, package_name: str, number: int) -> str:
  306. # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
  307. # 调用者:MCP 框架按工具名 order_meeting_package 分派到这里。
  308. # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
  309. # 同名的 TripService.order_meeting_package 是业务方法;当前嵌套函数只是对外暴露的工具入口。
  310. logger.info(f"正在预订会务套餐: {date}, {package_name}, {number}人")
  311. return service.order_meeting_package(date, package_name, number)
  312. @trip_mcp.tool(
  313. name="order_insurance",
  314. description="仅模拟演示,不实际下单、不创建订单、不扣减库存、不出票或生成保单。根据保险类型、生效日期和份数购买差旅保险。参数:insurance_type(保险类型,如综合型/意外型/医疗型/境外型), date(生效日期,格式YYYY-MM-DD), number(购买份数)"
  315. )
  316. def order_insurance(insurance_type: str, date: str, number: int) -> str:
  317. # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
  318. # 调用者:MCP 框架按工具名 order_insurance 分派到这里。
  319. # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
  320. # 同名的 TripService.order_insurance 是业务方法;当前嵌套函数只是对外暴露的工具入口。
  321. logger.info(f"正在购买保险: {insurance_type}, {date}, {number}份")
  322. return service.order_insurance(insurance_type, date, number)
  323. # 【启动 S2.3】工具注册结束后构建 HTTP 应用;下一行 uvicorn.run 开始监听并等待请求。
  324. app = create_fastapi_app(mcp_server=trip_mcp)
  325. uvicorn.run(app, host="0.0.0.0", port=8003)
  326. if __name__ == '__main__':
  327. create_trip_mcp_server()