Files
AskaEth 44fbd29836 feat(memory): sensitive 标记 + 提取器敏感判据,私事退出受限车道召回
M63(M60 审计 P0-1/P0-2)。作用域 ≠ 敏感度:`scope='user'` 是共享域,里
面却装着住址(花溪 64 条 active)/私称(叶酱 63)/作息(早睡 38),于是受限车
道(群聊/陌生人)把共享域整锅注入上下文 —— 2026-10-29 群 531082168 的住址泄漏
即此路径。本片给 memory_items 加 `sensitive` 列,把「这条归谁」(scope)与
「这条能不能外说」(sensitive)拆成两根正交的轴。

- 迁移 0028:加列(INTEGER NOT NULL DEFAULT 0)+ **同迁移幂等回填**(精确短语
  白名单 花溪/叶酱/早睡,`AND sensitive=0`,不改 updated_at),down 只撤结构;
  memory 包内 `ensureSensitiveSchema` 同形前滚(任意 *sql.DB 自举)。
- 受限判据:新增 `MemoryItem.SharedVisible()` = 共享域 且 !sensitive,成为
  restricted 侧唯一起点;`loop.FilterSharedVisible` 与复用它的 knowledge 工具
  自动跟随。主人车道不过这道判据,照常读到全部记忆(sensitive 不是权限系统)。
- 提取器:prompt 追加 sensitive 位(住址/私人称呼/家人/健康/作息与行程 → true,
  口味/兴趣/项目约定 → false,含正反例);产出侧与落库侧共用
  `resolveSensitive`(模型的类目判断 ∨ 已实证短语白名单),只加标不撤标。
- 回填入口 `BackfillSensitive(ctx, db, dryRun)`:幂等、可 dry-run、返回候选池
  前后规模与逐短语计数(生产快照实测 3116 → 2948,标 725 行)。
- 读路径并到 15 列(scanItem + 7 处 SELECT);store.go 拆分 store_insert.go
  守住 500 行上限。

验收:go build/vet/test 全绿(memory + loop 单包复跑绿);删实现三处变异分别打红
(sensitiveColumn 恒 0 / SharedVisible 忽略 Sensitive / Extractor 跳过白名单);
生产库快照上跑 0028 后 Go 侧回填 marked=0(迁移 SQL 与 privateFacts 等价)。
2026-10-30 09:26:15 +08:00

