复审 r2 = p2-2items 全在「RememberManual 钉 long」那半,两条指向同一件事:
**我以为修好的人工路径实际没修**。逐条:
P2-1 上一版断言是**空断言**:夹具正文「记住她喜欢喝咖啡」含「记住」⇒
extract_retention.go 的护栏① 直接放行 ⇒ 默认落点本来就是 long,因此删掉
store.go 的 pin 仍然绿。
→ extract_test.go 的 TestStore_RememberManual_正常 改为一对语料:
「我正在测通道」(N7,规则真会判 temp)+「记住她喜欢喝咖啡」(正文保留契约),
并同时断言档位、`ExpiresAt` 零值、以及**落库列** `(retention_tier, expires_at)`。
P2-2 pin 钉在零生产调用方的方法上:真实路径是 ManualExtractor.Extract(无可用
LLM 出口时的生产回退)→ persistMemories → AddScoped → Add,而 rememberDirective
**先剥掉「记住」前缀** ⇒ 护栏① 永远看不到它。改前实测:记住今天下雨了→temp/n6、
记住我正在测通道→temp/n7、记住现在几点了→temp/n4(均 +7 天)。
→ extract.go 的 ManualExtractor.Extract 在**产出侧**显式置
`RetentionTier: RetentionLong`(并在注释里写清「不能靠护栏①」的原因)。
store.go 的 RememberManual pin 保留(两条独立的契约点)。
→ 新增 TestManualExtractorItemsStayLongTerm:四条正文过 Extract →
复刻 persistMemories 的溯源覆盖 → AddScoped 落库 → 断言**落库列**
`(long, 空串)`。
**变异证据(删掉两处 pin 后实测)**:
--- FAIL: TestManualExtractorItemsStayLongTerm
extract_test.go:167: 「记住今天下雨了」全链落库 =
(tier="temp", expires_at="2026-11-02T05:34:20.844624200Z"), want (long, 空串)
--- FAIL: TestStore_RememberManual_正常
extract_test.go:99: RememberManual("我正在测通道") 的档位 = "temp", want "long"
恢复 pin 后两条均 PASS。
回归:go build ./... + go vet ./... + go test -count=1 ./internal/runtime/memory/...
./internal/db/... ./cmd/cyrene/ 全绿;go test -race ./internal/runtime/memory/ 绿。
104 lines
3.8 KiB
Go
104 lines
3.8 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
var (
|
||
// ErrNilAdapter 在构建或使用 LLMExtractor 却未提供
|
||
// 驱动提取的 model.Adapter 时返回。
|
||
ErrNilAdapter = errors.New("memory: nil model adapter")
|
||
)
|
||
|
||
// Extractor 从一次对话中推导候选记忆条目(design §1)。
|
||
//
|
||
// 有两个实现:
|
||
//
|
||
// - LLMExtractor:让 model.Adapter 对每轮对话去噪去重,
|
||
// 同步运行(Extract)或在后台 goroutine 上运行
|
||
// (ExtractAsync)。
|
||
// - ManualExtractor:确定性、规则式 —— 只记录用户
|
||
// 显式要求记住的内容(「记住 X」)。
|
||
type Extractor interface {
|
||
// Extract 返回对话中值得持久化的记忆条目。ctx
|
||
// 控制取消;ctx 被取消时返回错误。
|
||
Extract(ctx context.Context, conversation []model.Message) ([]MemoryItem, error)
|
||
}
|
||
|
||
// ManualExtractor 实现不经 LLM 的手动「记住 X」提取路径。
|
||
// 它扫描用户轮次中领起的记住指令,为每条产出一个记忆
|
||
// 条目,普通对话则不产出。它是 M4 LLM 提取器的
|
||
// 确定性对照实现。
|
||
type ManualExtractor struct {
|
||
personaID string
|
||
roleID string
|
||
}
|
||
|
||
// NewManualExtractor 构建 ManualExtractor,其提取出的条目以
|
||
// personaID/roleID 为作者。与 LLM 提取器一样,角色是
|
||
// 默认值而非权威:多角色调用方以空
|
||
// 角色构建它,并在持久化时用该轮的角色给条目盖章
|
||
// (design/22 §4.2),因此要构建与角色无关的提取器就传 ""。
|
||
func NewManualExtractor(personaID, roleID string) *ManualExtractor {
|
||
return &ManualExtractor{personaID: personaID, roleID: roleID}
|
||
}
|
||
|
||
// Extract 为每个要求代理记住点什么的用户
|
||
// 轮次返回一条记忆条目(kind 为 relationship、作用域为 persona)。领起的
|
||
// 「记住」/「请记住」指令会从落库内容中剥离;没有该指令的轮次跳过。
|
||
//
|
||
// 🔴 **档位显式置 long**(迁移 0026 / 主人 2026-10-26 对「人工动作默认 long」的裁定):
|
||
// 这是**人明确要求记住**的路径,留存规则不许把它降成临时层(否则「记住我现在在
|
||
// 测通道」会被判 temp,7 天后被静默删掉)。注意这里**不能靠护栏①**(
|
||
// extract_retention.go 的「显式记住一票否决」)兜底:`rememberDirective` 在上一步
|
||
// 已经把「记住」前缀剥掉了,落库正文里没有那两个词,护栏① 永远看不到它 ——
|
||
// 因此必须在**产出侧**把档位写死(retention.go 的 resolveRetention 第一条分支
|
||
// 就是「调用方显式档位不许被规则推翻」)。
|
||
func (e *ManualExtractor) Extract(ctx context.Context, conversation []model.Message) ([]MemoryItem, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
items := make([]MemoryItem, 0)
|
||
for _, msg := range conversation {
|
||
if msg.Role != model.RoleUser {
|
||
continue
|
||
}
|
||
text := strings.TrimSpace(messageText(msg))
|
||
content, ok := rememberDirective(text)
|
||
if !ok {
|
||
continue
|
||
}
|
||
items = append(items, MemoryItem{
|
||
PersonaID: e.personaID,
|
||
RoleID: e.roleID,
|
||
Scope: ScopePersona,
|
||
Kind: KindRelationship,
|
||
Content: content,
|
||
Confidence: 1.0,
|
||
DecayWeight: 1.0,
|
||
RetentionTier: RetentionLong,
|
||
})
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
// rememberDirective 在 text 是记住指令时剥离「记住」/「请记住」前缀。
|
||
// 它返回被记住的内容与 true;text 不是显式记住请求时
|
||
// ok=false。
|
||
func rememberDirective(text string) (string, bool) {
|
||
for _, prefix := range []string{"请记住", "记住"} {
|
||
if strings.HasPrefix(text, prefix) {
|
||
content := strings.TrimSpace(strings.TrimPrefix(text, prefix))
|
||
if content == "" {
|
||
return "", false
|
||
}
|
||
return content, true
|
||
}
|
||
}
|
||
return "", false
|
||
}
|