Files
ai-companion/app/agent/memory_policy.py
T
ylhan 4fe2755fd6 Phase 2:抽出工具与策略层
按 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,无退化。
2026-09-16 13:25:46 +08:00

270 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""记忆写入/删除的策略层。
**为什么需要独立的一层**:交接文档明确要求
"不要仅依赖'请不要误记'的 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",
]