Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bf619e2dd | ||
|
|
1120be2f60 | ||
|
|
9880ce998f |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
-- 添加MCP接入点附带说话人身份的开关参数配置
|
||||
delete from `sys_params` where param_code = 'mcp_forward_speaker';
|
||||
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark)
|
||||
VALUES (313, 'mcp_forward_speaker', 'true', 'boolean', 1, '是否随MCP调用附带说话人身份');
|
||||
@@ -753,3 +753,10 @@ databaseChangeLog:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202609172100.sql
|
||||
- changeSet:
|
||||
id: 202610261030
|
||||
author: RanChen
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202610261030.sql
|
||||
@@ -119,6 +119,10 @@ wakeup_words:
|
||||
# MCP接入点地址,地址格式为:ws://你的mcp接入点ip或者域名:端口号/mcp/?token=你的token
|
||||
# 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md
|
||||
mcp_endpoint: 你的接入点 websocket地址
|
||||
# 声纹识别到说话人后,是否把身份附在 MCP 接入点 tools/call 的 params._meta 上。
|
||||
# true:写入 speakerId、speakerName。未识别到说话人时不附带,请求与原来一致。
|
||||
# 工具参数本身不改。false:完全不附带。
|
||||
mcp_forward_speaker: true
|
||||
|
||||
# 上下文源配置
|
||||
# 用于在系统提示词中注入动态数据,如健康数据、股票信息等
|
||||
|
||||
@@ -42,7 +42,7 @@ from core.providers.tts.dto.dto import ContentType, TTSMessageDTO, SentenceType
|
||||
from config.logger import setup_logging, build_module_string, create_connection_logger
|
||||
from config.manage_api_client import DeviceNotFoundException, DeviceBindException, generate_and_save_chat_title
|
||||
from core.utils.prompt_manager import PromptManager
|
||||
from core.utils.voiceprint_provider import VoiceprintProvider
|
||||
from core.utils.voiceprint_provider import VoiceprintProvider, UNKNOWN_SPEAKER_NAME
|
||||
from core.utils.util import get_system_error_response
|
||||
from core.utils import textUtils
|
||||
|
||||
@@ -158,7 +158,7 @@ class ConnectionHandler:
|
||||
# 所以涉及到ASR的变量,需要在这里定义,属于connection的私有变量
|
||||
self.asr_audio = [] # 存储PCM帧列表,供VAD和ASR共享
|
||||
self.asr_audio_queue = queue.Queue()
|
||||
self.current_speaker = None # 存储当前说话人
|
||||
self.current_speaker = None # 当前说话人展示名
|
||||
self.introduced_speakers = set() # 已"首次引入"的说话人,控制只在首轮带名字
|
||||
self.system_introduced_speakers = set() # 已在 system 注入过身份的说话人,控制 system 身份只首轮出现
|
||||
|
||||
@@ -1052,7 +1052,8 @@ class ConnectionHandler:
|
||||
# 更新系统prompt至上下文
|
||||
self.dialogue.update_system_message(self.prompt)
|
||||
|
||||
def chat(self, query, depth=0):
|
||||
def chat(self, query, depth=0, speaker=None):
|
||||
# speaker: 本轮声纹身份快照,随工具调用下传
|
||||
# 保存当前任务的sentence_id到局部变量,避免被新任务覆盖
|
||||
current_sentence_id = None
|
||||
|
||||
@@ -1122,7 +1123,7 @@ class ConnectionHandler:
|
||||
# 避免每轮在 system 重复出现名字诱导模型反复称呼
|
||||
speaker_for_system = None
|
||||
cs = (self.current_speaker or "").strip()
|
||||
if cs and cs != "未知说话人" and cs not in self.system_introduced_speakers:
|
||||
if cs and cs != UNKNOWN_SPEAKER_NAME and cs not in self.system_introduced_speakers:
|
||||
self.system_introduced_speakers.add(cs)
|
||||
speaker_for_system = cs
|
||||
|
||||
@@ -1337,7 +1338,7 @@ class ConnectionHandler:
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.func_handler.handle_llm_function_call(
|
||||
self, tool_call_data
|
||||
self, tool_call_data, speaker=speaker
|
||||
),
|
||||
self.loop,
|
||||
)
|
||||
@@ -1500,7 +1501,7 @@ class ConnectionHandler:
|
||||
)
|
||||
)
|
||||
|
||||
self.chat(None, depth=depth + 1)
|
||||
self.chat(None, depth=depth + 1, speaker=speaker)
|
||||
|
||||
def _report_worker(self):
|
||||
"""聊天记录上报工作线程"""
|
||||
|
||||
@@ -17,7 +17,7 @@ from core.providers.tts.dto.dto import TTSMessageDTO, SentenceType
|
||||
TAG = __name__
|
||||
|
||||
|
||||
async def handle_user_intent(conn: "ConnectionHandler", text):
|
||||
async def handle_user_intent(conn: "ConnectionHandler", text, speaker=None):
|
||||
# 预处理输入文本,处理可能的JSON格式
|
||||
try:
|
||||
if text.strip().startswith("{") and text.strip().endswith("}"):
|
||||
@@ -172,7 +172,7 @@ async def process_intent_result(
|
||||
try:
|
||||
result = asyncio.run_coroutine_threadsafe(
|
||||
conn.func_handler.handle_llm_function_call(
|
||||
conn, function_call_data
|
||||
conn, function_call_data, speaker=speaker
|
||||
),
|
||||
conn.loop,
|
||||
).result(timeout=tool_call_timeout)
|
||||
|
||||
@@ -40,7 +40,8 @@ async def resume_vad_detection(conn: "ConnectionHandler"):
|
||||
conn.just_woken_up = False
|
||||
|
||||
|
||||
async def startToChat(conn: "ConnectionHandler", text):
|
||||
async def startToChat(conn: "ConnectionHandler", text, speaker=None):
|
||||
"""speaker: 本轮声纹身份快照,随调用链传递。"""
|
||||
# 检查输入是否是JSON格式(包含说话人信息)
|
||||
speaker_name = None
|
||||
actual_text = text
|
||||
@@ -69,11 +70,8 @@ async def startToChat(conn: "ConnectionHandler", text):
|
||||
# 如果解析失败,继续使用原始文本
|
||||
pass
|
||||
|
||||
# 保存说话人信息到连接对象
|
||||
if speaker_name:
|
||||
conn.current_speaker = speaker_name
|
||||
else:
|
||||
conn.current_speaker = None
|
||||
# 说话人名供提示词注入;声纹身份走 speaker 快照,不挂连接
|
||||
conn.current_speaker = speaker_name
|
||||
|
||||
if conn.need_bind:
|
||||
await check_bind_device(conn)
|
||||
@@ -92,7 +90,7 @@ async def startToChat(conn: "ConnectionHandler", text):
|
||||
await handleAbortMessage(conn)
|
||||
|
||||
# 首先进行意图分析,使用实际文本内容
|
||||
intent_handled = await handle_user_intent(conn, actual_text)
|
||||
intent_handled = await handle_user_intent(conn, actual_text, speaker=speaker)
|
||||
|
||||
if intent_handled:
|
||||
# 如果意图已被处理,不再进行聊天
|
||||
@@ -104,7 +102,7 @@ async def startToChat(conn: "ConnectionHandler", text):
|
||||
# 准备开始新会话
|
||||
conn.client_abort = False
|
||||
|
||||
conn.executor.submit(conn.chat, actual_text)
|
||||
conn.executor.submit(conn.chat, actual_text, depth=0, speaker=speaker)
|
||||
|
||||
|
||||
async def no_voice_close_connect(conn: "ConnectionHandler", have_voice):
|
||||
|
||||
@@ -17,6 +17,7 @@ from core.providers.asr.dto.dto import InterfaceType
|
||||
from core.handle.receiveAudioHandle import startToChat
|
||||
from core.handle.reportHandle import enqueue_asr_report
|
||||
from core.utils.util import remove_punctuation_and_length
|
||||
from core.utils.voiceprint_provider import SpeakerIdentification
|
||||
from core.handle.receiveAudioHandle import handleAudioMessage
|
||||
from typing import Optional, Tuple, List, NamedTuple, TYPE_CHECKING
|
||||
|
||||
@@ -101,7 +102,7 @@ class ASRProviderBase(ABC):
|
||||
)
|
||||
|
||||
if conn.voiceprint_provider and wav_data:
|
||||
voiceprint_task = conn.voiceprint_provider.identify_speaker(
|
||||
voiceprint_task = conn.voiceprint_provider.identify_speaker_detail(
|
||||
wav_data, conn.session_id
|
||||
)
|
||||
# 并发等待两个结果
|
||||
@@ -119,11 +120,11 @@ class ASRProviderBase(ABC):
|
||||
else:
|
||||
raw_text, _ = asr_result
|
||||
|
||||
speaker_name = ""
|
||||
if isinstance(voiceprint_result, Exception):
|
||||
logger.bind(tag=TAG).error(f"声纹识别失败: {voiceprint_result}")
|
||||
speaker_name = ""
|
||||
else:
|
||||
speaker_name = voiceprint_result
|
||||
elif voiceprint_result is not None:
|
||||
speaker_name = voiceprint_result.name or ""
|
||||
|
||||
# 判断 ASR 结果类型
|
||||
if isinstance(raw_text, dict):
|
||||
@@ -164,10 +165,16 @@ class ASRProviderBase(ABC):
|
||||
self.stop_ws_connection()
|
||||
|
||||
if text_len > 0:
|
||||
# 本轮声纹身份快照,随调用链供 MCP 接入点工具使用
|
||||
speaker = (
|
||||
voiceprint_result
|
||||
if isinstance(voiceprint_result, SpeakerIdentification)
|
||||
else None
|
||||
)
|
||||
audio_snapshot = asr_audio_task.copy()
|
||||
enqueue_asr_report(conn, enhanced_text, audio_snapshot)
|
||||
# 使用自定义模块进行上报
|
||||
await startToChat(conn, enhanced_text)
|
||||
await startToChat(conn, enhanced_text, speaker=speaker)
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"处理语音停止失败: {e}")
|
||||
import traceback
|
||||
|
||||
@@ -11,9 +11,9 @@ class ToolExecutor(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def execute(
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any]
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any], speaker=None
|
||||
) -> ActionResponse:
|
||||
"""执行工具调用"""
|
||||
"""执行工具调用。speaker: 可选的说话人身份快照。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -15,7 +15,7 @@ class DeviceIoTExecutor(ToolExecutor):
|
||||
self.iot_tools: Dict[str, ToolDefinition] = {}
|
||||
|
||||
async def execute(
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any]
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any], speaker=None
|
||||
) -> ActionResponse:
|
||||
"""执行设备端IoT工具"""
|
||||
if not self.has_tool(tool_name):
|
||||
|
||||
@@ -16,7 +16,7 @@ class DeviceMCPExecutor(ToolExecutor):
|
||||
self.conn = conn
|
||||
|
||||
async def execute(
|
||||
self, conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any]
|
||||
self, conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any], speaker=None
|
||||
) -> ActionResponse:
|
||||
"""执行设备端MCP工具"""
|
||||
if not hasattr(conn, "mcp_client") or not conn.mcp_client:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""MCP接入点工具执行器"""
|
||||
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, Optional
|
||||
from ..base import ToolType, ToolDefinition, ToolExecutor
|
||||
from plugins_func.register import Action, ActionResponse
|
||||
from core.utils.voiceprint_provider import SpeakerIdentification
|
||||
from .mcp_endpoint_handler import call_mcp_endpoint_tool
|
||||
from .mcp_speaker import should_forward_speaker, speaker_call_meta
|
||||
|
||||
|
||||
class MCPEndpointExecutor(ToolExecutor):
|
||||
@@ -13,9 +15,9 @@ class MCPEndpointExecutor(ToolExecutor):
|
||||
self.conn = conn
|
||||
|
||||
async def execute(
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any]
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any], speaker: Optional[SpeakerIdentification] = None
|
||||
) -> ActionResponse:
|
||||
"""执行MCP接入点工具"""
|
||||
"""执行MCP接入点工具。speaker: 本轮说话人快照。"""
|
||||
if not hasattr(conn, "mcp_endpoint_client") or not conn.mcp_endpoint_client:
|
||||
return ActionResponse(
|
||||
action=Action.ERROR,
|
||||
@@ -29,14 +31,20 @@ class MCPEndpointExecutor(ToolExecutor):
|
||||
)
|
||||
|
||||
try:
|
||||
# 转换参数为JSON字符串
|
||||
# 转换参数为JSON字符串。说话人身份不写入参数,只放在 _meta。
|
||||
import json
|
||||
|
||||
meta = None
|
||||
if speaker is not None and should_forward_speaker(
|
||||
getattr(conn, "config", None)
|
||||
):
|
||||
meta = speaker_call_meta(speaker.speaker_id, speaker.name)
|
||||
|
||||
args_str = json.dumps(arguments) if arguments else "{}"
|
||||
|
||||
# 调用MCP接入点工具
|
||||
result = await call_mcp_endpoint_tool(
|
||||
conn.mcp_endpoint_client, tool_name, args_str
|
||||
conn.mcp_endpoint_client, tool_name, args_str, meta=meta
|
||||
)
|
||||
|
||||
resultJson = None
|
||||
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
import websockets
|
||||
from config.logger import setup_logging
|
||||
from .mcp_endpoint_client import MCPEndpointClient
|
||||
from .mcp_speaker import build_mcp_endpoint_tool_payload
|
||||
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
@@ -284,7 +285,11 @@ async def send_mcp_endpoint_tools_list_continue(
|
||||
|
||||
|
||||
async def call_mcp_endpoint_tool(
|
||||
mcp_client: MCPEndpointClient, tool_name: str, args: str = "{}", timeout: int = 30
|
||||
mcp_client: MCPEndpointClient,
|
||||
tool_name: str,
|
||||
args: str = "{}",
|
||||
timeout: int = 30,
|
||||
meta: dict = None,
|
||||
):
|
||||
"""
|
||||
调用指定的MCP接入点工具,并等待响应
|
||||
@@ -350,16 +355,16 @@ async def call_mcp_endpoint_tool(
|
||||
raise e
|
||||
|
||||
actual_name = mcp_client.name_mapping.get(tool_name, tool_name)
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": tool_call_id,
|
||||
"method": "tools/call",
|
||||
"params": {"name": actual_name, "arguments": arguments},
|
||||
}
|
||||
payload = build_mcp_endpoint_tool_payload(
|
||||
tool_call_id, actual_name, arguments, meta
|
||||
)
|
||||
|
||||
message = json.dumps(payload)
|
||||
log_suffix = ""
|
||||
if meta:
|
||||
log_suffix = f",_meta: {json.dumps(meta, ensure_ascii=False)}"
|
||||
logger.bind(tag=TAG).info(
|
||||
f"发送MCP接入点工具调用请求: {actual_name},参数: {json.dumps(arguments, ensure_ascii=False)}"
|
||||
f"发送MCP接入点工具调用请求: {actual_name},参数: {json.dumps(arguments, ensure_ascii=False)}{log_suffix}"
|
||||
)
|
||||
await mcp_client.send_message(message)
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""把已识别的说话人附到 MCP 接入点 tools/call 上。
|
||||
|
||||
工具参数保持模型给出的原样。身份放在 MCP 的 params._meta 里,
|
||||
接入点上的服务可以读取,不读的服务按协议忽略。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from core.utils.voiceprint_provider import UNKNOWN_SPEAKER_NAME
|
||||
|
||||
|
||||
def should_forward_speaker(config: Optional[dict]) -> bool:
|
||||
"""mcp_forward_speaker 缺省为开启。显式 false 时不附带身份。"""
|
||||
if not isinstance(config, dict):
|
||||
return True
|
||||
value = config.get("mcp_forward_speaker", True)
|
||||
if value is None:
|
||||
return True
|
||||
return str(value).strip().lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
def speaker_call_meta(speaker_id: Any, speaker_name: Any) -> Optional[Dict[str, str]]:
|
||||
"""有真实身份时返回 _meta;没有则返回 None,调用方不写这个字段。"""
|
||||
meta: Dict[str, str] = {}
|
||||
if isinstance(speaker_id, str):
|
||||
speaker_id = speaker_id.strip()
|
||||
if speaker_id:
|
||||
meta["speakerId"] = speaker_id
|
||||
if isinstance(speaker_name, str):
|
||||
speaker_name = speaker_name.strip()
|
||||
if speaker_name and speaker_name != UNKNOWN_SPEAKER_NAME:
|
||||
meta["speakerName"] = speaker_name
|
||||
return meta or None
|
||||
|
||||
|
||||
def build_mcp_endpoint_tool_payload(
|
||||
tool_call_id: int,
|
||||
name: str,
|
||||
arguments: Dict[str, Any],
|
||||
meta: Optional[Dict[str, str]] = None,
|
||||
) -> dict:
|
||||
"""组装 tools/call。meta 为空时不出现 _meta,参数字典原样放进 arguments。"""
|
||||
params: Dict[str, Any] = {"name": name, "arguments": arguments}
|
||||
if meta:
|
||||
params["_meta"] = meta
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": tool_call_id,
|
||||
"method": "tools/call",
|
||||
"params": params,
|
||||
}
|
||||
@@ -22,7 +22,7 @@ class ServerMCPExecutor(ToolExecutor):
|
||||
await self.mcp_manager.initialize_servers()
|
||||
|
||||
async def execute(
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any]
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any], speaker=None
|
||||
) -> ActionResponse:
|
||||
"""执行服务端MCP工具"""
|
||||
if not self._initialized or not self.mcp_manager:
|
||||
|
||||
@@ -17,7 +17,7 @@ class ServerPluginExecutor(ToolExecutor):
|
||||
self.config = conn.config
|
||||
|
||||
async def execute(
|
||||
self, conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any]
|
||||
self, conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any], speaker=None
|
||||
) -> ActionResponse:
|
||||
"""执行服务端插件工具"""
|
||||
func_item = all_function_registry.get(tool_name)
|
||||
|
||||
@@ -137,16 +137,16 @@ class UnifiedToolHandler:
|
||||
return self.tool_manager.has_tool(tool_name)
|
||||
|
||||
async def handle_llm_function_call(
|
||||
self, conn, function_call_data: Dict[str, Any]
|
||||
self, conn, function_call_data: Dict[str, Any], speaker=None
|
||||
) -> Optional[ActionResponse]:
|
||||
"""处理LLM函数调用"""
|
||||
"""处理LLM函数调用。speaker: 本轮说话人快照。"""
|
||||
try:
|
||||
# 处理多函数调用
|
||||
if "function_calls" in function_call_data:
|
||||
responses = []
|
||||
for call in function_call_data["function_calls"]:
|
||||
result = await self.tool_manager.execute_tool(
|
||||
call["name"], call.get("arguments", {})
|
||||
call["name"], call.get("arguments", {}), speaker=speaker
|
||||
)
|
||||
responses.append(result)
|
||||
return self._combine_responses(responses)
|
||||
@@ -175,7 +175,9 @@ class UnifiedToolHandler:
|
||||
self.logger.warning(f"发送工具调用显示消息失败: {e}")
|
||||
|
||||
# 执行工具调用
|
||||
result = await self.tool_manager.execute_tool(function_name, arguments)
|
||||
result = await self.tool_manager.execute_tool(
|
||||
function_name, arguments, speaker=speaker
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -71,9 +71,9 @@ class ToolManager:
|
||||
return tool_def.tool_type if tool_def else None
|
||||
|
||||
async def execute_tool(
|
||||
self, tool_name: str, arguments: Dict[str, Any]
|
||||
self, tool_name: str, arguments: Dict[str, Any], speaker=None
|
||||
) -> ActionResponse:
|
||||
"""执行工具调用"""
|
||||
"""执行工具调用。speaker: 本轮说话人快照。"""
|
||||
try:
|
||||
# 查找工具类型
|
||||
tool_type = self.get_tool_type(tool_name)
|
||||
@@ -93,7 +93,7 @@ class ToolManager:
|
||||
|
||||
# 执行工具
|
||||
self.logger.info(f"执行工具: {tool_name},参数: {arguments}")
|
||||
result = await executor.execute(self.conn, tool_name, arguments)
|
||||
result = await executor.execute(self.conn, tool_name, arguments, speaker=speaker)
|
||||
self.logger.debug(f"工具执行结果: {result}")
|
||||
return result
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import re
|
||||
from typing import List, Dict
|
||||
from datetime import datetime
|
||||
|
||||
from core.utils.voiceprint_provider import UNKNOWN_SPEAKER_NAME
|
||||
|
||||
|
||||
class Message:
|
||||
def __init__(
|
||||
@@ -125,7 +127,7 @@ class Dialogue:
|
||||
current_speaker_name = (current_speaker or "").strip()
|
||||
# 仅在本轮注入了有效身份时才输出 speakers_info,避免列表里的名字每轮
|
||||
# 重复出现诱导模型反复称呼;后续轮不再注入身份,靠对话历史首轮保留
|
||||
if current_speaker_name and current_speaker_name != "未知说话人":
|
||||
if current_speaker_name and current_speaker_name != UNKNOWN_SPEAKER_NAME:
|
||||
speakers = voiceprint_config.get("speakers", [])
|
||||
speakers_info = "\n<speakers_info>"
|
||||
speakers_info += f"\n当前说话人:{current_speaker_name}"
|
||||
|
||||
@@ -3,7 +3,7 @@ import time
|
||||
import aiohttp
|
||||
import requests
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from typing import Optional, Dict
|
||||
from typing import Optional, Dict, NamedTuple
|
||||
from config.logger import setup_logging
|
||||
from core.utils.cache.manager import cache_manager
|
||||
from core.utils.cache.config import CacheType
|
||||
@@ -11,6 +11,30 @@ from core.utils.cache.config import CacheType
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
|
||||
# 未命中声纹时沿用的展示名。下游只把这个名字交给 LLM,不把它当成真实身份。
|
||||
UNKNOWN_SPEAKER_NAME = "未知说话人"
|
||||
|
||||
|
||||
class SpeakerIdentification(NamedTuple):
|
||||
"""一次声纹识别结果。speaker_id 仅在命中已配置说话人时有值。"""
|
||||
|
||||
speaker_id: Optional[str]
|
||||
name: str
|
||||
|
||||
|
||||
def resolve_speaker_identification(
|
||||
speaker_id: Optional[str],
|
||||
score: float,
|
||||
speaker_map: Dict[str, Dict[str, str]],
|
||||
similarity_threshold: float,
|
||||
) -> SpeakerIdentification:
|
||||
"""把声纹接口返回的 speaker_id / score 解析成展示名,并保留命中的 id。"""
|
||||
if score < similarity_threshold:
|
||||
return SpeakerIdentification(None, UNKNOWN_SPEAKER_NAME)
|
||||
if speaker_id and speaker_id in speaker_map:
|
||||
return SpeakerIdentification(speaker_id, speaker_map[speaker_id]["name"])
|
||||
return SpeakerIdentification(None, UNKNOWN_SPEAKER_NAME)
|
||||
|
||||
|
||||
class VoiceprintProvider:
|
||||
"""声纹识别服务提供者"""
|
||||
@@ -138,7 +162,16 @@ class VoiceprintProvider:
|
||||
return is_healthy
|
||||
|
||||
async def identify_speaker(self, audio_data: bytes, session_id: str) -> Optional[str]:
|
||||
"""识别说话人"""
|
||||
"""识别说话人,返回展示名。需要 speaker_id 时用 identify_speaker_detail。"""
|
||||
identified = await self.identify_speaker_detail(audio_data, session_id)
|
||||
if identified is None:
|
||||
return None
|
||||
return identified.name
|
||||
|
||||
async def identify_speaker_detail(
|
||||
self, audio_data: bytes, session_id: str
|
||||
) -> Optional[SpeakerIdentification]:
|
||||
"""识别说话人,同时返回声纹 id 和展示名。"""
|
||||
if not self.enabled or not self.api_url or not self.api_key:
|
||||
logger.bind(tag=TAG).debug("声纹识别功能已禁用或未配置,跳过识别")
|
||||
return None
|
||||
@@ -171,18 +204,19 @@ class VoiceprintProvider:
|
||||
|
||||
logger.bind(tag=TAG).info(f"声纹识别耗时: {total_elapsed_time:.3f}s")
|
||||
|
||||
# 相似度阈值检查
|
||||
if score < self.similarity_threshold:
|
||||
identified = resolve_speaker_identification(
|
||||
speaker_id, score, self.speaker_map, self.similarity_threshold
|
||||
)
|
||||
# 三种结果:命中 / 低于阈值 / id不在配置中
|
||||
if identified.speaker_id:
|
||||
logger.bind(tag=TAG).info(
|
||||
f"声纹识别成功: {identified.name} (id: {identified.speaker_id}, 相似度: {score:.3f})"
|
||||
)
|
||||
elif score < self.similarity_threshold:
|
||||
logger.bind(tag=TAG).warning(f"声纹识别相似度{score:.3f}低于阈值{self.similarity_threshold}")
|
||||
return "未知说话人"
|
||||
|
||||
if speaker_id and speaker_id in self.speaker_map:
|
||||
result_name = self.speaker_map[speaker_id]["name"]
|
||||
logger.bind(tag=TAG).info(f"声纹识别成功: {result_name} (相似度: {score:.3f})")
|
||||
return result_name
|
||||
else:
|
||||
logger.bind(tag=TAG).warning(f"未识别的说话人ID: {speaker_id}")
|
||||
return "未知说话人"
|
||||
return identified
|
||||
else:
|
||||
logger.bind(tag=TAG).error(f"声纹识别API错误: HTTP {response.status}")
|
||||
return None
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,162 @@
|
||||
"""MCP 接入点 tools/call 通过 _meta 携带说话人,不改工具参数。"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from core.providers.tools.mcp_endpoint.mcp_speaker import (
|
||||
build_mcp_endpoint_tool_payload,
|
||||
should_forward_speaker,
|
||||
speaker_call_meta,
|
||||
)
|
||||
from core.utils.voiceprint_provider import UNKNOWN_SPEAKER_NAME, SpeakerIdentification
|
||||
|
||||
|
||||
def test_meta_includes_id_and_name():
|
||||
assert speaker_call_meta(" test1 ", " 张三 ") == {
|
||||
"speakerId": "test1",
|
||||
"speakerName": "张三",
|
||||
}
|
||||
|
||||
|
||||
def test_meta_omits_unknown_speaker_and_blank_id():
|
||||
assert speaker_call_meta(None, UNKNOWN_SPEAKER_NAME) is None
|
||||
assert speaker_call_meta(" ", "张三") == {"speakerName": "张三"}
|
||||
assert speaker_call_meta("test1", UNKNOWN_SPEAKER_NAME) == {"speakerId": "test1"}
|
||||
|
||||
|
||||
def test_forwarding_defaults_on_and_can_be_disabled():
|
||||
assert should_forward_speaker(None) is True
|
||||
assert should_forward_speaker({}) is True
|
||||
assert should_forward_speaker({"mcp_forward_speaker": True}) is True
|
||||
assert should_forward_speaker({"mcp_forward_speaker": False}) is False
|
||||
# 字符串 false 可关闭;空值视为缺省开启
|
||||
assert should_forward_speaker({"mcp_forward_speaker": "false"}) is False
|
||||
assert should_forward_speaker({"mcp_forward_speaker": "False"}) is False
|
||||
assert should_forward_speaker({"mcp_forward_speaker": "0"}) is False
|
||||
assert should_forward_speaker({"mcp_forward_speaker": "true"}) is True
|
||||
assert should_forward_speaker({"mcp_forward_speaker": None}) is True
|
||||
# 空串按关闭处理,与 manager-api 的 Boolean.parseBoolean("") 一致
|
||||
assert should_forward_speaker({"mcp_forward_speaker": ""}) is False
|
||||
|
||||
|
||||
def test_payload_adds_meta_without_touching_arguments():
|
||||
arguments = {"name": "灯"}
|
||||
payload = build_mcp_endpoint_tool_payload(
|
||||
7,
|
||||
"HassTurnOn",
|
||||
arguments,
|
||||
{"speakerId": "test1", "speakerName": "张三"},
|
||||
)
|
||||
assert payload["method"] == "tools/call"
|
||||
assert payload["params"]["arguments"] is arguments
|
||||
assert "speakerId" not in arguments
|
||||
assert payload["params"]["_meta"]["speakerId"] == "test1"
|
||||
|
||||
|
||||
def test_payload_without_meta_matches_previous_shape():
|
||||
payload = build_mcp_endpoint_tool_payload(7, "calculator", {"a": 1})
|
||||
assert set(payload["params"]) == {"name", "arguments"}
|
||||
assert "_meta" not in payload["params"]
|
||||
|
||||
|
||||
class _Client:
|
||||
async def is_ready(self):
|
||||
return True
|
||||
|
||||
|
||||
class _Conn:
|
||||
def __init__(self, config=None):
|
||||
self.mcp_endpoint_client = _Client()
|
||||
self.config = {} if config is None else config
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_forwards_speaker_in_meta_only(monkeypatch):
|
||||
from core.providers.tools.mcp_endpoint import mcp_endpoint_executor as executor_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_call(client, tool_name, args_str="{}", timeout=30, meta=None):
|
||||
captured["tool_name"] = tool_name
|
||||
captured["args"] = args_str
|
||||
captured["meta"] = meta
|
||||
return "done"
|
||||
|
||||
monkeypatch.setattr(executor_mod, "call_mcp_endpoint_tool", fake_call)
|
||||
arguments = {"area": "客厅"}
|
||||
conn = _Conn()
|
||||
result = await executor_mod.MCPEndpointExecutor(conn).execute(
|
||||
conn,
|
||||
"HassTurnOn",
|
||||
arguments,
|
||||
speaker=SpeakerIdentification("test1", "张三"),
|
||||
)
|
||||
|
||||
assert json.loads(captured["args"]) == {"area": "客厅"}
|
||||
assert captured["meta"] == {"speakerId": "test1", "speakerName": "张三"}
|
||||
assert arguments == {"area": "客厅"}
|
||||
assert result.result == "done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_skips_meta_when_disabled(monkeypatch):
|
||||
from core.providers.tools.mcp_endpoint import mcp_endpoint_executor as executor_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_call(client, tool_name, args_str="{}", timeout=30, meta=None):
|
||||
captured["meta"] = meta
|
||||
return "done"
|
||||
|
||||
monkeypatch.setattr(executor_mod, "call_mcp_endpoint_tool", fake_call)
|
||||
conn = _Conn(config={"mcp_forward_speaker": False})
|
||||
await executor_mod.MCPEndpointExecutor(conn).execute(
|
||||
conn,
|
||||
"HassTurnOn",
|
||||
{"area": "客厅"},
|
||||
speaker=SpeakerIdentification("test1", "张三"),
|
||||
)
|
||||
assert captured["meta"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_omits_meta_for_unknown_speaker(monkeypatch):
|
||||
from core.providers.tools.mcp_endpoint import mcp_endpoint_executor as executor_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_call(client, tool_name, args_str="{}", timeout=30, meta=None):
|
||||
captured["meta"] = meta
|
||||
return "done"
|
||||
|
||||
monkeypatch.setattr(executor_mod, "call_mcp_endpoint_tool", fake_call)
|
||||
conn = _Conn()
|
||||
await executor_mod.MCPEndpointExecutor(conn).execute(
|
||||
conn,
|
||||
"HassTurnOn",
|
||||
{},
|
||||
speaker=SpeakerIdentification(None, UNKNOWN_SPEAKER_NAME),
|
||||
)
|
||||
assert captured["meta"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_reads_snapshot_not_conn_state(monkeypatch):
|
||||
"""meta 取自快照,不受连接态影响。"""
|
||||
from core.providers.tools.mcp_endpoint import mcp_endpoint_executor as executor_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_call(client, tool_name, args_str="{}", timeout=30, meta=None):
|
||||
captured["meta"] = meta
|
||||
return "done"
|
||||
|
||||
monkeypatch.setattr(executor_mod, "call_mcp_endpoint_tool", fake_call)
|
||||
conn = _Conn()
|
||||
await executor_mod.MCPEndpointExecutor(conn).execute(
|
||||
conn,
|
||||
"HassTurnOn",
|
||||
{},
|
||||
speaker=SpeakerIdentification("test1", "张三"),
|
||||
)
|
||||
assert captured["meta"] == {"speakerId": "test1", "speakerName": "张三"}
|
||||
@@ -0,0 +1,98 @@
|
||||
"""声纹识别结果要同时保留 speaker_id 和展示名。"""
|
||||
import aiohttp
|
||||
import pytest
|
||||
|
||||
from core.utils.voiceprint_provider import (
|
||||
UNKNOWN_SPEAKER_NAME,
|
||||
VoiceprintProvider,
|
||||
resolve_speaker_identification,
|
||||
)
|
||||
|
||||
SPEAKERS = {"test1": {"name": "张三", "description": "程序员"}}
|
||||
|
||||
|
||||
def test_matched_speaker_keeps_id_and_name():
|
||||
identified = resolve_speaker_identification("test1", 0.91, SPEAKERS, 0.4)
|
||||
assert identified.speaker_id == "test1"
|
||||
assert identified.name == "张三"
|
||||
|
||||
|
||||
def test_score_equal_to_threshold_still_matches():
|
||||
identified = resolve_speaker_identification("test1", 0.4, SPEAKERS, 0.4)
|
||||
assert identified.speaker_id == "test1"
|
||||
|
||||
|
||||
def test_low_score_hides_id_and_uses_unknown_name():
|
||||
identified = resolve_speaker_identification("test1", 0.39, SPEAKERS, 0.4)
|
||||
assert identified.speaker_id is None
|
||||
assert identified.name == UNKNOWN_SPEAKER_NAME
|
||||
|
||||
|
||||
def test_unconfigured_speaker_id_is_unknown():
|
||||
identified = resolve_speaker_identification("other", 0.99, SPEAKERS, 0.4)
|
||||
assert identified.speaker_id is None
|
||||
assert identified.name == UNKNOWN_SPEAKER_NAME
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, status, payload):
|
||||
self.status = status
|
||||
self._payload = payload
|
||||
|
||||
async def json(self):
|
||||
return self._payload
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, response):
|
||||
self._response = response
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def post(self, *args, **kwargs):
|
||||
return self._response
|
||||
|
||||
|
||||
def _enabled_provider():
|
||||
provider = VoiceprintProvider({})
|
||||
provider.enabled = True
|
||||
provider.api_url = "http://voiceprint.local/voiceprint/identify"
|
||||
provider.api_key = "k"
|
||||
provider.speaker_ids = ["test1"]
|
||||
provider.speaker_map = SPEAKERS
|
||||
provider.similarity_threshold = 0.4
|
||||
return provider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_identify_speaker_still_returns_display_name(monkeypatch):
|
||||
provider = _enabled_provider()
|
||||
monkeypatch.setattr(
|
||||
aiohttp,
|
||||
"ClientSession",
|
||||
lambda *args, **kwargs: _Session(
|
||||
_Response(200, {"speaker_id": "test1", "score": 0.88})
|
||||
),
|
||||
)
|
||||
|
||||
assert await provider.identify_speaker(b"wav", "session") == "张三"
|
||||
detail = await provider.identify_speaker_detail(b"wav", "session")
|
||||
assert detail.speaker_id == "test1"
|
||||
assert detail.name == "张三"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_identify_speaker_disabled_returns_none():
|
||||
provider = VoiceprintProvider({})
|
||||
assert await provider.identify_speaker(b"wav", "session") is None
|
||||
assert await provider.identify_speaker_detail(b"wav", "session") is None
|
||||
Reference in new issue
Block a user