fix: 把已识别说话人附到 MCP 接入点 tools/call (#3443)

声纹接口返回的 speaker_id 此前只换成展示名交给 LLM,接入点转发工具参数时不会带上说话人。识别成功后把 speakerId / speakerName 写入 tools/call 的 params._meta,工具参数保持原样;可用 mcp_forward_speaker 关闭。

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: shinjiyu <shinjiyu@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-26 10:04:51 +08:00
1 parent 9bd668aebc
commit 9880ce998f
10 files changed
+350 -23

No files matched your search

+4
View File
@@ -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
# 上下文源配置
# 用于在系统提示词中注入动态数据,如健康数据、股票信息等
+2 -1
View File
@@ -158,7 +158,8 @@ 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.current_speaker_id = None # 声纹识别得到的 speaker_id
self.introduced_speakers = set() # 已"首次引入"的说话人,控制只在首轮带名字
self.system_introduced_speakers = set() # 已在 system 注入过身份的说话人,控制 system 身份只首轮出现
@@ -69,11 +69,13 @@ async def startToChat(conn: "ConnectionHandler", text):
# 如果解析失败,继续使用原始文本
pass
# 保存说话人信息到连接对象
# 保存说话人信息到连接对象。speaker_id 由声纹识别写入,这里只在没有说话人时清掉,
# 避免上一轮身份落到没有声纹的文本消息上。
if speaker_name:
conn.current_speaker = speaker_name
else:
conn.current_speaker = None
conn.current_speaker_id = None
if conn.need_bind:
await check_bind_device(conn)
+11 -2
View File
@@ -101,7 +101,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 +119,15 @@ class ASRProviderBase(ABC):
else:
raw_text, _ = asr_result
speaker_id = None
if isinstance(voiceprint_result, Exception):
logger.bind(tag=TAG).error(f"声纹识别失败: {voiceprint_result}")
speaker_name = ""
elif voiceprint_result is not None:
speaker_name = voiceprint_result.name or ""
speaker_id = voiceprint_result.speaker_id
else:
speaker_name = voiceprint_result
speaker_name = ""
# 判断 ASR 结果类型
if isinstance(raw_text, dict):
@@ -164,6 +168,11 @@ class ASRProviderBase(ABC):
self.stop_ws_connection()
if text_len > 0:
# 声纹 id 不进入发给 LLM 的文本,留在连接上供本轮 MCP 接入点调用读取。
if isinstance(speaker_id, str) and speaker_id.strip():
conn.current_speaker_id = speaker_id.strip()
else:
conn.current_speaker_id = None
audio_snapshot = asr_audio_task.copy()
enqueue_asr_report(conn, enhanced_text, audio_snapshot)
# 使用自定义模块进行上报
@@ -4,6 +4,7 @@ from typing import Dict, Any
from ..base import ToolType, ToolDefinition, ToolExecutor
from plugins_func.register import Action, ActionResponse
from .mcp_endpoint_handler import call_mcp_endpoint_tool
from .mcp_speaker import should_forward_speaker, speaker_call_meta
class MCPEndpointExecutor(ToolExecutor):
@@ -29,14 +30,21 @@ class MCPEndpointExecutor(ToolExecutor):
)
try:
# 转换参数为JSON字符串
# 转换参数为JSON字符串。说话人身份不写入参数,只放在 _meta。
import json
meta = None
if should_forward_speaker(getattr(conn, "config", None)):
meta = speaker_call_meta(
getattr(conn, "current_speaker_id", None),
getattr(conn, "current_speaker", None),
)
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,48 @@
"""把已识别的说话人附到 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
return bool(config.get("mcp_forward_speaker", True))
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,
}
@@ -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,27 @@ 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: Optional[str]
def resolve_speaker_identification(
speaker_id, score, speaker_map, similarity_threshold
) -> 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 +159,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 +201,19 @@ class VoiceprintProvider:
logger.bind(tag=TAG).info(f"声纹识别耗时: {total_elapsed_time:.3f}s")
identified = resolve_speaker_identification(
speaker_id, score, self.speaker_map, self.similarity_threshold
)
# 相似度阈值检查
if 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
elif identified.speaker_id:
logger.bind(tag=TAG).info(
f"声纹识别成功: {identified.name} (id: {identified.speaker_id}, 相似度: {score:.3f})"
)
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
@@ -0,0 +1,121 @@
"""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
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
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, speaker_id="test1", speaker_name="张三"):
self.mcp_endpoint_client = _Client()
self.config = {} if config is None else config
self.current_speaker_id = speaker_id
self.current_speaker = speaker_name
@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
)
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": "客厅"})
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(speaker_id=None, speaker_name=UNKNOWN_SPEAKER_NAME)
await executor_mod.MCPEndpointExecutor(conn).execute(conn, "HassTurnOn", {})
assert captured["meta"] is None
@@ -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