calculator.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. # server.py
  2. from mcp.server.fastmcp import FastMCP
  3. import sys
  4. import logging
  5. import os
  6. from typing import List, Dict, Any, Optional
  7. import requests
  8. from utils.logger_config import logger
  9. try:
  10. import yaml
  11. except ImportError: # 允许在未安装 PyYAML 的环境下给出清晰日志
  12. yaml = None
  13. # Fix UTF-8 encoding for Windows console
  14. if sys.platform == 'win32':
  15. sys.stderr.reconfigure(encoding='utf-8')
  16. sys.stdout.reconfigure(encoding='utf-8')
  17. # Create an MCP server
  18. mcp = FastMCP("展厅交互模块")
  19. # YAML 配置路径
  20. VIDEO_CONFIG_PATH = os.path.join(os.path.dirname(__file__), 'video_config_prod.yaml')
  21. # 内存状态
  22. _is_running: bool = False
  23. _current_video_id: Optional[int] = None
  24. def _load_videos_from_yaml(config_path: str) -> List[Dict[str, Any]]:
  25. if yaml is None:
  26. logger.error("未安装 PyYAML,请在环境中安装 pyyaml 以读取配置文件")
  27. return []
  28. if not os.path.exists(config_path):
  29. logger.error(f"未找到配置文件: {config_path}")
  30. return []
  31. try:
  32. with open(config_path, 'r', encoding='utf-8') as f:
  33. data = yaml.safe_load(f) or {}
  34. videos = data.get('video_infos', [])
  35. if not isinstance(videos, list):
  36. logger.error("配置文件格式错误: 'video_infos' 应为列表")
  37. return []
  38. # 规范化字段
  39. normalized = []
  40. for v in videos:
  41. if not isinstance(v, dict):
  42. continue
  43. if 'id' not in v or 'name' not in v:
  44. continue
  45. normalized.append({
  46. 'id': v.get('id'),
  47. 'name': v.get('name'),
  48. 'formula': v.get('formula'),
  49. 'description': v.get('description'),
  50. 'video_duration': v.get('video_duration'),
  51. 'video_path': v.get('video_path'),
  52. 'code': v.get('code'),
  53. })
  54. return normalized
  55. except Exception as e:
  56. logger.exception(f"读取配置失败: {e}")
  57. return []
  58. def _index_videos(videos: List[Dict[str, Any]]) -> Dict[int, Dict[str, Any]]:
  59. return {int(v['id']): v for v in videos if isinstance(v.get('id'), int)}
  60. # 启动时加载
  61. _videos: List[Dict[str, Any]] = _load_videos_from_yaml(VIDEO_CONFIG_PATH)
  62. _videos_by_id: Dict[int, Dict[str, Any]] = _index_videos(_videos)
  63. # Flask API 基础地址(与 README_API.md 一致)
  64. FLASK_BASE = os.environ.get('FLASK_API_BASE', 'http://192.168.254.242:5050')
  65. def _flask_get(path: str) -> Dict[str, Any]:
  66. url = f"{FLASK_BASE}{path}"
  67. try:
  68. resp = requests.get(url, timeout=3)
  69. resp.raise_for_status()
  70. return resp.json()
  71. except Exception as e:
  72. logger.error(f"GET {url} 失败: {e}")
  73. return {"success": False, "message": str(e)}
  74. def _flask_post(path: str, json_body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
  75. url = f"{FLASK_BASE}{path}"
  76. try:
  77. resp = requests.post(url, json=json_body or {}, timeout=3)
  78. resp.raise_for_status()
  79. return resp.json()
  80. except Exception as e:
  81. logger.error(f"POST {url} 失败: {e}")
  82. return {"success": False, "message": str(e)}
  83. @mcp.tool(name="获取所有可用的视频列表")
  84. def list_videos() -> dict:
  85. """获取所有可用的视频列表(来自 video_config.yaml)。
  86. 使用建议:
  87. - 当用户只说出展品名称且可能存在谐音/近音时,先调用本工具获取候选列表,
  88. 在客户端侧做相似度/拼音/编辑距离匹配,选取"最接近"的名称对应的 video_id,
  89. 再调用 kodi_start(video_id)。
  90. 返回的每个视频包含以下字段,可用于匹配:
  91. - id: 视频ID(整数)
  92. - name: 展品名称(如"氯化铷"、"硫酸钙新材料"等)
  93. - code: 展品编号(如"1号展品"、"2号展品"等)
  94. - formula: 化学式或公式
  95. - description: 描述信息
  96. """
  97. logger.info(f"获取视频列表,共 {_videos.__len__()} 个")
  98. return {"success": True, "videos": _videos, "count": len(_videos)}
  99. @mcp.tool(name="播放到指定的展品视频")
  100. def kodi_start(video_id: int) -> dict:
  101. """启动或切换 Kodi 播放到指定 video_id(转发到 Flask: POST /api/kodi/start)。
  102. 参数:
  103. - video_id: 整数类型的视频ID(从 list_videos() 获取)
  104. 使用场景和匹配策略:
  105. 1. 用户说编号类表达(如"一号展品"、"1号产品"、"展品1"、"产品1"等):
  106. - 提取数字部分(如"一号"→1、"1号"→1、"展品1"→1)
  107. - 直接使用该数字作为 video_id
  108. 2. 用户直接说物质名称(如"播放氯化铷"、"播放硫酸钙"、"氯化铯介绍视频"等):
  109. - 先调用 list_videos() 获取所有视频列表
  110. - 从返回的列表中匹配:
  111. a) 优先匹配 name 字段(精确或模糊匹配)
  112. b) 其次匹配 formula 字段
  113. c) 再次匹配 code 字段中的名称部分
  114. - 匹配时考虑:
  115. * 精确匹配(如"氯化铷"完全匹配)
  116. * 包含匹配(如输入"氯化铷"匹配到 name="氯化铷")
  117. * 拼音/谐音匹配(如"氯化铯"与"氯化铷"的相似度)
  118. * 编辑距离(允许轻微口误)
  119. - 选择"最接近"的条目,使用其 id 字段作为 video_id 调用本接口
  120. 3. 匹配示例:
  121. - "播放1号展品" → video_id=1
  122. - "播放一号产品" → video_id=1
  123. - "播放氯化铷" → 匹配 name="氯化铷" → video_id=1
  124. - "播放氯化铯介绍视频" → 匹配 name="氯化铯" → video_id=6
  125. - "硫酸钙" → 匹配 name="硫酸钙新材料"(包含匹配) → video_id=2
  126. 提示:如果用户给的是展品"名称"而非 id,且存在谐音/口误,
  127. - 先调用 list_videos() 获取所有视频元数据
  128. - 在调用侧进行名称相似度匹配(可用拼音/编辑距离/分词等方法)
  129. - 选择"最接近"的条目并传入其 video_id 调用本接口
  130. """
  131. if not isinstance(video_id, int) or video_id < 0:
  132. return {"success": False, "message": "video_id 必须为大于等于 0 的整数"}
  133. if video_id not in _videos_by_id:
  134. return {"success": False, "message": f"未找到视频ID={video_id}"}
  135. video = _videos_by_id[video_id]
  136. logger.info(f"请求 Flask 启动/切换播放: 视频ID={video_id},名称={video.get('name')}")
  137. api = _flask_post('/api/kodi/start', {"video_id": video_id})
  138. # 同步内存状态
  139. if api.get('success') is True:
  140. global _is_running, _current_video_id
  141. _is_running = True
  142. _current_video_id = video_id
  143. return api
  144. # Start the server
  145. if __name__ == "__main__":
  146. mcp.run(transport="stdio")