基线:姓名档案修复、疑问句防误写、搜索与传输测试

这是进入 Pipecat Flows 改造前的回滚点。
提交时已核对本地与线上代码 md5 一致(engine.py 7e97bad8、
bot_server.py 1cf543ae),所以这个提交就是当前线上状态。

内容(此前一直在线上运行但未提交的改动):
- 姓名档案:用户自报"我叫林"同步写 confirmed 姓名,重连后恢复
- 疑问句防误写:"谁吗"之类的疑问句不再被覆盖成姓名
- 新增 test_profile_memory.py(姓名档案专项回归)
- 新增 test_profile_live.py(需要真实 LLM 的现场测试)
- 搜索、身份、记忆测试的相应补充

背景:下一步要按 P1-Pipecat Flows Agent化与记忆改造交接.md
渐进引入 Pipecat Flows,交接文档明确要求先保护基线、不得
reset/checkout/覆盖现有改动。此提交即该基线。
This commit is contained in:
ylhan
2026-09-16 12:48:18 +08:00
parent 719fc13e48
commit 20cfaa6ef7
16 changed files with 408 additions and 11 deletions
+105 -1
View File
@@ -58,6 +58,29 @@ _SOURCE_SECTION_RE = re.compile(
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" # 本轮的身份裁决结果
@@ -143,6 +166,18 @@ class ConversationEngine:
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)
@@ -180,6 +215,10 @@ class ConversationEngine:
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)
@@ -306,6 +345,43 @@ class ConversationEngine:
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。"""
@@ -476,6 +552,9 @@ def extract_memories(user_text: str, reply: str) -> list[Memory]:
"""
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:
# 取触发词之后的正文
@@ -491,6 +570,31 @@ def extract_memories(user_text: str, reply: str) -> list[Memory]:
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",
"ConversationEngine", "Event", "EventKind", "TurnMetrics",
"extract_memories", "extract_display_name",
]