Files
Cyrene_v2/internal/services/gateway/admin_memory_tidy_test.go
T
AskaEth d6ed9cfa2b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 记忆整理「试算(dry-run)」入口 + 面板卡(契约 v0.75)
主人要「现在就想看到在 736 条上它会建议合并哪些」,而不是等 23:00 的低峰首跑。
- 后端:Tidier.DryRun + TidyService.DryRun,**复用同一条扫描→召回→判定→构造候选路径**(抽出 tidyRoundPolicy,生产轮与试算轮只差「候选出口」与「召回降级策略」两处;禁止第二套实现)
- 🔴 零副作用:不写 memory_tidy_candidates / memory_tidy_runs,绝不改 memory_items 任何列;机械证明 = 跑前跑后 cyrene.db 与 -wal 的 SHA256 一致(测试断言「表行数不变」)
- 失败码诚实:500 tidy_unavailable / 409 tidy_disabled / 500 tidy_recall_unavailable(**embedding 不可用不降级成 0 条**)/ 503 tidy_timeout / 500 tidy_dry_run_failed;**空数组唯一含义 = 算过了没建议**
- 端点 POST /api/v1/admin/memories/tidy/dry-run(writeAuth);面板「记忆」页加试算卡(点了才跑、无轮询、三态分开、明写「仅试算,不会改动任何记忆」)
- 真机 e2e(741 条活库副本):76 ms / 15 分区 / 53 候选 / 41 skipped / 1 vetoed(low_confidence);样例 merge reason=fact_subset score=1.0000、link reason=similar_but_not_contained score=0.9248
- 变异:dry-run 顺手写库 ⇒ 两条测试红(行数 0→2);自造阈值 ⇒ 红;失败回 200 空数组 ⇒ 红
- 如实登记:Compared=688 被 400/分区护栏截断、候选上限 20/分区被打满 ⇒ 护栏值需在 741 条量级重定(非 bug)
2026-10-05 22:06:54 +08:00