171 lines
6.4 KiB
Go
Raw Permalink 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.
package memory
import (
"context"
"testing"
)
// 本文件钉住「敏感标记」这根轴(迁移 0028,切片 M63)在**数据层**的四条:
//
// 1. 白名单判据的方向性:已实证私事命中、习惯类不命中(审计 §2 的实测短语);
// 2. SharedVisible 是作用域与敏感度的合取(受限车道唯一判据);
// 3. 写入侧兜底:白名单命中的内容经 Store.Add 落库后 sensitive=1,且**只加不撤**;
// 4. 15 列读回路:sensitive 在四条列表读路径(Get / ListByPersona / ListScope /
// List / ListProject)上都读得回来 —— 少同步一处 SELECT,这里就红。
//
// 删掉实现(sensitive 列、resolveSensitive、scanItem 的第 15 列、SharedVisible 的
// 判据)会分别让 1/2/3/4 变红;这正是本文件存在的意义。
// TestIsPrivateContent_判据方向 钉住白名单的两个方向:主人的私事命中,习惯与
// 普通词不命中。反向用例(「住手」「喜欢睡懒觉的猫」)是**刻意**的:泛关键词
// (住 / 睡)会把它们一起标掉,那正是本片拒绝的误标方向(sensitive.go 理由 2)。
func TestIsPrivateContent_判据方向(t *testing.T) {
t.Parallel()
for _, content := range []string{
"用户不在上海上班,目前所在城市为花溪(贵阳花溪区一带)",
"主人希望被称呼为「叶酱」",
"主人有上班日程,工作日需要早起,助手提醒早睡",
} {
if !IsPrivateContent(content) {
t.Fatalf("IsPrivateContent(%q) = false, want true(已实证私事必须命中)", content)
}
}
for _, content := range []string{
"用户喜欢冰美式",
"住手!不要再改了",
"用户喜欢睡懒觉的猫,周末会看猫的视频",
"Cyrene 用 Go 编写,项目编号规则是 CY-日期-序号",
} {
if IsPrivateContent(content) {
t.Fatalf("IsPrivateContent(%q) = true, want false(习惯与普通词不许误标)", content)
}
}
}
// TestSharedVisible_作用域与敏感度合取 钉住受限车道唯一判据的矩阵:
// 只有「共享域 且 未标敏感」才可注入;persona 域无论标没标都不可注入
// (它本来就是主人私有的角色日常,design/03 §3/§5)。
func TestSharedVisible_作用域与敏感度合取(t *testing.T) {
t.Parallel()
cases := []struct {
name string
item MemoryItem
want bool
}{
{"user+非敏感", MemoryItem{Scope: ScopeUser}, true},
{"project+非敏感", MemoryItem{Scope: ScopeProject}, true},
{"user+敏感", MemoryItem{Scope: ScopeUser, Sensitive: true}, false},
{"project+敏感", MemoryItem{Scope: ScopeProject, Sensitive: true}, false},
{"persona+非敏感", MemoryItem{Scope: ScopePersona}, false},
{"persona+敏感", MemoryItem{Scope: ScopePersona, Sensitive: true}, false},
}
for _, c := range cases {
if got := c.item.SharedVisible(); got != c.want {
t.Fatalf("%s: SharedVisible() = %v, want %v", c.name, got, c.want)
}
}
}
// TestAdd_私事按白名单补标 钉住写入侧兜底:模型(或人工调用方)漏标时,白名单
// 命中的内容落库后必须 sensitive=1;而 habit 类内容必须保持 0(否则冻结的
// 「用户习惯可查」裁决会被这次改动悄悄推翻)。
func TestAdd_私事按白名单补标(t *testing.T) {
t.Parallel()
s := newTestStore(t)
private := baseItem()
private.Scope = ScopeUser
private.Content = "用户居住在花溪(贵阳花溪区),不在上海工作"
storedPrivate := addItem(t, s, private)
if !storedPrivate.Sensitive {
t.Fatal("白名单命中的条目落库后 Sensitive = false, want true(写入侧兜底没生效)")
}
habit := baseItem()
habit.Scope = ScopeUser
habit.Content = "用户喜欢冰美式"
storedHabit := addItem(t, s, habit)
if storedHabit.Sensitive {
t.Fatal("习惯类条目被误标成敏感 —— 受限车道会平白丢掉合法习惯")
}
// 只加不撤:调用方显式标 true 的习惯类内容必须保持 true(撤标是人工动作)。
explicit := baseItem()
explicit.Scope = ScopeUser
explicit.Content = "用户喜欢手冲咖啡"
explicit.Sensitive = true
if got := addItem(t, s, explicit); !got.Sensitive {
t.Fatal("显式 sensitive=true 被改写成 false —— 写入路径不许撤标")
}
}
// TestSensitive_十五条读路径回读 钉住「15 列读回路」:sensitive 必须在每一条
// 走 scanItem 的读路径上都读得回来。任一处 SELECT 少一列,这里 first 就会扫错列
// (报错或把别的列当成 sensitive),不会有静默通过的余地。
func TestSensitive_十五条读路径回读(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
personaPrivate := baseItem()
personaPrivate.Content = "主人只愿在私下被叫作「叶酱」"
stored := addItem(t, s, personaPrivate)
userPrivate := baseItem()
userPrivate.Scope = ScopeUser
userPrivate.Content = "主人现居贵阳花溪区,不在上海"
userStored := addItem(t, s, userPrivate)
projectPlain := baseItem()
projectPlain.Scope = ScopeProject
projectPlain.ProjectID = "proj-1"
projectPlain.Content = "Cyrene 用 Go 编写"
projectStored := addItem(t, s, projectPlain)
byID, err := s.Get(ctx, stored.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if !byID.Sensitive {
t.Fatal("Get 读回的 sensitive = false, want true(Get 的 SELECT 少一列?)")
}
byPersona, err := s.ListByPersona(ctx, personaPrivate.PersonaID)
if err != nil {
t.Fatalf("ListByPersona: %v", err)
}
if !containsSensitive(byPersona, stored.ID) {
t.Fatal("ListByPersona 读回的条目丢掉了 sensitive 位")
}
byScope, err := s.ListScope(ctx, ScopeUser, personaPrivate.PersonaID)
if err != nil {
t.Fatalf("ListScope: %v", err)
}
if !containsSensitive(byScope, userStored.ID) {
t.Fatal("ListScope 读回的条目丢掉了 sensitive 位")
}
byProject, err := s.ListProject(ctx, "proj-1")
if err != nil {
t.Fatalf("ListProject: %v", err)
}
if containsSensitive(byProject, projectStored.ID) {
t.Fatal("ListProject 把非敏感的条目读成了敏感")
}
all, err := s.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
if !containsSensitive(all, stored.ID) || containsSensitive(all, projectStored.ID) {
t.Fatal("List 读回的 sensitive 位与落库值不一致")
}
}
// containsSensitive 报告 items 中 id 那条是否为 sensitive(找不到即失败)。
func containsSensitive(items []MemoryItem, id string) bool {
for _, it := range items {
if it.ID == id {
return it.Sensitive
}
}
return false
}