Files
AskaEth 066319f9c6 fix(memory): 复审 r2 返工 —— 真实「记住 X」路径钉 long,断言改为能打红
复审 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/ 绿。
2026-10-26 13:36:15 +08:00

104 lines
3.8 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"
"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
}