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 等价)。
92 lines
3.8 KiB
Go
92 lines
3.8 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
)
|
|
|
|
// 本文件钉住**提取器侧的敏感判据**(切片 M63、审计 §7 P0-2):
|
|
//
|
|
// 1. prompt 里真的给了判据与正反例(不是「交给模型自由发挥」);
|
|
// 2. **dry-run 私事不落 `scope=user & sensitive=false`** —— 即模型漏标时,产出侧
|
|
// 仍按白名单补上敏感位。这是审计 P0-2 的验收原话,也是最容易复发的一条:
|
|
// 模型的标注是概率性的,白名单是确定性的。
|
|
// 3. 模型显式标 true 的非白名单内容不被撤标。
|
|
//
|
|
// 删掉实现(prompt 里的敏感位、resolveSensitive 在 dedupeAndBuild 里的调用)会让
|
|
// 2 直接变红。
|
|
|
|
// extractOne 跑一次 Extract,返回第一条候选(没有候选即失败)。
|
|
func extractOne(t *testing.T, reply string) MemoryItem {
|
|
t.Helper()
|
|
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
|
|
return model.Response{Content: reply}, nil
|
|
}}
|
|
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
|
|
items, err := ex.Extract(context.Background(), []model.Message{
|
|
model.TextMessage(model.RoleUser, "我们聊聊住哪儿"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Extract: %v", err)
|
|
}
|
|
if len(items) == 0 {
|
|
t.Fatal("Extract 没有产出候选")
|
|
}
|
|
return items[0]
|
|
}
|
|
|
|
// TestExtractPrompt_带敏感位判据与正反例 钉住 prompt 本体:敏感位的键名、判据、
|
|
// 正反例(住址 = 私事 / 口味 = 习惯)必须都在。这三样少任何一样,模型就只能瞎猜。
|
|
func TestExtractPrompt_带敏感位判据与正反例(t *testing.T) {
|
|
t.Parallel()
|
|
for _, want := range []string{
|
|
`"sensitive"`,
|
|
"住址",
|
|
"私人称呼",
|
|
"「用户喜欢冰美式」→ sensitive=false",
|
|
"「主人住在花溪」→ sensitive=true",
|
|
"拿不准就标 true",
|
|
} {
|
|
if !strings.Contains(extractSystemPrompt, want) {
|
|
t.Fatalf("extractSystemPrompt 缺少 %q —— 提取器没有敏感判据,模型只能把私事当习惯", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestExtract_私事漏标仍落敏感 是审计 P0-2 的验收:模型(这里是脚本化的回复)
|
|
// 把「主人住在花溪」标成 scope=user 且**没有**给 sensitive 位 —— 产出侧必须补上
|
|
// 敏感位,绝不吐出 `user & sensitive=false` 的候选。
|
|
func TestExtract_私事漏标仍落敏感(t *testing.T) {
|
|
t.Parallel()
|
|
got := extractOne(t, `[{"content":"主人住在花溪(贵阳花溪区),不在上海工作","scope":"user","kind":"world_event","confidence":0.9}]`)
|
|
if got.Scope != ScopeUser {
|
|
t.Fatalf("scope = %q, want user(本用例考的正是 user 域里的私事)", got.Scope)
|
|
}
|
|
if !got.Sensitive {
|
|
t.Fatal("产出侧漏了敏感位:候选是 scope=user & sensitive=false,正是审计 P0-2 的复发点")
|
|
}
|
|
}
|
|
|
|
// TestExtract_HabitStaysShared 是方向性护栏:习惯类内容在模型标 false(或缺省)时
|
|
// 必须保持非敏感 —— 否则本片就把已冻结的「用户习惯可查」(design/03 §5)推翻了。
|
|
func TestExtract_HabitStaysShared(t *testing.T) {
|
|
t.Parallel()
|
|
got := extractOne(t, `[{"content":"用户喜欢冰美式","scope":"user","kind":"relationship","sensitive":false}]`)
|
|
if got.Sensitive {
|
|
t.Fatal("习惯类候选被标成敏感 —— 受限车道会平白丢掉合法习惯")
|
|
}
|
|
}
|
|
|
|
// TestExtract_模型显式敏感不被撤标 钉住方向性:模型标 true 的非白名单内容
|
|
// (例如家人情况,措辞不在白名单里)必须保持 true。
|
|
func TestExtract_模型显式敏感不被撤标(t *testing.T) {
|
|
t.Parallel()
|
|
got := extractOne(t, `[{"content":"主人有个弟弟在读高三","scope":"user","kind":"relationship","sensitive":true}]`)
|
|
if !got.Sensitive {
|
|
t.Fatal("模型显式标 sensitive=true 被改写成 false —— 产出侧不许撤标")
|
|
}
|
|
}
|