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 等价)。
171 lines
6.4 KiB
Go
171 lines
6.4 KiB
Go
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
|
||
}
|