按 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,无退化。
270 lines
9.7 KiB
Python
270 lines
9.7 KiB
Python
"""记忆写入/删除的策略层。
|
||
|
||
**为什么需要独立的一层**:交接文档明确要求
|
||
"不要仅依赖'请不要误记'的 system prompt"。
|
||
|
||
模型可以提议动作,**这一层拥有最终决定权**。理由:
|
||
|
||
- system prompt 是软的,模型会漂移;策略是硬的,可测试、可回归
|
||
- "我今天吃了香菜"不能被记成"用户不吃香菜"这种稳定偏好——
|
||
这不是措辞问题,是**语义等级**问题,必须在代码里判定
|
||
- 身份未确认时不得碰任何私人记忆,这也不能交给模型自觉
|
||
|
||
输入:用户原话、工具请求参数、身份确认状态、已有同键记忆。
|
||
输出:allow_confirmed / allow_candidate / needs_confirmation / reject。
|
||
|
||
**本模块不依赖 LLM、不依赖网络、不依赖真实存储**,可以纯单元测试。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from dataclasses import dataclass, field
|
||
from enum import Enum
|
||
|
||
from ..memory.base import ConfirmStatus, Memory, MemoryKind
|
||
|
||
|
||
class MemoryDecision(str, Enum):
|
||
"""策略层的裁决。"""
|
||
ALLOW_CONFIRMED = "allow_confirmed" # 用户明确要求 → 写 confirmed
|
||
ALLOW_CANDIDATE = "allow_candidate" # 只是观察/推断 → 写 candidate
|
||
NEEDS_CONFIRMATION = "needs_confirmation" # 目标不明确 → 转入确认节点
|
||
REJECT = "reject" # 不允许(越权、身份未确认等)
|
||
|
||
|
||
@dataclass
|
||
class PolicyResult:
|
||
decision: MemoryDecision
|
||
reason: str
|
||
#: 规范化后的写入内容。策略层可以改写模型给的 kind/key/value。
|
||
kind: MemoryKind = MemoryKind.FACT
|
||
key: str = ""
|
||
value: str = ""
|
||
#: NEEDS_CONFIRMATION 时,候选目标(供确认节点展示)
|
||
candidates: list[Memory] = field(default_factory=list)
|
||
#: REJECT/NEEDS_CONFIRMATION 时给模型看的话(不冒充已执行)
|
||
message: str = ""
|
||
|
||
@property
|
||
def confirm_status(self) -> ConfirmStatus:
|
||
return (ConfirmStatus.CONFIRMED
|
||
if self.decision is MemoryDecision.ALLOW_CONFIRMED
|
||
else ConfirmStatus.CANDIDATE)
|
||
|
||
|
||
# ---------------------------------------------------------------- 显式意图
|
||
|
||
#: 用户明确要求"记住"的说法。命中才允许写 confirmed。
|
||
#: 刻意保守:宁可多判成 candidate(不参与召回),也不要误记成稳定事实。
|
||
EXPLICIT_REMEMBER_PATTERNS = (
|
||
"记住", "记一下", "记下", "帮我记", "帮我记住",
|
||
"别忘了", "别忘记", "不要忘",
|
||
"以后都", "以后要", "以后别", "今后都",
|
||
"我的偏好是", "我习惯", "我通常", "我总是",
|
||
)
|
||
|
||
#: 明确表示"这是这一次的事"的说法。命中则**降级**为 candidate,
|
||
#: 即使句中同时出现"记住"之类的词。
|
||
#: 例:"记住我今天吃了香菜"——语义是"记下今天这件事",不是稳定偏好。
|
||
#: 注意不要收太宽的词。比如"先"看着像临时,但"我习惯先喝汤"是稳定偏好,
|
||
#: 收进来会造成漏判(把偏好降级成候选)。宁可少收。
|
||
EPISODIC_MARKERS = (
|
||
"今天", "昨天", "刚才", "刚刚", "这次", "这一回", "暂时", "目前",
|
||
"这会儿", "眼下",
|
||
)
|
||
|
||
#: 主语是"今天/这次"这类时间词时,更可能是事件而非偏好
|
||
_TIME_LEAD_RE = re.compile(r"^(今天|昨天|刚才|刚刚|这次|这会|这回|现在|目前)")
|
||
|
||
|
||
def has_explicit_remember_intent(user_text: str) -> bool:
|
||
"""用户是否明确要求记住(而不是模型自己觉得值得记)。
|
||
|
||
注意:本函数调用下面定义的 `looks_episodic`(Python 晚绑定,没问题)。
|
||
"""
|
||
t = (user_text or "").strip()
|
||
if not t:
|
||
return False
|
||
if not any(p in t for p in EXPLICIT_REMEMBER_PATTERNS):
|
||
return False
|
||
# 明确是"这一次"的事 → 不算稳定偏好意图。
|
||
# **必须复用 looks_episodic**:早先这里只检查句首时间词,
|
||
# 而"记住我今天吃了香菜"的时间词在中间,导致漏判(测试逮到的)。
|
||
if looks_episodic(t):
|
||
return False
|
||
return True
|
||
|
||
|
||
def looks_episodic(user_text: str) -> bool:
|
||
"""是否是"这一次发生的事"而非稳定事实。"""
|
||
t = (user_text or "").strip()
|
||
if not t:
|
||
return False
|
||
if _TIME_LEAD_RE.search(t):
|
||
return True
|
||
return any(m in t for m in EPISODIC_MARKERS)
|
||
|
||
|
||
# ---------------------------------------------------------------- 越权检查
|
||
|
||
#: 模型**不得**传入的字段——身份与授权范围必须由服务端注入。
|
||
FORBIDDEN_ARG_KEYS = (
|
||
"owner_user_id", "user_id", "owner", "uid",
|
||
"scope", "confirm_status", "trust_level",
|
||
"session_id", "turn_id",
|
||
)
|
||
|
||
|
||
def find_forbidden_args(args: dict) -> list[str]:
|
||
"""返回模型试图传入的越权字段名。"""
|
||
if not isinstance(args, dict):
|
||
return []
|
||
lower = {str(k).lower() for k in args.keys()}
|
||
return [k for k in FORBIDDEN_ARG_KEYS if k.lower() in lower]
|
||
|
||
|
||
# ---------------------------------------------------------------- 写入裁决
|
||
|
||
def decide_remember(
|
||
*,
|
||
user_text: str,
|
||
args: dict,
|
||
identity_confirmed: bool,
|
||
model_claims_explicit: bool = False,
|
||
) -> PolicyResult:
|
||
"""裁决一次 memory_remember 请求。
|
||
|
||
参数:
|
||
user_text: 本轮用户原话(策略的主要依据)
|
||
args: 模型给的工具参数(含 kind/key/value)
|
||
identity_confirmed: 当前身份是否已确认
|
||
model_claims_explicit: 模型自称"用户明确要求记住"。
|
||
仅作参考,**不采信**——必须由 user_text 判定。
|
||
"""
|
||
# 1. 身份未确认:一律拒绝,不碰任何私人记忆
|
||
if not identity_confirmed:
|
||
return PolicyResult(
|
||
decision=MemoryDecision.REJECT,
|
||
reason="身份未确认",
|
||
message="现在还不能记录私人信息。请先确认身份。",
|
||
)
|
||
|
||
# 2. 越权字段:拒绝(不静默忽略,避免模型以为生效了)
|
||
forbidden = find_forbidden_args(args)
|
||
if forbidden:
|
||
return PolicyResult(
|
||
decision=MemoryDecision.REJECT,
|
||
reason=f"模型传入越权字段 {forbidden}",
|
||
message=f"参数不合法:不接受 {', '.join(forbidden)}。",
|
||
)
|
||
|
||
value = str(args.get("value") or "").strip()
|
||
if not value:
|
||
return PolicyResult(
|
||
decision=MemoryDecision.REJECT,
|
||
reason="value 为空",
|
||
message="没有可记录的内容。",
|
||
)
|
||
|
||
kind = _coerce_kind(args.get("kind"))
|
||
key = str(args.get("key") or "").strip()
|
||
|
||
# 3. 判定语义等级。
|
||
# 注意:以 user_text 为准,不采信 model_claims_explicit。
|
||
explicit = has_explicit_remember_intent(user_text)
|
||
episodic = looks_episodic(user_text)
|
||
|
||
if explicit and not episodic:
|
||
return PolicyResult(
|
||
decision=MemoryDecision.ALLOW_CONFIRMED,
|
||
reason="用户明确要求记住",
|
||
kind=kind, key=key, value=value,
|
||
)
|
||
|
||
# 4. 其余一律 candidate:模型推断、总结、或"这一次"的事件
|
||
reason = "属于一次性事件" if episodic else "非用户明确要求,仅作观察"
|
||
return PolicyResult(
|
||
decision=MemoryDecision.ALLOW_CANDIDATE,
|
||
reason=reason,
|
||
kind=kind, key=key, value=value,
|
||
)
|
||
|
||
|
||
def _coerce_kind(raw) -> MemoryKind:
|
||
"""把模型给的 kind 收敛到枚举;不认识的一律当 FACT。"""
|
||
try:
|
||
return MemoryKind(str(raw))
|
||
except (ValueError, TypeError):
|
||
return MemoryKind.FACT
|
||
|
||
|
||
# ---------------------------------------------------------------- 删除裁决
|
||
|
||
#: 模糊删除的说法——命中则必须先确认,不得猜目标
|
||
AMBIGUOUS_FORGET_MARKERS = (
|
||
"所有", "全部", "都删", "清空", "关于我的", "我的资料", "我的信息",
|
||
"这些", "那些", "乱七八糟",
|
||
)
|
||
|
||
|
||
def decide_forget(
|
||
*,
|
||
user_text: str,
|
||
matches: list[Memory],
|
||
identity_confirmed: bool,
|
||
) -> PolicyResult:
|
||
"""裁决一次 memory_forget 请求。
|
||
|
||
规则(交接文档 §八):
|
||
- 精确匹配一条 → 返回待删除摘要,可执行
|
||
- 多条或模糊 → 进入确认节点,**不得猜测删除目标**
|
||
- 删除必须同时匹配 owner(由存储层保证,这里只判数量与措辞)
|
||
"""
|
||
if not identity_confirmed:
|
||
return PolicyResult(
|
||
decision=MemoryDecision.REJECT,
|
||
reason="身份未确认",
|
||
message="现在不能操作私人记忆。",
|
||
)
|
||
|
||
if not matches:
|
||
return PolicyResult(
|
||
decision=MemoryDecision.REJECT,
|
||
reason="没有匹配的记忆",
|
||
message="没有找到相关的记忆。",
|
||
)
|
||
|
||
t = (user_text or "").strip()
|
||
ambiguous_wording = any(m in t for m in AMBIGUOUS_FORGET_MARKERS)
|
||
|
||
if len(matches) == 1 and not ambiguous_wording:
|
||
m = matches[0]
|
||
return PolicyResult(
|
||
decision=MemoryDecision.ALLOW_CONFIRMED,
|
||
reason="精确匹配一条",
|
||
kind=m.kind, key=m.key, value=m.value,
|
||
candidates=[m],
|
||
message=f"将删除「{m.text}」。",
|
||
)
|
||
|
||
# 多条 / 模糊 → 必须确认
|
||
return PolicyResult(
|
||
decision=MemoryDecision.NEEDS_CONFIRMATION,
|
||
reason=f"匹配到 {len(matches)} 条" + (",且措辞模糊" if ambiguous_wording else ""),
|
||
candidates=list(matches),
|
||
message=(
|
||
f"找到 {len(matches)} 条相关记忆,需要你确认删哪一条:\n"
|
||
+ "\n".join(f" {i+1}. {m.text}" for i, m in enumerate(matches[:5]))
|
||
),
|
||
)
|
||
|
||
|
||
__all__ = [
|
||
"MemoryDecision", "PolicyResult",
|
||
"decide_remember", "decide_forget",
|
||
"has_explicit_remember_intent", "looks_episodic",
|
||
"find_forbidden_args",
|
||
"EXPLICIT_REMEMBER_PATTERNS", "EPISODIC_MARKERS", "FORBIDDEN_ARG_KEYS",
|
||
]
|