237 lines
9.4 KiB
Go
Raw 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 gateway
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件锁定记忆整理「试算」端点(契约 v0.75;切片 MT1)的四条判据:
// ① 投影正确:候选的 action/source_ids/reason/分数/规则版本/证据都如实上线,
// 且 `candidates` 恒为数组、`dryRun` 恒为 true;
// ② RBAC 沿用既有 admin 门(viewer 403 / owner|admin 200 / 匿名 401);
// ③ **诚实失败**:未接线 ⇒ 500 tidy_unavailable、配置关闭 ⇒ 409 tidy_disabled、
// 语义召回不可用 ⇒ 500 tidy_recall_unavailable —— 且**绝不**回空候选数组
// 假装「没有建议」,也不把原始内部错误正文抖给客户端;
// ④ 空结果与失败可区分:真的算完且没有建议 ⇒ 200 + `candidates:[]`。
// fakeTidyDryRunner 是端点测试用的试算替身:按注入的结果/错误作答,并记录调用
// 次数(用来钉住「关闭时连试算都不跑」)。
type fakeTidyDryRunner struct {
result memory.TidyDryRun
err error
calls int
}
func (f *fakeTidyDryRunner) DryRun(context.Context) (memory.TidyDryRun, error) {
f.calls++
return f.result, f.err
}
// fixtureTidyDryRun 是一条两候选/含否决分布/含截断标签的完整试算结果。
func fixtureTidyDryRun() memory.TidyDryRun {
return memory.TidyDryRun{
Stats: memory.TidyStats{
Scanned: 736, Partitions: 2, Compared: 400, Pairs: 5, Vetoed: 2,
VetoReasons: map[string]int{"scope": 1, "temporal": 1},
Truncated: []string{"cyrene/persona/(compare)"},
},
Candidates: []memory.TidyCandidate{{
Action: memory.TidyMerge,
SourceIDs: []string{"mem-a"},
TargetID: "mem-b",
PersonaID: "cyrene",
Scope: memory.ScopePersona,
Kind: memory.KindRelationship,
ProposedContent: "主人常在深夜与助手聊天,有熬夜习惯",
MergedContent: "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚",
Reason: "containment_a_subset_b",
Evidence: memory.TidyEvidence{
Similarity: 0.95, RecallMode: "embedding", Containment: "a_subset_b",
ContentA: "主人常在深夜与助手聊天,有熬夜习惯",
ContentB: "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚",
},
RuleVersion: memory.TidyRuleVersion, ScoreMin: 0.95, ScoreMax: 0.95,
}},
RecallMode: "embedding",
DurationMS: 42,
}
}
// tidyDryRunEnvelope 是端点的解码壳(数据 + 错误两态)。
type tidyDryRunEnvelope struct {
Data AdminTidyDryRunResponse `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// postTidyDryRun 调一次试算端点并解码。
func postTidyDryRun(t *testing.T, deps Deps, role auth.Role) (int, []byte, tidyDryRunEnvelope) {
t.Helper()
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
token := ""
if role != "" {
token = signAny(t, jwt, "acct", role)
}
resp, raw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/dry-run", nil, token)
var out tidyDryRunEnvelope
if len(raw) > 0 {
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
}
return resp.StatusCode, raw, out
}
// TestAdminMemoryTidyDryRun_ProjectsCandidates 是判据 ①:候选的关键字段逐一上线,
// 且 `dryRun`/`recallMode`/计数/截断标签都如实投影。
func TestAdminMemoryTidyDryRun_ProjectsCandidates(t *testing.T) {
t.Parallel()
status, _, env := postTidyDryRun(t, Deps{MemoryTidy: &fakeTidyDryRunner{result: fixtureTidyDryRun()}}, auth.RoleOwner)
if status != http.StatusOK {
t.Fatalf("status = %d, body = %s", status, env.Error.Message)
}
got := env.Data
if !got.DryRun {
t.Fatal("dryRun 必须恒为 true —— 它就是「这份响应没有改任何记忆」的结构化判据")
}
if got.RecallMode != "embedding" || got.DurationMs != 42 || got.RuleVersion != memory.TidyRuleVersion {
t.Fatalf("元信息投影错误:%+v", got)
}
if got.Scanned != 736 || got.Compared != 400 || got.Pairs != 5 || got.Vetoed != 2 || got.Partitions != 2 {
t.Fatalf("观测口径投影错误:%+v", got)
}
if got.VetoReasons["scope"] != 1 || got.VetoReasons["temporal"] != 1 {
t.Fatalf("否决分布投影错误:%+v", got.VetoReasons)
}
if len(got.Truncated) != 1 || got.Truncated[0] != "cyrene/persona/(compare)" {
t.Fatalf("截断标签投影错误:%+v", got.Truncated)
}
if len(got.Candidates) != 1 {
t.Fatalf("候选数 = %d, want 1", len(got.Candidates))
}
c := got.Candidates[0]
if c.Action != "merge" || c.Reason != "containment_a_subset_b" || c.TargetID != "mem-b" {
t.Fatalf("候选动作/理由/存活条投影错误:%+v", c)
}
if len(c.SourceIDs) != 1 || c.SourceIDs[0] != "mem-a" {
t.Fatalf("source_ids 投影错误:%+v", c.SourceIDs)
}
if c.ScoreMin != 0.95 || c.ScoreMax != 0.95 || c.RuleVersion != memory.TidyRuleVersion {
t.Fatalf("分数区间/规则版本投影错误:%+v", c)
}
if c.MergedContent == "" || c.ProposedContent == "" || c.Evidence.ContentA == "" {
t.Fatalf("diff 预览(原文/合并内容/证据)缺失:%+v", c)
}
if c.Scope != string(memory.ScopePersona) || c.PersonaID != "cyrene" {
t.Fatalf("分区键投影错误:%+v", c)
}
}
// TestAdminMemoryTidyDryRun_RequiresOwnerOrAdmin 是判据 ②:RBAC 沿用既有 admin
// 门(POST ⇒ writeAuth),且**不**给 viewer。
func TestAdminMemoryTidyDryRun_RequiresOwnerOrAdmin(t *testing.T) {
t.Parallel()
deps := Deps{MemoryTidy: &fakeTidyDryRunner{result: fixtureTidyDryRun()}}
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin} {
status, _, _ := postTidyDryRun(t, deps, role)
if status != http.StatusOK {
t.Fatalf("role %s = %d, want 200", role, status)
}
}
if status, _, _ := postTidyDryRun(t, deps, auth.RoleViewer); status != http.StatusForbidden {
t.Fatalf("viewer = %d, want 403", status)
}
if status, _, _ := postTidyDryRun(t, deps, ""); status != http.StatusUnauthorized {
t.Fatalf("匿名 = %d, want 401", status)
}
}
// TestAdminMemoryTidyDryRun_UnwiredIsHonest 是判据 ③(未接线):真 nil 依赖 ⇒
// 500 tidy_unavailable,**不是** 200 + 空候选。
func TestAdminMemoryTidyDryRun_UnwiredIsHonest(t *testing.T) {
t.Parallel()
status, raw, env := postTidyDryRun(t, Deps{}, auth.RoleOwner)
if status != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", status)
}
if env.Error.Code != "tidy_unavailable" {
t.Fatalf("code = %q, want tidy_unavailable", env.Error.Code)
}
if strings.Contains(string(raw), `"candidates"`) {
t.Fatalf("未接线时不得回候选数组(那是把「没算出来」谎报成「没有建议」):%s", raw)
}
}
// TestAdminMemoryTidyDryRun_DisabledIsHonest 是判据 ③(配置关闭):409 tidy_disabled,
// 且**连试算都不跑**(关闭是显式选择,端点不绕过它)。
func TestAdminMemoryTidyDryRun_DisabledIsHonest(t *testing.T) {
t.Parallel()
fake := &fakeTidyDryRunner{err: memory.ErrTidyDisabled}
status, raw, env := postTidyDryRun(t, Deps{MemoryTidy: fake}, auth.RoleOwner)
if status != http.StatusConflict {
t.Fatalf("status = %d, want 409", status)
}
if env.Error.Code != "tidy_disabled" {
t.Fatalf("code = %q, want tidy_disabled", env.Error.Code)
}
if fake.calls != 1 {
t.Fatalf("DryRun 调用次数 = %d, want 1(替身自己返回关闭态)", fake.calls)
}
if strings.Contains(string(raw), `"candidates"`) {
t.Fatalf("关闭时不得回候选数组:%s", raw)
}
}
// TestAdminMemoryTidyDryRun_RecallUnavailableIsHonest 是判据 ③(语义召回不可用):
// 500 tidy_recall_unavailable,且原始内部错误(含内网地址)**不抖给客户端**。
func TestAdminMemoryTidyDryRun_RecallUnavailableIsHonest(t *testing.T) {
t.Parallel()
sentinel := "dial tcp 10.9.8.7:11434: internal-secret-endpoint"
fake := &fakeTidyDryRunner{err: errors.Join(memory.ErrTidyRecallUnavailable, errors.New(sentinel))}
status, raw, env := postTidyDryRun(t, Deps{MemoryTidy: fake}, auth.RoleOwner)
if status != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", status)
}
if env.Error.Code != "tidy_recall_unavailable" {
t.Fatalf("code = %q, want tidy_recall_unavailable", env.Error.Code)
}
if strings.Contains(string(raw), "10.9.8.7") || strings.Contains(string(raw), "internal-secret-endpoint") {
t.Fatalf("内部错误细节泄漏到响应:%s", raw)
}
if strings.Contains(string(raw), `"candidates"`) {
t.Fatalf("召回不可用时不得回空候选:%s", raw)
}
}
// TestAdminMemoryTidyDryRun_EmptyIsNotAFailure 是判据 ④:真的算完且没有建议 ⇒
// 200 + 恒为非 null 的空数组(与失败态可区分)。
func TestAdminMemoryTidyDryRun_EmptyIsNotAFailure(t *testing.T) {
t.Parallel()
fake := &fakeTidyDryRunner{result: memory.TidyDryRun{
Stats: memory.TidyStats{VetoReasons: map[string]int{"temporal": 3}},
RecallMode: "lexical",
}}
status, raw, env := postTidyDryRun(t, Deps{MemoryTidy: fake}, auth.RoleOwner)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
if !strings.Contains(string(raw), `"candidates":[]`) {
t.Fatalf("空候选必须序列化成 [](不是 null、不是缺键):%s", raw)
}
if env.Data.Vetoed != 0 || env.Data.VetoReasons["temporal"] != 3 {
t.Fatalf("空结果仍须如实报否决分布:%+v", env.Data)
}
if fake.calls != 1 {
t.Fatalf("DryRun 调用次数 = %d, want 1", fake.calls)
}
}