Files
Cyrene_v2/internal/services/gateway/admin_memory_tidy_test.go
T
AskaEth 6e28b38b08
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 护栏抬高+时间预算闸+试算加性异步 与 D3 入库前审查(耦合两片一起落)
【MT-A:护栏 + 诚实截断 + 试算异步】
- maxTidyCompareItems 400(写死)→2000 并开放为 MEMORY_TIDY_COMPARE_LIMIT;
  MEMORY_TIDY_CANDIDATE_LIMIT 默认 20→200(语义不变,仍每分区)
- 依据是首跑实测:459 条的分区被 400 截断 ⇒ compared(705) < scanned(784),
  候选数(77)由上限而不是规则决定;三处上限的 WARN 都补齐了总数/上限/分区名
- 新增「上限 + 时间预算」双闸(实测 2000 条/分区 ≈234s > 3 分钟墙钟):
  整分区跳过 / 两两比对中途收手,一律如实记 (budget) + WARN
- ?async=1 ⇒ 202 {runId,status};GET …/{runId} 轮询(writeAuth 同档);
  done 的 result 与同步 data 逐字节同形;并发=复用既有 runId、TTL 30min 自完成起算、
  重启即如实 404 tidy_run_not_found;不带 async 时同步路径逐字不变

【MT-B:D3 入库前审查(design/23 §15.1)】
- 新记忆经 Store.Add(仓库里唯一 INSERT INTO memory_items)落库后,由独立记忆管理
  会话(sess-memorytidy)在有界后台队列异步判定 skip/merge/modify/create
- 软合并回写(status + merged_into/merged_from/merge_count,可 revert、绝不改 content)
- 隔离来源(群聊/陌生人)**概不审查**、召回/超时失败**零改写**(fail-safe 结构性成立)
- 写入侧仅 +62.7µs/条(非阻塞入队);默认 ENABLED=true(主人裁定:入库前先审查)

【耦合说明(重要)】两片同包且互相依赖:MT-B 的 preinsert_recall.go 用 MT-A 的
collectTidyPairs 新签名 + tidyBudget ⇒ **各自单独都 build 不过**,故一并落地。
MT-A 为让整包编译,最小幅度适配了 MT-B 的 preinsert_recall.go / preinsert_test.go
(零值预算=行为不变;符号重名改名)。

【我的验收】隔离 worktree(37 文件)build/vet/linux/darwin 全过,切片包全绿;
我亲手打红两个变异:① 去掉 (compare) 截断标记 ⇒ TestTidyCompareLimit_HitIsLoud 红;
② 中和隔离来源守卫 ⇒ TestReviewRestrictedSourceIsNeverTouched 红。
唯一红是 internal/runtime/tool 的两条超时用例(该包无人改)⇒ 主仓库 -count=8 全绿,
判定为负载敏感 flake(当时两个整树验收 + 子代理并发),已记入开发习惯与约定 §7。

.env.example 补 MEMORY_REVIEW_* 四键(MT-B 无该文件权限,由我按其代码语义补写)。
2026-10-06 08:04:12 +08:00

276 lines
11 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"
"fmt"
"net/http"
"strings"
"testing"
"time"
"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 是端点测试用的试算替身:按注入的结果/错误作答,并记录调用
// 次数(用来钉住「关闭时连试算都不跑」)。
//
// 异步面(契约 v0.76 §4.12.1)同样以「可驱动的桩」呈现:HTTP 层的职责是把领域
// 状态**如实投影**,注册表本身的行为(TTL / 并发 / 重启语义)由 memory 包的单测
// 钉住,不在这里重复实现一遍。
type fakeTidyDryRunner struct {
result memory.TidyDryRun
err error
calls int
handle memory.TidyDryRunHandle
startErr error
startCalls int
// timeoutGot 记录网关传给 StartDryRun 的墙钟上限(钉住「与同步同一口径」)。
timeoutGot time.Duration
status memory.TidyAsyncDryRun
statusErr error
statusCalls int
}
func (f *fakeTidyDryRunner) DryRun(context.Context) (memory.TidyDryRun, error) {
f.calls++
return f.result, f.err
}
func (f *fakeTidyDryRunner) StartDryRun(timeout time.Duration) (memory.TidyDryRunHandle, error) {
f.startCalls++
f.timeoutGot = timeout
if f.startErr != nil {
return memory.TidyDryRunHandle{}, f.startErr
}
return f.handle, nil
}
func (f *fakeTidyDryRunner) DryRunResult(runID string) (memory.TidyAsyncDryRun, error) {
f.statusCalls++
if f.statusErr != nil {
return memory.TidyAsyncDryRun{}, f.statusErr
}
if f.status.Status == "" {
// 未登记的状态 = 查不到(真实实现里由内存注册表决定)。
return memory.TidyAsyncDryRun{}, fmt.Errorf("%w: %s", memory.ErrTidyRunNotFound, runID)
}
out := f.status
out.RunID = runID
return out, nil
}
// 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)
}
}