feat(memory): 护栏抬高+时间预算闸+试算加性异步 与 D3 入库前审查(耦合两片一起落)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

【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 无该文件权限,由我按其代码语义补写)。
This commit is contained in:
AskaEth committed 2026-10-06 08:04:12 +08:00
1 parent fe4c702f87
commit 6e28b38b08
37 files changed
+4011 -75

No files matched your search

@@ -8,7 +8,7 @@ import (
)
// 本文件承载 P3「配置只读事实面」:把面板此前**看不见、只能照模板猜**的一组调参
// 键做成只读事实展示(记忆整理 7 键 + 安静时段 2 键 + 后台思考心跳 3 键)。三条边界
// 键做成只读事实展示(记忆整理 8 键 + 安静时段 2 键 + 后台思考心跳 3 键)。三条边界
// (AGENTS §10 与主人红线):
//
// 1. **键名单的权威来源是 `.env.example`**(不是 `.env`、也不是本文件自己发明
@@ -20,7 +20,7 @@ import (
// 面板看到的是运行实例真正在用的数,而不是 `.env` 原文。每条事实另带**声明
// 默认值**(从 `default:"…"` 标签反射读出)与 `isDefault`,于是「这项还是默认
// 值」与「这项被改过」都能如实显示,不必假装知道 `.env` 里写过什么。
// 3. **绝不回任何密钥 / 令牌值**:本端点只覆盖上述 12 个非敏感调参键,响应里连
// 3. **绝不回任何密钥 / 令牌值**:本端点只覆盖上述 13 个非敏感调参键,响应里连
// 凭据类键的「是否已配置」都不出现。
// `TestAdminConfigFacts_NeverLeaksSecretValues` 用哨兵值把这条钉死。
//
@@ -78,6 +78,7 @@ type configFactSpec struct {
// configFactSpecs 是事实表的**唯一真源**:顺序即面板显示顺序(先记忆整理,
// 后安静时段与后台思考心跳)。键名必须与 `.env.example` 逐字一致(测试钉住)。
// 条数:记忆整理 8 + 后台思考心跳 3 + 安静时段 2 = 13。
func configFactSpecs() []configFactSpec {
return []configFactSpec{
{"MEMORY_TIDY_ENABLED", func(c *config.Config) string {
@@ -101,6 +102,12 @@ func configFactSpecs() []configFactSpec {
{"MEMORY_TIDY_CANDIDATE_LIMIT", func(c *config.Config) string {
return strconv.Itoa(c.MemoryTidy.CandidateLimit)
}},
// 两两比对的性能护栏(切片 MT-A,2026-10-06):此前是写死的常量 400,
// 459 条的分区被截到 400 ⇒ compared < scanned。开放成 env 键后,面板能看到
// 「这项是不是还是默认值」,运维放大配额也不必改代码。
{"MEMORY_TIDY_COMPARE_LIMIT", func(c *config.Config) string {
return strconv.Itoa(c.MemoryTidy.CompareLimit)
}},
// 后台思考心跳(design/12 §N A4):安静窗口只降低周期性心跳的频率,
// 而 env 是**启动期**读的 ⇒ 面板照实列出「生效值 + 声明默认」并写明改需重启。
{"ENABLE_BACKGROUND_THINKING", func(c *config.Config) string {
@@ -108,7 +108,7 @@ func TestAdminConfigFacts_QuietHoursAreProcessEffective(t *testing.T) {
// TestAdminConfigFacts_BackthinkHeartbeatKeys 钉住 design/12 §N A4 新增的三条
// 后台思考心跳事实(`ENABLE_BACKGROUND_THINKING` / `THINK_INTERVAL_SEC` /
// `THINK_QUIET_INTERVAL_SEC`):值取**进程生效值**、总条数确实是 12。键名与
// `THINK_QUIET_INTERVAL_SEC`):值取**进程生效值**、总条数确实是 13。键名与
// `.env.example` 逐字一致那一半由 TestAdminConfigFacts_KeysMatchEnvExample 钉。
func TestAdminConfigFacts_BackthinkHeartbeatKeys(t *testing.T) {
t.Parallel()
@@ -117,8 +117,8 @@ func TestAdminConfigFacts_BackthinkHeartbeatKeys(t *testing.T) {
cfg.Background.IntervalSec = 7200
cfg.Background.QuietIntervalSec = 0 // ≤0 = 关闭降频;事实面必须如实报 0
_, _, facts := getConfigFacts(t, cfg, "")
if len(facts.Facts) != 12 {
t.Fatalf("facts = %d, want 12(记忆整理 7 + 后台思考心跳 3 + 安静时段 2)", len(facts.Facts))
if len(facts.Facts) != 13 {
t.Fatalf("facts = %d, want 13(记忆整理 8 + 后台思考心跳 3 + 安静时段 2)", len(facts.Facts))
}
for key, want := range map[string]string{
"ENABLE_BACKGROUND_THINKING": "false",
@@ -147,7 +147,8 @@ func TestAdminConfigFacts_DeclaredDefaultsAndDefaultFlag(t *testing.T) {
"MEMORY_TIDY_QUIET_END_HOUR": "7",
"MEMORY_TIDY_RECALL_THRESHOLD": "0.8",
"MEMORY_TIDY_SCAN_LIMIT": "5000",
"MEMORY_TIDY_CANDIDATE_LIMIT": "20",
"MEMORY_TIDY_CANDIDATE_LIMIT": "200",
"MEMORY_TIDY_COMPARE_LIMIT": "2000",
"ENABLE_BACKGROUND_THINKING": "true",
"THINK_INTERVAL_SEC": "900",
"THINK_QUIET_INTERVAL_SEC": "3600",
@@ -157,7 +158,7 @@ func TestAdminConfigFacts_DeclaredDefaultsAndDefaultFlag(t *testing.T) {
defaultCfg := &config.Config{
MemoryTidy: config.MemoryTidyConfig{
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
RecallThreshold: 0.8, ScanLimit: 5000, CandidateLimit: 20,
RecallThreshold: 0.8, ScanLimit: 5000, CandidateLimit: 200, CompareLimit: 2000,
},
Background: config.BackgroundConfig{
Enabled: true, IntervalSec: 900, QuietIntervalSec: 3600,
@@ -309,7 +310,7 @@ func TestAdminConfigFacts_NeverLeaksSecretValues(t *testing.T) {
}
// TestAdminConfigFacts_KeysMatchEnvExample 是判据 ④:键名单的权威来源是
// `.env.example` —— 12 个键必须逐字出现在模板里(键名写错一个字母,用户照面板
// `.env.example` —— 13 个键必须逐字出现在模板里(键名写错一个字母,用户照面板
// 抄去改就是静默不生效,AGENTS §10 的 E6 固定动作)。
func TestAdminConfigFacts_KeysMatchEnvExample(t *testing.T) {
t.Parallel()
+25 -27
View File
@@ -2,7 +2,6 @@ package gateway
import (
"context"
"errors"
"net/http"
"time"
@@ -41,9 +40,18 @@ const tidyDryRunTimeout = 3 * time.Minute
// MemoryTidyDryRunner 是记忆整理「试算」的注入出口(契约 v0.75;切片 MT1)。
// 组合根注入的就是低峰心跳跑的那个 `*memory.TidyService` —— 同一套规则、同一份
// 配置,因此试算给出的建议与今晚 23:00 那一轮是同一口径。
//
// 契约 v0.76(切片 MT-A)在同一出口上加了**加性**的两个方法(异步试算的启动与
// 查询):它们不改变 DryRun 的语义,只是把「跑很久的那一轮」挪到后台。
type MemoryTidyDryRunner interface {
// DryRun 跑一轮整理规则并返回候选,零副作用。
DryRun(ctx context.Context) (memory.TidyDryRun, error)
// StartDryRun 启动一次异步试算并立即返回句柄(契约 v0.76 §4.12.1)。
// timeout 是这次运行的墙钟上限(非正取实现侧的兜底值)。
StartDryRun(timeout time.Duration) (memory.TidyDryRunHandle, error)
// DryRunResult 查询一次异步试算的状态;runId 未知(含进程重启后)返回
// memory.ErrTidyRunNotFound。
DryRunResult(runID string) (memory.TidyAsyncDryRun, error)
}
// AdminTidyDryRunCandidate 是试算响应里的一条候选(契约 v0.75 §4.12)。
@@ -97,7 +105,15 @@ type AdminTidyDryRunResponse struct {
// adminMemoryTidyDryRun 是「试算整理建议」的处理函数。它只读语义、不审计
// (见文件头注释第 4 条),失败一律如实作答。
//
// 带 `?async=1` 时改走**加性异步**(契约 v0.76 §4.12.1,见
// admin_memory_tidy_async.go):立刻回 202 + runId,结果由 B15 轮询取回。
// 不带该参数时这条函数的行为**逐字不变** —— 老客户端(含现有面板)零影响。
func (s *Server) adminMemoryTidyDryRun(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get(tidyAsyncQueryParam) == tidyAsyncTriggerValue {
s.adminMemoryTidyDryRunAsync(w, r)
return
}
if s.memoryTidy == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_unavailable", "记忆整理服务未接线"))
return
@@ -113,34 +129,16 @@ func (s *Server) adminMemoryTidyDryRun(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, adminTidyDryRunResponse(result))
}
// writeTidyDryRunError 把试算失败映射成**诚实**的错误码。用户可见的 message
// 只说明「哪一类原因」,原始错误(可能含内部地址/凭据片段)只进日志。
// writeTidyDryRunError 把试算失败映射成**诚实**的 HTTP 错误响应。映射表在
// tidyDryRunAppError(同步与异步共用的唯一映射点,见 admin_memory_tidy_async.go)。
func (s *Server) writeTidyDryRunError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, memory.ErrTidyDisabled):
writeError(w, &apperr.AppError{
Code: "tidy_disabled",
Message: "记忆整理已被配置关闭(MEMORY_TIDY_ENABLED=false),试算同样不上报候选",
HTTPCode: http.StatusConflict,
})
case errors.Is(err, memory.ErrTidyRecallUnavailable):
s.log.Error("memory tidy dry-run recall unavailable", "err", err)
writeError(w, &apperr.AppError{
Code: "tidy_recall_unavailable",
Message: "语义召回(embedding)不可用,试算未产出候选(试算不做静默降级)",
HTTPCode: http.StatusInternalServerError,
})
case errors.Is(err, context.DeadlineExceeded):
s.log.Error("memory tidy dry-run timed out", "err", err)
writeError(w, &apperr.AppError{
Code: "tidy_timeout",
Message: "记忆整理试算超时(未产出候选)",
HTTPCode: http.StatusServiceUnavailable,
})
default:
s.log.Error("memory tidy dry-run failed", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_dry_run_failed", "记忆整理试算失败"))
failed := tidyDryRunAppError(err)
// 配置关闭是**显式选择**,不算故障:与既有口径一致,不为它记 ERROR 日志。
// 其余失败(召回不可用 / 超时 / 内部错)都要在日志里留原始错误。
if failed.Code != "tidy_disabled" {
s.log.Error("memory tidy dry-run failed", "code", failed.Code, "err", err)
}
writeError(w, failed)
}
// adminTidyDryRunResponse 把领域结果投影成线上结构。它**不复制**任何阈值/上限:
@@ -0,0 +1,162 @@
package gateway
import (
"context"
"errors"
"net/http"
"time"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件承载记忆整理「试算」的**加性异步**(契约 v0.76 §4.12.1;切片 MT-A)。
//
// 为什么有它:真机一轮试算是 **89.9 秒 / 744 条**(同量级的那轮 784 条更慢),
// 而抬高护栏(compare 400→2000 / candidate 20→200)只会让它更慢 —— 同步 HTTP
// 会被反代或客户端超时切断。异步把「跑」与「取」拆开:`?async=1` 立刻回
// `202 + runId`,客户端轮询 B15 取结果。
//
// 五条不可走样的口径:
//
// 1. **加性**:不带 `async` 时走的是**原封不动**的同步路径(B14)—— 老客户端
// 逐字不变。异步是另一条入口,不是替换。
// 2. **结果形状只有一份**:`done` 时的 `result` 就是 §4.12 同步响应的 `data`
// (同一个 `adminTidyDryRunResponse` 投影函数),绝不另造第二份 JSON。
// 3. **运行活在进程内存**(见 memory.TidyService.StartDryRun):不落库、不新开
// 端口;TTL 30 分钟、同时只允许 1 个在跑(第二个请求复用既有 runId);
// **进程重启后旧 runId 如实失效** ⇒ `404 tidy_run_not_found`,绝不假装还在跑。
// 4. **失败码沿用 §4.12**:`failed` 状态的 `error.code` 与同步路径同一张表
// (tidyDryRunAppError 是**唯一**映射点),message 同样只说明原因类别,
// 原始内部错误只进日志。
// 5. **只算不写 ⇒ 不审计**:与 B14 同门同口径,也不进 owner-only 的写路径。
// tidyAsyncQueryParam 是触发异步试算的查询参数名(契约 v0.76 §4.12.1)。
// 只有字面量 "1" 触发异步:`?async=0` / 缺省 / 其它值一律走同步(老客户端语义)。
const tidyAsyncQueryParam = "async"
// tidyAsyncTriggerValue 是触发异步的字面量(契约登记的就是 `?async=1`)。
const tidyAsyncTriggerValue = "1"
// AdminTidyDryRunAccepted 是 `POST …/tidy/dry-run?async=1` 的 202 响应
// (契约 v0.76 §4.12.1)。`runId` 是**不透明**的进程内标识(客户端只用它轮询,
// 不要解析其结构);`startedAt` 是这次运行真正开始的墙钟时刻(UTC RFC3339)——
// 复用既有运行时,它是那次运行的时间,不是本次请求的时间。
type AdminTidyDryRunAccepted struct {
RunID string `json:"runId"`
Status string `json:"status"`
StartedAt string `json:"startedAt"`
}
// AdminTidyRunError 是 `failed` 状态的诚实失败原因:`code` 沿用 §4.12 的失败码表。
type AdminTidyRunError struct {
Code string `json:"code"`
Message string `json:"message"`
}
// AdminTidyDryRunStatusResponse 是 B15 轮询响应(契约 v0.76 §4.12.1):
// `{status, result?, error?}`,`done` 时 `result` 与 §4.12 同步响应的 `data`
// **逐键同形**(含 `candidates` 恒非 null)。
type AdminTidyDryRunStatusResponse struct {
Status string `json:"status"`
// Result 只在 status == "done" 时出现;形状 = §4.12 的 data。
Result *AdminTidyDryRunResponse `json:"result,omitempty"`
// Error 只在 status == "failed" 时出现。
Error *AdminTidyRunError `json:"error,omitempty"`
}
// adminMemoryTidyDryRunStatus 是 B15 的处理函数:查询一次异步试算的状态。
// runId 未知(从未存在 / 超 TTL / 进程重启过)⇒ `404 tidy_run_not_found`。
func (s *Server) adminMemoryTidyDryRunStatus(w http.ResponseWriter, r *http.Request) {
if s.memoryTidy == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_unavailable", "记忆整理服务未接线"))
return
}
runID := r.PathValue("runId")
run, err := s.memoryTidy.DryRunResult(runID)
if err != nil {
if errors.Is(err, memory.ErrTidyRunNotFound) {
// 如实说「查不到」:进程内存态不跨重启,旧 runId 就是不存在。
writeError(w, &apperr.AppError{
Code: "tidy_run_not_found",
Message: "该试算运行不存在或已过期(结果只活在进程内存:TTL 30 分钟,进程重启即失效)",
HTTPCode: http.StatusNotFound,
})
return
}
s.log.Error("memory tidy dry-run status failed", "run_id", runID, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_dry_run_failed", "记忆整理试算状态查询失败"))
return
}
writeJSON(w, http.StatusOK, adminTidyDryRunStatusResponse(run))
}
// adminMemoryTidyDryRunAsync 是 `?async=1` 的处理函数:启动一次后台试算并立刻回
// 202(契约 v0.76 §4.12.1)。**不读 body**(与同步路径同:端点自己不带旋钮)。
//
// 它只做两件事:登记运行 + 回 runId。真正的计算在 memory.TidyService 的后台
// goroutine 上跑(它不绑定本请求的上下文 —— 请求一返回连接就结束了,绑上去会
// 当场取消这次运行)。
func (s *Server) adminMemoryTidyDryRunAsync(w http.ResponseWriter, r *http.Request) {
if s.memoryTidy == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_unavailable", "记忆整理服务未接线"))
return
}
handle, err := s.memoryTidy.StartDryRun(tidyDryRunTimeout)
if err != nil {
// 同步的诚实口径照旧:连「排队」都失败时不回 202 —— 那会是一句空承诺。
s.writeTidyDryRunError(w, err)
return
}
s.log.Info("memory tidy dry-run accepted",
"run_id", handle.RunID, "started_at", handle.StartedAt.UTC().Format(time.RFC3339))
writeJSON(w, http.StatusAccepted, AdminTidyDryRunAccepted{
RunID: handle.RunID,
Status: string(memory.TidyAsyncRunning),
StartedAt: handle.StartedAt.UTC().Format(time.RFC3339),
})
}
// adminTidyDryRunStatusResponse 把领域侧的异步状态投影成线上结构。`running` 时
// 两个载荷键都缺席(**不能**回一个空 result 冒充「跑完了但没有建议」)。
func adminTidyDryRunStatusResponse(run memory.TidyAsyncDryRun) AdminTidyDryRunStatusResponse {
out := AdminTidyDryRunStatusResponse{Status: string(run.Status)}
if run.Result != nil {
projected := adminTidyDryRunResponse(*run.Result)
out.Result = &projected
}
if run.Err != nil {
failed := tidyDryRunAppError(run.Err)
out.Error = &AdminTidyRunError{Code: failed.Code, Message: failed.Message}
}
return out
}
// tidyDryRunAppError 把试算失败映射成**诚实**的错误码,是同步(HTTP 状态码)与
// 异步(failed 状态的 code/message)**唯一**的映射点 —— 两条路径的失败口径因此
// 不可能漂移。用户可见的 message 只说明「哪一类原因」,原始错误(可能含内部地址
// 或凭据片段)只进日志。
func tidyDryRunAppError(err error) *apperr.AppError {
switch {
case errors.Is(err, memory.ErrTidyDisabled):
return &apperr.AppError{
Code: "tidy_disabled",
Message: "记忆整理已被配置关闭(MEMORY_TIDY_ENABLED=false),试算同样不上报候选",
HTTPCode: http.StatusConflict,
}
case errors.Is(err, memory.ErrTidyRecallUnavailable):
return &apperr.AppError{
Code: "tidy_recall_unavailable",
Message: "语义召回(embedding)不可用,试算未产出候选(试算不做静默降级)",
HTTPCode: http.StatusInternalServerError,
}
case errors.Is(err, context.DeadlineExceeded):
return &apperr.AppError{
Code: "tidy_timeout",
Message: "记忆整理试算超时(未产出候选)",
HTTPCode: http.StatusServiceUnavailable,
}
default:
return apperr.Wrap(apperr.ErrInternal, "tidy_dry_run_failed", "记忆整理试算失败")
}
}
@@ -0,0 +1,463 @@
package gateway
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件锁定记忆整理「试算」的**加性异步**(契约 v0.76 §4.12.1;切片 MT-A):
//
// ① `?async=1` ⇒ 202 + `{runId,status:"running",startedAt}`;**不带**它时逐字走
// 原同步路径(老客户端零影响);
// ② 轮询 `done` 时的 `result` 与 §4.12 同步响应的 `data` **逐字节同形**
// (同一套 JSON,不是第二份);
// ③ `failed` 的 `error.code` 与同步路径同一张表;未知/过期 runId ⇒ `404
// tidy_run_not_found`(**绝不假装还在跑**);
// ④ 鉴权与 B14 同档(owner/admin;viewer 403、匿名 401),未接线 ⇒ 500
// `tidy_unavailable` —— 一律**不**回空候选数组冒充「没有建议」;
// ⑤ 真机链路:真实 `*memory.TidyService` 上「202 → 轮询 → done」跑通,且
// **一行都不写**(三张表调用前后逐行一致)。
// tidyRunAcceptedEnvelope 是 202 响应的解码壳。
type tidyRunAcceptedEnvelope struct {
Data struct {
RunID string `json:"runId"`
Status string `json:"status"`
StartedAt string `json:"startedAt"`
} `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// tidyRunStatusEnvelope 是轮询响应的解码壳(result 保留原始字节,供同形断言)。
type tidyRunStatusEnvelope struct {
Data struct {
Status string `json:"status"`
Result json.RawMessage `json:"result"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
} `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// tidyRunRawEnvelope 只留 data 原始字节(比对同形用)。
type tidyRunRawEnvelope struct {
Data json.RawMessage `json:"data"`
}
// postTidyDryRunAsync 调一次 `?async=1` 并解码 202 壳。
func postTidyDryRunAsync(t *testing.T, deps Deps, role auth.Role) (int, []byte, tidyRunAcceptedEnvelope, *auth.JWTService, string) {
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?async=1", nil, token)
var out tidyRunAcceptedEnvelope
if len(raw) > 0 {
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
}
return resp.StatusCode, raw, out, jwt, ts.URL
}
// getTidyRunStatus 查一次异步状态。
func getTidyRunStatus(t *testing.T, baseURL, runID, token string) (int, []byte, tidyRunStatusEnvelope) {
t.Helper()
resp, raw := doReq(t, http.MethodGet, baseURL+"/api/v1/admin/memories/tidy/dry-run/"+runID, nil, token)
var out tidyRunStatusEnvelope
if len(raw) > 0 {
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
}
return resp.StatusCode, raw, out
}
// TestAdminMemoryTidyDryRunAsync_AcceptedShape 是判据 ①:`?async=1` 立刻回 202 +
// runId/status/startedAt,**不跑**同步试算(不占连接),且墙钟上限与同步同口径。
func TestAdminMemoryTidyDryRunAsync_AcceptedShape(t *testing.T) {
t.Parallel()
startedAt := time.Date(2026, 10, 6, 1, 2, 3, 0, time.UTC)
fake := &fakeTidyDryRunner{handle: memory.TidyDryRunHandle{RunID: "run-abc", StartedAt: startedAt}}
status, raw, env, _, _ := postTidyDryRunAsync(t, Deps{MemoryTidy: fake}, auth.RoleOwner)
if status != http.StatusAccepted {
t.Fatalf("status = %d, want 202(body=%s)", status, raw)
}
if env.Data.RunID != "run-abc" || env.Data.Status != "running" {
t.Fatalf("202 载荷不对:%+v", env.Data)
}
if _, err := time.Parse(time.RFC3339, env.Data.StartedAt); err != nil {
t.Fatalf("startedAt 必须是 RFC3339:%q(%v)", env.Data.StartedAt, err)
}
if fake.startCalls != 1 {
t.Fatalf("StartDryRun 调用次数 = %d, want 1", fake.startCalls)
}
if fake.calls != 0 {
t.Fatalf("异步入口不得再同步跑一遍:DryRun 调用次数 = %d", fake.calls)
}
if fake.timeoutGot != tidyDryRunTimeout {
t.Fatalf("墙钟上限 = %s, want %s(与同步试算同一口径)", fake.timeoutGot, tidyDryRunTimeout)
}
if strings.Contains(string(raw), `"candidates"`) {
t.Fatalf("202 不得夹带候选数组(结果只在轮询里取):%s", raw)
}
}
// TestAdminMemoryTidyDryRunAsync_ResultIsByteIdenticalToSync 是判据 ②:轮询 `done`
// 的 `result` 与同步端点的 `data` **逐字节一致** —— 客户端可以只写一份解析器。
func TestAdminMemoryTidyDryRunAsync_ResultIsByteIdenticalToSync(t *testing.T) {
t.Parallel()
result := fixtureTidyDryRun()
fake := &fakeTidyDryRunner{
result: result,
handle: memory.TidyDryRunHandle{RunID: "run-1", StartedAt: time.Now()},
status: memory.TidyAsyncDryRun{Status: memory.TidyAsyncDone, Result: &result},
}
deps := Deps{MemoryTidy: fake}
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
token := signAny(t, jwt, "acct", auth.RoleOwner)
_, syncRaw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/dry-run", nil, token)
var syncEnv tidyRunRawEnvelope
if err := json.Unmarshal(syncRaw, &syncEnv); err != nil {
t.Fatalf("decode sync: %v (%s)", err, syncRaw)
}
status, raw, env := getTidyRunStatus(t, ts.URL, "run-1", token)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200(body=%s)", status, raw)
}
if env.Data.Status != "done" {
t.Fatalf("status = %q, want done", env.Data.Status)
}
if env.Data.Error != nil {
t.Fatalf("done 不得带 error:%+v", env.Data.Error)
}
if string(env.Data.Result) != string(syncEnv.Data) {
t.Fatalf("result 必须与同步响应的 data 逐字节同形:\n poll=%s\n sync=%s", env.Data.Result, syncEnv.Data)
}
if !strings.Contains(string(env.Data.Result), `"candidates":[`) {
t.Fatalf("result 里的 candidates 必须是非 null 数组:%s", env.Data.Result)
}
}
// TestAdminMemoryTidyDryRunAsync_FailedCarriesSameCodeAsSync 是判据 ③:失败态的
// `error.code` 与同步路径同一张映射表(这里用「语义召回不可用」),且不回候选数组。
func TestAdminMemoryTidyDryRunAsync_FailedCarriesSameCodeAsSync(t *testing.T) {
t.Parallel()
fake := &fakeTidyDryRunner{
err: memory.ErrTidyRecallUnavailable,
handle: memory.TidyDryRunHandle{RunID: "run-fail", StartedAt: time.Now()},
status: memory.TidyAsyncDryRun{Status: memory.TidyAsyncFailed, Err: memory.ErrTidyRecallUnavailable},
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{MemoryTidy: fake})
token := signAny(t, jwt, "acct", auth.RoleOwner)
status, raw, env := getTidyRunStatus(t, ts.URL, "run-fail", token)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200(失败在载荷里,不是 HTTP 错误)", status)
}
if env.Data.Status != "failed" || env.Data.Error == nil {
t.Fatalf("失败态载荷不对:%s", raw)
}
if env.Data.Error.Code != "tidy_recall_unavailable" {
t.Fatalf("error.code = %q, want tidy_recall_unavailable(与同步同一张表)", env.Data.Error.Code)
}
if len(env.Data.Result) != 0 {
t.Fatalf("失败态不得带 result:%s", raw)
}
if strings.Contains(string(raw), `"candidates"`) {
t.Fatalf("失败态不得出现候选数组(那是把「没算出来」谎报成「没有建议」):%s", raw)
}
// 负对照:同步端点在同一个错误上给的是同一个 code。
_, syncRaw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/dry-run", nil, token)
if !strings.Contains(string(syncRaw), `"tidy_recall_unavailable"`) {
t.Fatalf("同步路径的失败码必须与异步一致:%s", syncRaw)
}
}
// TestAdminMemoryTidyDryRunAsync_UnknownRunIsHonest404 是判据 ③(重启/过期语义):
// 查不到的 runId 如实回 404,**不做**「还在跑」的假承诺。
func TestAdminMemoryTidyDryRunAsync_UnknownRunIsHonest404(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{MemoryTidy: &fakeTidyDryRunner{}})
token := signAny(t, jwt, "acct", auth.RoleOwner)
status, raw, env := getTidyRunStatus(t, ts.URL, "run-gone", token)
if status != http.StatusNotFound {
t.Fatalf("status = %d, want 404(body=%s)", status, raw)
}
if env.Error.Code != "tidy_run_not_found" {
t.Fatalf("code = %q, want tidy_run_not_found", env.Error.Code)
}
if env.Data.Status != "" || strings.Contains(string(raw), `"running"`) {
t.Fatalf("查不到时不得回任何状态(更不许假装还在跑):%s", raw)
}
}
// TestAdminMemoryTidyDryRunAsync_RequiresOwnerOrAdmin 是判据 ④:两条端点的鉴权
// 都与 B14 同档(owner/admin 可,viewer 403,匿名 401)。
func TestAdminMemoryTidyDryRunAsync_RequiresOwnerOrAdmin(t *testing.T) {
t.Parallel()
result := fixtureTidyDryRun()
deps := Deps{MemoryTidy: &fakeTidyDryRunner{
result: result,
handle: memory.TidyDryRunHandle{RunID: "run-rbac", StartedAt: time.Now()},
status: memory.TidyAsyncDryRun{Status: memory.TidyAsyncDone, Result: &result},
}}
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin} {
status, _, _, _, _ := postTidyDryRunAsync(t, deps, role)
if status != http.StatusAccepted {
t.Fatalf("POST async role %s = %d, want 202", role, status)
}
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
s, _, _ := getTidyRunStatus(t, ts.URL, "run-rbac", signAny(t, jwt, "acct", role))
if s != http.StatusOK {
t.Fatalf("GET role %s = %d, want 200", role, s)
}
}
if status, _, _, _, _ := postTidyDryRunAsync(t, deps, auth.RoleViewer); status != http.StatusForbidden {
t.Fatalf("POST async viewer = %d, want 403", status)
}
if status, _, _, _, _ := postTidyDryRunAsync(t, deps, ""); status != http.StatusUnauthorized {
t.Fatalf("POST async 匿名 = %d, want 401", status)
}
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
if s, _, _ := getTidyRunStatus(t, ts.URL, "run-rbac", signAny(t, jwt, "acct", auth.RoleViewer)); s != http.StatusForbidden {
t.Fatalf("GET viewer = %d, want 403", s)
}
if s, _, _ := getTidyRunStatus(t, ts.URL, "run-rbac", ""); s != http.StatusUnauthorized {
t.Fatalf("GET 匿名 = %d, want 401", s)
}
}
// TestAdminMemoryTidyDryRunAsync_UnwiredIsHonest 是判据 ④(未接线):两条端点都
// 如实回 500 tidy_unavailable,且绝不回候选数组。
func TestAdminMemoryTidyDryRunAsync_UnwiredIsHonest(t *testing.T) {
t.Parallel()
status, raw, env, _, _ := postTidyDryRunAsync(t, Deps{}, auth.RoleOwner)
if status != http.StatusInternalServerError || env.Error.Code != "tidy_unavailable" {
t.Fatalf("未接线 POST async = %d/%q, want 500/tidy_unavailable", status, env.Error.Code)
}
if strings.Contains(string(raw), `"candidates"`) {
t.Fatalf("未接线时不得回候选数组:%s", raw)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
s, rawGet, getEnv := getTidyRunStatus(t, ts.URL, "run-x", signAny(t, jwt, "acct", auth.RoleOwner))
if s != http.StatusInternalServerError || getEnv.Error.Code != "tidy_unavailable" {
t.Fatalf("未接线 GET = %d/%q, want 500/tidy_unavailable(body=%s)", s, getEnv.Error.Code, rawGet)
}
}
// TestAdminMemoryTidyDryRun_AsyncOnlyOnExplicitFlag 是判据 ①(加性):只有
// `async=1` 才走异步;缺省、`async=0`、`async=true` 一律走原同步路径。
func TestAdminMemoryTidyDryRun_AsyncOnlyOnExplicitFlag(t *testing.T) {
t.Parallel()
for _, query := range []string{"", "?async=0", "?async=true", "?async="} {
fake := &fakeTidyDryRunner{result: fixtureTidyDryRun()}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{MemoryTidy: fake})
resp, raw := doReq(t, http.MethodPost,
ts.URL+"/api/v1/admin/memories/tidy/dry-run"+query, nil, signAny(t, jwt, "acct", auth.RoleOwner))
if resp.StatusCode != http.StatusOK {
t.Fatalf("query=%q status = %d, want 200(老客户端逐字不变)", query, resp.StatusCode)
}
if !strings.Contains(string(raw), `"dryRun":true`) {
t.Fatalf("query=%q 必须是同步响应:%s", query, raw)
}
if fake.calls != 1 || fake.startCalls != 0 {
t.Fatalf("query=%q 走了异步路径:calls=%d startCalls=%d", query, fake.calls, fake.startCalls)
}
}
}
// tidyDBRows 把三张表的内容渲染成可比较的字符串行(逐行、按主键排序)。
func tidyDBRows(t *testing.T, conn *sql.DB) []string {
t.Helper()
queries := []string{
`SELECT id || '|' || persona_id || '|' || scope || '|' || kind || '|' || content || '|' || status FROM memory_items ORDER BY id`,
`SELECT id || '|' || action || '|' || status || '|' || rule_version FROM memory_tidy_candidates ORDER BY id`,
`SELECT id || '|' || scanned || '|' || candidates || '|' || rule_version FROM memory_tidy_runs ORDER BY id`,
}
out := make([]string, 0)
for _, q := range queries {
rows, err := conn.QueryContext(context.Background(), q)
if err != nil {
t.Fatalf("dump (%s): %v", q, err)
}
for rows.Next() {
var line string
if err := rows.Scan(&line); err != nil {
_ = rows.Close()
t.Fatalf("scan: %v", err)
}
out = append(out, line)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
t.Fatalf("iterate: %v", err)
}
_ = rows.Close()
}
return out
}
// TestAdminMemoryTidyDryRunAsync_EndToEndIsZeroSideEffect 是判据 ⑤:真实
// `*memory.TidyService` 上走完「202 → 轮询 → done」,结果与同步端点一致,且
// **三张表逐行不变**(异步路径与同步路径同一条硬约束)。
func TestAdminMemoryTidyDryRunAsync_EndToEndIsZeroSideEffect(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn, err := db.Open(ctx, filepath.Join(t.TempDir(), "cyrene.db"), gwTestLogger())
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
store, err := memory.NewStore(ctx, conn, gwTestLogger())
if err != nil {
t.Fatalf("memory.NewStore: %v", err)
}
base := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC)
for i, content := range []string{
"主人常在深夜与助手聊天,有熬夜习惯",
"主人常在深夜与助手聊天,有熬夜习惯,作息偏晚",
} {
if _, err := store.Add(ctx, memory.MemoryItem{
PersonaID: "persona.a", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
Content: content, Confidence: 0.9, CreatedAt: base.Add(time.Duration(i) * time.Minute),
}); err != nil {
t.Fatalf("seed: %v", err)
}
}
svc, err := memory.NewTidyService(memory.TidyConfig{
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
RecallThreshold: 0.80, ScanLimit: 5000, CandidateLimit: 200, CompareLimit: 2000,
}, memory.TidyDeps{Store: store, Log: gwTestLogger()})
if err != nil {
t.Fatalf("NewTidyService: %v", err)
}
before := tidyDBRows(t, conn)
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{MemoryTidy: svc})
token := signAny(t, jwt, "acct", auth.RoleOwner)
resp, raw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/dry-run?async=1", nil, token)
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("status = %d, want 202(body=%s)", resp.StatusCode, raw)
}
var accepted tidyRunAcceptedEnvelope
if err := json.Unmarshal(raw, &accepted); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
runID := accepted.Data.RunID
if runID == "" || accepted.Data.Status != "running" {
t.Fatalf("202 载荷不对:%+v", accepted.Data)
}
// 轮询到 done(真机一轮 90 秒量级;测试夹具是毫秒级)。
var done tidyRunStatusEnvelope
deadline := time.Now().Add(20 * time.Second)
for {
if time.Now().After(deadline) {
t.Fatalf("轮询超时,最后状态 %q", done.Data.Status)
}
status, body, env := getTidyRunStatus(t, ts.URL, runID, token)
if status != http.StatusOK {
t.Fatalf("轮询 status = %d(body=%s)", status, body)
}
if env.Data.Status == "failed" {
t.Fatalf("异步试算失败:%+v", env.Data.Error)
}
if env.Data.Status == "done" {
done = env
break
}
time.Sleep(5 * time.Millisecond)
}
var result AdminTidyDryRunResponse
if err := json.Unmarshal(done.Data.Result, &result); err != nil {
t.Fatalf("decode result: %v (%s)", err, done.Data.Result)
}
if !result.DryRun || result.RuleVersion != memory.TidyRuleVersion {
t.Fatalf("result 元信息不对:%+v", result)
}
if len(result.Candidates) == 0 {
t.Fatalf("夹具应当产出候选(否则这条用例是空转):%+v", result)
}
// 同步端点在同一批数据上给出同一套结果(同一套规则、同一个投影函数)。
_, syncRaw, syncEnv := postTidyDryRun(t, Deps{MemoryTidy: svc}, auth.RoleOwner)
if syncEnv.Data.RuleVersion != result.RuleVersion || len(syncEnv.Data.Candidates) != len(result.Candidates) {
t.Fatalf("异步与同步结果不一致:\n async=%+v\n sync =%+v(%s)", result, syncEnv.Data, syncRaw)
}
after := tidyDBRows(t, conn)
if strings.Join(before, "\n") != strings.Join(after, "\n") {
t.Fatalf("试算有副作用(三张表必须逐行不变):\n before=%v\n after =%v", before, after)
}
}
// TestAdminMemoryTidyDryRunAsync_RealServiceRestartDropsRunID 钉住「进程重启 ⇒
// 旧 runId 如实失效」:同一份存储上换一个服务实例(= 重启),旧 runId 回 404。
func TestAdminMemoryTidyDryRunAsync_RealServiceRestartDropsRunID(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn, err := db.Open(ctx, filepath.Join(t.TempDir(), "cyrene.db"), gwTestLogger())
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
store, err := memory.NewStore(ctx, conn, gwTestLogger())
if err != nil {
t.Fatalf("memory.NewStore: %v", err)
}
cfg := memory.TidyConfig{
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
RecallThreshold: 0.80, ScanLimit: 5000, CandidateLimit: 200, CompareLimit: 2000,
}
first, err := memory.NewTidyService(cfg, memory.TidyDeps{Store: store, Log: gwTestLogger()})
if err != nil {
t.Fatalf("NewTidyService: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{MemoryTidy: first})
token := signAny(t, jwt, "acct", auth.RoleOwner)
resp, raw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/dry-run?async=1", nil, token)
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("status = %d, want 202(body=%s)", resp.StatusCode, raw)
}
var accepted tidyRunAcceptedEnvelope
if err := json.Unmarshal(raw, &accepted); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
// 「重启」:同一份存储上的新实例(注册表是进程内存态,重启即空)。
restarted, err := memory.NewTidyService(cfg, memory.TidyDeps{Store: store, Log: gwTestLogger()})
if err != nil {
t.Fatalf("NewTidyService(restart): %v", err)
}
_, ts2, jwt2 := buildEnv(t, cfgDefault(), Deps{MemoryTidy: restarted})
status, body, env := getTidyRunStatus(t, ts2.URL, accepted.Data.RunID, signAny(t, jwt2, "acct", auth.RoleOwner))
if status != http.StatusNotFound || env.Error.Code != "tidy_run_not_found" {
t.Fatalf("重启后旧 runId = %d/%q, want 404/tidy_run_not_found(body=%s)", status, env.Error.Code, body)
}
}
@@ -4,9 +4,11 @@ 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"
@@ -23,10 +25,24 @@ import (
// 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) {
@@ -34,6 +50,29 @@ func (f *fakeTidyDryRunner) DryRun(context.Context) (memory.TidyDryRun, error) {
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{
+8
View File
@@ -195,7 +195,15 @@ func (s *Server) registerAdminRoutes(
// 它是**只读语义**——跑同一套整理规则把候选直接回给面板、一行都不写;因此
// 它不在 registerAdminWriteRoutes(那条路是 owner-only 的真实写入 + 审计)。
// 试算属于「算给我看」,不是「改给我看」。
//
// 切片 MT-A(**v0.76**)在同一端点上加了**加性**异步:`?async=1` ⇒ 202 + runId,
// 再用下面那条 GET 轮询;不带该参数时逐字走原同步路径。
mux.Handle("POST /api/v1/admin/memories/tidy/dry-run", writeAuth(http.HandlerFunc(s.adminMemoryTidyDryRun)))
// 异步试算的结果查询(切片 MT-A,**v0.76**):与 B14 **同门同档**(writeAuth =
// owner/admin)—— 它是同一次计算的另一种取法,绝不比同步入口更宽;同样只读
// 语义、同样**不审计**。结果只活在进程内存(TTL 30 分钟),进程重启后旧 runId
// 如实 404。
mux.Handle("GET /api/v1/admin/memories/tidy/dry-run/{runId}", writeAuth(http.HandlerFunc(s.adminMemoryTidyDryRunStatus)))
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))