"""提示词构造。 关键约束(实测,P0-本地LLM验证报告.md §四): 本机 vLLM **只接受一条 system 消息**,多条返回 400 "System message must be at the beginning." 所以记忆不能作为"第二条 system"注入,必须**并进同一条里、每轮重建**。 这其实比追加式更干净,也正好满足方案 §8 的要求: "切换身份清理上一人的私有检索上下文"、"注入的记忆每轮替换,不是追加"—— 每轮从零拼出来的 system 天然不会有上一人的残留。 """ from __future__ import annotations from typing import Sequence from .identity.base import IdentityDecision from .memory.base import Memory #: 语音助手的回复风格。方案 §5:"默认简短自然回复"。 #: 这几条不是"风格偏好",是**功能性的**——放出去的文本会直接进 TTS, #: 带 Markdown 的话会被逐字念出来("井号井号 第一步")。 PERSONA_RULES = """\ 你是一个桌面陪伴助手,通过语音和用户交流。用户听到的是合成语音,不是文字。 必须遵守: 1. 回复简短自然,适合朗读,默认一到两句话。不要长篇解释。 2. 绝对不要使用 Markdown、星号、井号、列表、表格、表情符号或括号注释。 3. 数字、日期、金额按中文口语写法,例如"一千二百元""下午三点半"。 4. 不确定的事直说不确定,不要编造。没做过的事不要说已经做了。 5. 需要操作设备时调用工具。不要假装已经执行,也不要说"正在为您打开"。 6. 用户打断时立刻停止,不要坚持把上一句说完。""" def build_system_prompt( identity: IdentityDecision, memories: Sequence[Memory] = (), *, extra: str = "", ) -> str: """拼出本轮唯一的 system 消息。 每轮调用一次,不要缓存后复用——身份或记忆一变,缓存就会串。 """ parts = [PERSONA_RULES] if identity.confirmed: parts.append(f"\n当前用户:{identity.user_id}(已确认身份)。") else: # 方案 §6:未知身份只用访客上下文,不做个性化 parts.append("\n当前用户身份未确认,按访客对待。" "不要提及任何私人信息,也不要假定你知道对方是谁。") if memories: lines = ["\n已知的用户信息(供参考,不要逐条复述):"] for m in memories: lines.append(f"- {m.text}") parts.append("\n".join(lines)) if identity.suggestions and not identity.confirmed: # 有人脸候选但不自动切换。让助手可以自然地问一句, # 而不是自作主张切换档案(方案 §6:"看到熟人不等于确认他正在说话")。 names = "、".join( s.candidate_user_id for s in identity.suggestions[:2]) parts.append( f"\n注意:摄像头可能认出了 {names},但这不代表对方正在说话。" f"不要据此切换身份或提及该用户的私人信息。") if extra: parts.append("\n" + extra.strip()) return "\n".join(parts) def trim_history( history: Sequence[dict], *, max_turns: int, max_chars: int = 4000, ) -> list[dict]: """裁掉过旧的对话轮次。 方案 §8 要求"对话摘要限长、按需检索",不默认永久保存逐字稿。 这里做最简单的一档:保留最近 max_turns 轮,再按字符数兜底。 上下文越短,本地 LLM 的首 token 越短——这不是省资源,是直接买延迟。 """ msgs = list(history) # 一轮 = 一问一答,所以保留的消息数是轮数的两倍 keep = max_turns * 2 if len(msgs) > keep: msgs = msgs[-keep:] # 按字符数从旧到新裁,保证裁完仍然从 user 消息开始 while msgs and sum(len(m.get("content") or "") for m in msgs) > max_chars: msgs.pop(0) while msgs and msgs[0].get("role") != "user": msgs.pop(0) return msgs __all__ = ["build_system_prompt", "trim_history", "PERSONA_RULES"]