按 P1-Pipecat Flows Agent化与记忆改造交接.md 的 Phase 2 完成。
legacy 路径行为不变,只是改为复用新工具层。
新增 app/agent/
- memory_policy.py:记忆写入/删除的裁决层。模型只提议,策略决定。
不依赖 LLM/网络/真实存储,可纯单元测试。
- 身份未确认 → 一律 reject,不碰私人记忆
- 模型传 owner_user_id/scope 等越权字段 → reject(不静默忽略)
- 只有用户明确说"记住/以后/别忘了/我的偏好是"才写 confirmed
- "记住我今天吃了香菜"这类一次性事件 → 降级 candidate
- 不采信模型自称的"用户明确要求"
- 模糊/多条删除 → needs_confirmation,不猜目标
- tools.py:工具 schema 与执行器,legacy 与 flows 共用。
- 身份由 ToolContext 注入,**不在工具参数里**
- 身份未确认时不暴露任何记忆工具(比暴露后拒绝更干净)
- 写入结果如实回报:candidate 明确告知"尚未生效",
删除如实返回"已删除/没有找到/需要确认",不把"请求已收到"说成"已做到"
- 工具失败转成给模型的文本,不拖垮本轮对话
- llm_service.py:Pipecat LLMService 工厂,固化 Phase 1 spike 的选型结论
(复用 DeepSeekLLMService;必须显式覆盖模型名为 deepseek-flash,
因为 Pipecat 默认的 deepseek-v4-flash 不存在)
改造 app/engine.py
- _execute_tool 改为委托 app/agent/tools.execute(),删除重复实现
- 本轮用户原话挂到实例上,供策略层判定语义等级
- 移除不再使用的 SearchError 导入
新增 app/tests/test_agent_tools.py:45 项断言,**不依赖 LLM/网络/SQLite**
(用 fake store)。覆盖交接文档 §十 的清单:越权参数、未确认身份、
明确记住 vs 推断、同 key 幂等、模糊删除、精确删除、跨用户隔离。
回归:test_agent_tools 45/45、test_memory 30/30、test_identity 19/19、
test_search 12/12、test_transport 4/4、test_profile_memory 22/22,无退化。
607 lines
25 KiB
Python
607 lines
25 KiB
Python
"""会话引擎:身份 → 记忆 → LLM → TTS 的核心回路。
|
||
|
||
这一层不依赖 Pipecat、不依赖任何传输方式,只依赖四个接口
|
||
(LLMProvider / MemoryStore / IdentityProvider / LocalTTS)。
|
||
好处有两个:
|
||
|
||
1. **可以用文件音频端到端测试**,不需要麦克风、音箱、WebRTC。
|
||
2. 接 Pipecat 时只需写薄适配层;换传输方式(WebRTC / 本地声卡)不动这里。
|
||
|
||
一轮的完整流程:
|
||
|
||
用户音频
|
||
└→ STT 转写
|
||
└→ 身份裁决(决定用谁的记忆)
|
||
└→ 记忆检索(带超时,失败不阻塞)
|
||
└→ 拼 system(每轮重建,单条)
|
||
└→ LLM 流式
|
||
└→ 按句切分 → 逐句 TTS → 边合成边播
|
||
└→ 回复结束,异步写记忆(绑定创建时的身份)
|
||
|
||
延迟的关键在最后两步:**必须按句切分后立刻合成**。
|
||
VITS 是非自回归的,整段合成完才出声;只有切成短句才能提前开口。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import re
|
||
import time
|
||
import uuid
|
||
from dataclasses import dataclass, field
|
||
from enum import Enum
|
||
from typing import AsyncIterator, Sequence
|
||
|
||
import numpy as np
|
||
|
||
from .config import AppConfig
|
||
from .identity.base import IdentityDecision, IdentityProvider
|
||
from .agent.tools import ToolContext, execute as execute_tool
|
||
from .llm.base import ChunkKind, LLMProvider, ToolCall
|
||
from .memory.base import (
|
||
ConfirmStatus, Memory, MemoryKind, MemoryStore, Scope, Turn,
|
||
)
|
||
from .prompts import build_system_prompt, trim_history
|
||
from .search import (
|
||
WEB_SEARCH_PROMPT,
|
||
WEB_SEARCH_TOOL,
|
||
SearXNGSearchClient,
|
||
)
|
||
from .voice.stt import StreamingSTT
|
||
from .voice.tts import LocalTTS, split_sentences, text_for_speech
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
_SOURCE_SECTION_RE = re.compile(
|
||
r"(?:^|\n)\s*(?:参考来源|资料来源|来源)\s*[::]",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
_DISPLAY_NAME_PATTERNS = (
|
||
re.compile(
|
||
r"(?:我叫|我的(?:名字|姓名)(?:是|叫)|(?:你可以)?叫我)\s*"
|
||
r"[“\"']?([^,,。!?!?;;::\n\"”']{1,24})[”\"']?"
|
||
r"(?=$|[,,。!?!?;;::\n])",
|
||
re.IGNORECASE,
|
||
),
|
||
# 兼容语音识别常见的“我是林”。只接受 1–4 个中文姓名字符,
|
||
# 避免把“我是程序员/中国人”一类身份描述当作姓名。
|
||
re.compile(
|
||
r"我是\s*([\u4e00-\u9fff·]{1,4})"
|
||
r"(?=$|[,,。!?!?;;::\n])"
|
||
),
|
||
)
|
||
|
||
_NOT_NAMES = {
|
||
"程序员", "工程师", "中国人", "学生", "老师", "医生", "男生", "女生",
|
||
"用户", "访客", "谁", "什么", "哪位", "你", "他", "她", "它", "你的", "他的", "她的",
|
||
}
|
||
|
||
_NAME_QUESTION_WORDS = ("谁", "什么", "哪位")
|
||
_NAME_QUESTION_PARTICLES = ("吗", "么", "呢")
|
||
|
||
|
||
class EventKind(str, Enum):
|
||
IDENTITY = "identity" # 本轮的身份裁决结果
|
||
MEMORY_USED = "memory_used" # 检索到的记忆
|
||
TRANSCRIPT = "transcript" # STT 结果
|
||
RESPONSE_DELTA = "response_delta" # LLM 增量文本(未播,仅供显示)
|
||
SPEAK = "speak" # 一段可播放的音频
|
||
TOOL_CALL = "tool_call" # 模型请求调用工具
|
||
METRICS = "metrics" # 本轮计时
|
||
ERROR = "error"
|
||
|
||
|
||
@dataclass
|
||
class Event:
|
||
kind: EventKind
|
||
text: str = ""
|
||
audio: np.ndarray | None = None
|
||
sample_rate: int = 16000
|
||
identity: IdentityDecision | None = None
|
||
memories: list[Memory] = field(default_factory=list)
|
||
tool_call: ToolCall | None = None
|
||
metrics: "TurnMetrics | None" = None
|
||
error: str = ""
|
||
|
||
|
||
@dataclass
|
||
class TurnMetrics:
|
||
"""按方案 §10 要求记录耗时,便于定位瓶颈。"""
|
||
stt_ms: float = 0.0
|
||
identity_ms: float = 0.0
|
||
retrieve_ms: float = 0.0
|
||
llm_first_content_ms: float = 0.0
|
||
first_audio_ms: float = 0.0 # 从开始处理到第一段音频就绪
|
||
total_ms: float = 0.0
|
||
llm_backend: str = ""
|
||
memories_used: int = 0
|
||
reply_chars: int = 0
|
||
speaks: int = 0
|
||
search_ms: float = 0.0
|
||
searches: int = 0
|
||
|
||
def summary(self) -> str:
|
||
return (f"STT {self.stt_ms:.0f}ms | 身份 {self.identity_ms:.0f}ms | "
|
||
f"检索 {self.retrieve_ms:.0f}ms | LLM首token {self.llm_first_content_ms:.0f}ms | "
|
||
f"首音 {self.first_audio_ms:.0f}ms | 总 {self.total_ms:.0f}ms | "
|
||
f"{self.llm_backend} | 记忆{self.memories_used}条 | "
|
||
f"搜索{self.searches}次/{self.search_ms:.0f}ms | {self.speaks}段音频")
|
||
|
||
|
||
class ConversationEngine:
|
||
def __init__(
|
||
self,
|
||
cfg: AppConfig,
|
||
llm: LLMProvider,
|
||
memory: MemoryStore,
|
||
identity: IdentityProvider,
|
||
tts: LocalTTS,
|
||
stt: StreamingSTT | None = None,
|
||
search: SearXNGSearchClient | None = None,
|
||
):
|
||
self.cfg = cfg
|
||
self.llm = llm
|
||
self.memory = memory
|
||
self.identity = identity
|
||
self.tts = tts
|
||
self.stt = stt
|
||
self.search = search or (
|
||
SearXNGSearchClient(cfg.search) if cfg.search.enabled else None
|
||
)
|
||
#: 会话历史。切身份时会清空——方案 §8 要求切换身份
|
||
#: 清理上一人的私有检索上下文。
|
||
self._history: list[dict] = []
|
||
self._history_identity_version: int = -1
|
||
self._session_id: str = ""
|
||
self._current_turn: asyncio.Task | None = None
|
||
|
||
# ------------------------------------------------------------ 会话
|
||
|
||
async def start_session(self, session_id: str | None = None,
|
||
user_id: str | None = None) -> str:
|
||
sid = session_id or f"s_{uuid.uuid4().hex[:12]}"
|
||
if user_id:
|
||
self.identity.switch(user_id, reason="会话指定")
|
||
d = self.identity.current()
|
||
await self.memory.start_session(sid, d.user_id)
|
||
if d.confirmed:
|
||
try:
|
||
display_name = await self.memory.get_user_display_name(d.user_id)
|
||
if display_name:
|
||
self.identity.switch(
|
||
d.user_id,
|
||
display_name=display_name,
|
||
reason="从用户档案恢复称呼",
|
||
)
|
||
d = self.identity.current()
|
||
except Exception as exc: # noqa: BLE001 — 档案读取失败不阻塞会话
|
||
log.warning("读取用户称呼失败(本轮继续):%s", exc)
|
||
self._session_id = sid
|
||
self._history_identity_version = self._identity_version()
|
||
log.info("会话开始 %s,身份 %s", sid, d.user_id)
|
||
return sid
|
||
|
||
async def end_session(self) -> None:
|
||
if self._session_id:
|
||
await self.memory.end_session(self._session_id)
|
||
|
||
def _identity_version(self) -> int:
|
||
# ManualIdentityProvider 有 version;其他实现没有时退化为 0
|
||
return getattr(self.identity, "version", 0)
|
||
|
||
def _sync_identity_scope(self) -> None:
|
||
"""身份变了就丢弃历史。
|
||
|
||
不做这件事的后果:切到新用户后,模型仍看得到上一人的对话,
|
||
第一条回复就可能带出别人的信息。
|
||
"""
|
||
v = self._identity_version()
|
||
if v != self._history_identity_version:
|
||
if self._history:
|
||
log.info("身份变更,丢弃 %d 条历史消息", len(self._history))
|
||
self._history = []
|
||
self._history_identity_version = v
|
||
|
||
# ------------------------------------------------------------ 主回路
|
||
|
||
async def respond_to_text(self, user_text: str, *,
|
||
session_id: str | None = None) -> AsyncIterator[Event]:
|
||
"""处理一句用户输入,产出事件流。"""
|
||
t_start = time.perf_counter()
|
||
metrics = TurnMetrics()
|
||
sid = session_id or self._session_id or await self.start_session()
|
||
|
||
self._sync_identity_scope()
|
||
identity = self.identity.current()
|
||
display_name = extract_display_name(user_text)
|
||
if display_name and identity.confirmed:
|
||
await self._remember_display_name(identity, display_name, sid)
|
||
identity = self.identity.current()
|
||
metrics.identity_ms = (time.perf_counter() - t_start) * 1000
|
||
yield Event(kind=EventKind.IDENTITY, identity=identity)
|
||
|
||
if not user_text.strip():
|
||
yield Event(kind=EventKind.METRICS, metrics=metrics)
|
||
return
|
||
|
||
# ---- 记忆检索:带超时,失败不阻塞(方案 §8)
|
||
t0 = time.perf_counter()
|
||
memories = await self._retrieve_memories(identity, user_text)
|
||
metrics.retrieve_ms = (time.perf_counter() - t0) * 1000
|
||
metrics.memories_used = len(memories)
|
||
if memories:
|
||
yield Event(kind=EventKind.MEMORY_USED, memories=memories)
|
||
|
||
# ---- 拼 system(每轮重建,单条)
|
||
system = build_system_prompt(
|
||
identity,
|
||
memories,
|
||
extra=WEB_SEARCH_PROMPT if self.search is not None else "",
|
||
)
|
||
history = trim_history(self._history,
|
||
max_turns=self.cfg.context.max_turns)
|
||
messages = [{"role": "system", "content": system}] + history + \
|
||
[{"role": "user", "content": user_text}]
|
||
|
||
# ---- LLM 流式 + 逐句 TTS
|
||
reply_parts: list[str] = []
|
||
buffer = ""
|
||
speech_done = False
|
||
speaks = 0
|
||
# 策略层要拿用户原话来判定"明确要求记住"还是"模型推断",
|
||
# 所以本轮原话要挂到实例上,供 _execute_tool 构造 ToolContext。
|
||
self._current_user_text = user_text
|
||
async for ev in self._stream_and_speak(messages, metrics):
|
||
if ev.kind == EventKind.RESPONSE_DELTA:
|
||
reply_parts.append(ev.text)
|
||
# 增量文本必须**原样透传**给调用方(显示、字幕、日志都要用)。
|
||
# 这里漏掉 yield 的后果是:音频正常出来了,但上层拿到的是空回复——
|
||
# 因为文本只进了内部累加器,从未离开这个生成器。
|
||
yield ev
|
||
if speech_done:
|
||
continue
|
||
buffer += ev.text
|
||
source_start = _SOURCE_SECTION_RE.search(buffer)
|
||
if source_start:
|
||
buffer = buffer[:source_start.start()]
|
||
speech_done = True
|
||
# 攒到句末就送去合成——这是提前出声的唯一手段
|
||
segs = split_sentences(buffer)
|
||
if len(segs) > 1:
|
||
*ready, buffer = segs
|
||
for seg in ready:
|
||
async for speak_ev in self._speak(seg, metrics):
|
||
speaks += 1
|
||
yield speak_ev
|
||
else:
|
||
yield ev
|
||
|
||
# 收尾:把剩下的半句也合成掉
|
||
if buffer.strip():
|
||
async for speak_ev in self._speak(buffer, metrics):
|
||
speaks += 1
|
||
yield speak_ev
|
||
|
||
reply = "".join(reply_parts).strip()
|
||
metrics.reply_chars = len(reply)
|
||
metrics.speaks = speaks
|
||
metrics.total_ms = (time.perf_counter() - t_start) * 1000
|
||
|
||
# ---- 落历史
|
||
self._history.append({"role": "user", "content": user_text})
|
||
if reply:
|
||
self._history.append({"role": "assistant", "content": reply})
|
||
|
||
# ---- 异步写记忆:**绑定创建时的身份**,不读"当前用户"
|
||
if self.cfg.memory.auto_extract_enabled and reply:
|
||
self._spawn_memory_write(
|
||
user_text=user_text, reply=reply,
|
||
user_id=identity.user_id,
|
||
session_id=sid,
|
||
turn_id=f"t_{uuid.uuid4().hex[:10]}")
|
||
|
||
await self.memory.add_turn(Turn(
|
||
turn_id=f"t_{uuid.uuid4().hex[:10]}", session_id=sid,
|
||
user_id=identity.user_id, role="user", text=user_text))
|
||
|
||
yield Event(kind=EventKind.METRICS, metrics=metrics)
|
||
|
||
async def respond_to_audio(self, samples: np.ndarray, *,
|
||
session_id: str | None = None) -> AsyncIterator[Event]:
|
||
"""从音频走完整条链路(需要 STT)。"""
|
||
if self.stt is None:
|
||
yield Event(kind=EventKind.ERROR, error="未配置 STT,无法处理音频")
|
||
return
|
||
t0 = time.perf_counter()
|
||
stream = self.stt.new_stream()
|
||
stream.accept(samples)
|
||
text = stream.finish()
|
||
stt_ms = (time.perf_counter() - t0) * 1000
|
||
yield Event(kind=EventKind.TRANSCRIPT, text=text)
|
||
async for ev in self.respond_to_text(text, session_id=session_id):
|
||
if ev.kind == EventKind.METRICS and ev.metrics:
|
||
ev.metrics.stt_ms = stt_ms
|
||
yield ev
|
||
|
||
# ------------------------------------------------------------ 内部
|
||
|
||
async def _retrieve_memories(self, identity: IdentityDecision,
|
||
query: str) -> list[Memory]:
|
||
"""带超时的检索。超时/出错一律返回空,绝不让对话卡住。"""
|
||
if not identity.confirmed:
|
||
# 访客不做个性化检索(方案 §6:"未知身份只用访客临时上下文")
|
||
return []
|
||
try:
|
||
return await asyncio.wait_for(
|
||
self.memory.retrieve(
|
||
identity.user_id, query,
|
||
limit=self.cfg.context.max_memories,
|
||
scopes=[Scope.PRIVATE, Scope.SHARED]),
|
||
timeout=self.cfg.context.memory_timeout_s)
|
||
except asyncio.TimeoutError:
|
||
log.warning("记忆检索超时 %.2fs,本轮不带记忆继续",
|
||
self.cfg.context.memory_timeout_s)
|
||
return []
|
||
except Exception as e: # noqa: BLE001
|
||
log.warning("记忆检索失败(%s),本轮不带记忆继续", e)
|
||
return []
|
||
|
||
async def _remember_display_name(
|
||
self,
|
||
identity: IdentityDecision,
|
||
display_name: str,
|
||
session_id: str,
|
||
) -> None:
|
||
"""同步保存用户明确自报的称呼,保证断开连接前已经落盘。"""
|
||
turn_id = f"t_{uuid.uuid4().hex[:10]}"
|
||
|
||
async def write_profile() -> None:
|
||
await self.memory.set_user_display_name(identity.user_id, display_name)
|
||
await self.memory.remember(Memory(
|
||
owner_user_id=identity.user_id,
|
||
kind=MemoryKind.FACT,
|
||
key="姓名",
|
||
value=display_name,
|
||
confirm_status=ConfirmStatus.CONFIRMED,
|
||
source_session_id=session_id,
|
||
source_turn_id=turn_id,
|
||
))
|
||
|
||
try:
|
||
await asyncio.wait_for(
|
||
write_profile(),
|
||
timeout=max(1.0, self.cfg.context.memory_timeout_s),
|
||
)
|
||
log.info("已确认并保存用户称呼:%s", display_name)
|
||
except Exception as exc: # noqa: BLE001 — 记忆失败仍允许本轮继续
|
||
log.warning("保存用户称呼失败(仅本轮生效):%s", exc)
|
||
|
||
# 自报称呼只用于个性化,保持原 user_id 和 trust_level,不构成认证。
|
||
self.identity.switch(
|
||
identity.user_id,
|
||
display_name=display_name,
|
||
reason="用户明确自报称呼",
|
||
)
|
||
|
||
async def _stream_and_speak(self, messages: list[dict],
|
||
metrics: TurnMetrics) -> AsyncIterator[Event]:
|
||
"""执行 LLM/搜索工具闭环;最终文本仍然流式交给调用方做 TTS。"""
|
||
t0 = time.perf_counter()
|
||
try:
|
||
# 最多允许连续两轮搜索;第三次请求不再暴露工具,强制形成回答,
|
||
# 防止模型在搜索词之间无限循环。
|
||
for round_index in range(3):
|
||
tool_defs = (
|
||
[WEB_SEARCH_TOOL]
|
||
if self.search is not None and round_index < 2
|
||
else None
|
||
)
|
||
tool_acc: dict[int, dict[str, str]] = {}
|
||
assistant_parts: list[str] = []
|
||
|
||
async for chunk in self.llm.stream(messages, tools=tool_defs):
|
||
if chunk.kind == ChunkKind.CONTENT and chunk.text:
|
||
assistant_parts.append(chunk.text)
|
||
if not metrics.llm_first_content_ms:
|
||
metrics.llm_first_content_ms = (
|
||
time.perf_counter() - t0
|
||
) * 1000
|
||
yield Event(kind=EventKind.RESPONSE_DELTA, text=chunk.text)
|
||
elif chunk.kind == ChunkKind.TOOL_CALL:
|
||
slot = tool_acc.setdefault(
|
||
chunk.tool_index,
|
||
{"id": "", "name": "", "arguments": ""},
|
||
)
|
||
# base.py 传来的是截至当前分片的完整累计值,直接覆盖,
|
||
# 不能再次拼接,否则参数会重复。
|
||
slot["id"] = chunk.tool_id or slot["id"]
|
||
slot["name"] = chunk.tool_name or slot["name"]
|
||
slot["arguments"] = chunk.tool_arguments
|
||
|
||
metrics.llm_backend = getattr(
|
||
self.llm, "last_backend", self.llm.name)
|
||
if not tool_acc:
|
||
break
|
||
|
||
calls: list[ToolCall] = []
|
||
api_calls: list[dict] = []
|
||
for index, raw in sorted(tool_acc.items()):
|
||
call = ToolCall(
|
||
id=raw["id"] or f"call_{uuid.uuid4().hex[:12]}",
|
||
name=raw["name"],
|
||
arguments=raw["arguments"] or "{}",
|
||
)
|
||
calls.append(call)
|
||
api_calls.append({
|
||
"id": call.id,
|
||
"type": "function",
|
||
"function": {
|
||
"name": call.name,
|
||
"arguments": call.arguments,
|
||
},
|
||
})
|
||
yield Event(kind=EventKind.TOOL_CALL, tool_call=call)
|
||
|
||
messages.append({
|
||
"role": "assistant",
|
||
"content": "".join(assistant_parts) or None,
|
||
"tool_calls": api_calls,
|
||
})
|
||
for call in calls:
|
||
result = await self._execute_tool(call, metrics)
|
||
messages.append({
|
||
"role": "tool",
|
||
"tool_call_id": call.id,
|
||
"content": result,
|
||
})
|
||
except asyncio.CancelledError:
|
||
log.info("本轮被取消(打断)")
|
||
raise
|
||
except Exception as e: # noqa: BLE001
|
||
log.error("LLM 流失败:%s", e)
|
||
yield Event(kind=EventKind.ERROR, error=f"{type(e).__name__}: {e}")
|
||
metrics.llm_backend = getattr(self.llm, "last_backend", self.llm.name)
|
||
|
||
async def _execute_tool(self, call: ToolCall, metrics: TurnMetrics) -> str:
|
||
"""执行工具。**实现已抽到 `app/agent/tools.py`,两条路径共用。**
|
||
|
||
legacy 与 Flows 必须用同一套 schema 和执行器,否则容易两处行为漂移
|
||
(交接文档 Phase 2 的完成标准就是"工具层可被两条路径复用")。
|
||
这里只负责把引擎的状态转成 ToolContext。
|
||
"""
|
||
identity = self.identity.current()
|
||
ctx = ToolContext(
|
||
owner_user_id=identity.user_id, # ← 服务端注入,模型不得传
|
||
session_id=self._session_id,
|
||
turn_id=f"t_{uuid.uuid4().hex[:10]}",
|
||
identity_confirmed=identity.confirmed,
|
||
user_text=getattr(self, "_current_user_text", ""),
|
||
store=self.memory,
|
||
search=self.search,
|
||
memory_limit=self.cfg.context.max_memories,
|
||
memory_timeout_s=self.cfg.context.memory_timeout_s,
|
||
)
|
||
started = time.perf_counter()
|
||
outcome = await execute_tool(call.name, call.parsed_arguments(), ctx)
|
||
if call.name == "web_search":
|
||
metrics.searches += 1
|
||
metrics.search_ms += (time.perf_counter() - started) * 1000
|
||
return outcome.content
|
||
|
||
async def _speak(self, text: str, metrics: TurnMetrics) -> AsyncIterator[Event]:
|
||
"""合成一段并产出可播放音频。"""
|
||
text = text_for_speech(text)
|
||
if not text:
|
||
return
|
||
t0 = time.perf_counter()
|
||
try:
|
||
# TTS 是 CPU 密集的同步调用,放线程里跑,别阻塞事件循环
|
||
audio = await asyncio.to_thread(self.tts.synthesize, text)
|
||
except Exception as e: # noqa: BLE001
|
||
log.error("TTS 失败:%s", e)
|
||
yield Event(kind=EventKind.ERROR, error=f"TTS 失败:{e}")
|
||
return
|
||
if audio is None or len(audio) == 0:
|
||
return
|
||
if not metrics.first_audio_ms:
|
||
metrics.first_audio_ms = (time.perf_counter() - t0) * 1000
|
||
yield Event(kind=EventKind.SPEAK, text=text, audio=audio,
|
||
sample_rate=self.tts.sample_rate)
|
||
|
||
def _spawn_memory_write(self, *, user_text: str, reply: str,
|
||
user_id: str, session_id: str, turn_id: str) -> None:
|
||
"""起一个后台任务写记忆。
|
||
|
||
**身份是参数传进来的,不在这里读 self.identity.current()。**
|
||
方案 §8 明确要求:"异步任务绑定创建时的 user_id/session_id,
|
||
避免迟到结果写入新用户。" 如果这里去读"当前身份",
|
||
用户在这几百毫秒内切了身份,这条记忆就会写到别人名下。
|
||
"""
|
||
async def _write():
|
||
try:
|
||
for mem in extract_memories(user_text, reply):
|
||
mem.owner_user_id = user_id # 绑定创建时的身份
|
||
mem.source_session_id = session_id
|
||
mem.source_turn_id = turn_id
|
||
mem.confirm_status = ConfirmStatus(
|
||
self.cfg.memory.auto_extract_confirm_status)
|
||
await self.memory.remember(mem)
|
||
except Exception as e: # noqa: BLE001
|
||
log.warning("异步写记忆失败(不影响对话):%s", e)
|
||
|
||
task = asyncio.create_task(_write())
|
||
# 持有引用,避免被 GC 提前回收
|
||
if not hasattr(self, "_bg_tasks"):
|
||
self._bg_tasks: set[asyncio.Task] = set()
|
||
self._bg_tasks.add(task)
|
||
task.add_done_callback(self._bg_tasks.discard)
|
||
|
||
|
||
# ---------------------------------------------------------------- 记忆抽取
|
||
|
||
#: 显式"记住"的触发词。首版用规则,不额外调一次 LLM——
|
||
#: 那会多一次往返,而语音场景对延迟很敏感。
|
||
_REMEMBER_PATTERNS = (
|
||
("记住", ""), ("记一下", ""), ("别忘了", ""), ("帮我记", ""),
|
||
("我喜欢", "偏好"), ("我不喜欢", "偏好"), ("我习惯", "偏好"),
|
||
)
|
||
|
||
|
||
def extract_memories(user_text: str, reply: str) -> list[Memory]:
|
||
"""从一轮对话里抽取候选记忆。
|
||
|
||
首版只认显式表达。**抽取结果默认是 candidate,不参与检索**——
|
||
方案 §8:"模型推测不当作确认事实"。要变成 confirmed 需要用户确认
|
||
或走工具调用。
|
||
"""
|
||
out: list[Memory] = []
|
||
text = (user_text or "").strip()
|
||
# 姓名由同步、已确认的档案路径处理;不要再产生一条 candidate 副本。
|
||
if extract_display_name(text):
|
||
return out
|
||
for trigger, kind_hint in _REMEMBER_PATTERNS:
|
||
if trigger in text:
|
||
# 取触发词之后的正文
|
||
body = text.split(trigger, 1)[1].strip(" ::,,。.!!??")
|
||
if len(body) < 2:
|
||
continue
|
||
out.append(Memory(
|
||
kind=MemoryKind.PREFERENCE if kind_hint else MemoryKind.FACT,
|
||
key=kind_hint or "",
|
||
value=body[:200],
|
||
))
|
||
break
|
||
return out
|
||
|
||
|
||
def extract_display_name(user_text: str) -> str | None:
|
||
"""从用户明确的自我介绍中提取称呼;不使用模型猜测。"""
|
||
text = " ".join((user_text or "").strip().split())
|
||
if not text:
|
||
return None
|
||
for pattern in _DISPLAY_NAME_PATTERNS:
|
||
match = pattern.search(text)
|
||
if not match:
|
||
continue
|
||
name = " ".join(match.group(1).strip(" ‘'\"”").split())
|
||
if not name or len(name) > 24 or name in _NOT_NAMES:
|
||
continue
|
||
# “知道我是谁吗”“我是林吗”不是自我介绍。语音识别可能不带问号,
|
||
# 所以不能只依赖标点,需同时过滤疑问词和句末疑问助词。
|
||
if any(word in name for word in _NAME_QUESTION_WORDS):
|
||
continue
|
||
if name.endswith(_NAME_QUESTION_PARTICLES):
|
||
continue
|
||
if name.startswith(("你", "他", "她", "它")) and len(name) > 1:
|
||
continue
|
||
return name
|
||
return None
|
||
|
||
|
||
__all__ = [
|
||
"ConversationEngine", "Event", "EventKind", "TurnMetrics",
|
||
"extract_memories", "extract_display_name",
|
||
]
|