feat(memory): 记忆整理「试算(dry-run)」入口 + 面板卡(契约 v0.75)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人要「现在就想看到在 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)
This commit is contained in:
AskaEth committed 2026-10-05 22:06:54 +08:00
1 parent 3d8c30dc99
commit d6ed9cfa2b
18 files changed
+1631 -79

No files matched your search

@@ -0,0 +1,191 @@
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"
)
// 本文件承载记忆整理「试算(dry-run)」入口(契约 v0.75;切片 MT1)。
//
// 它回答的是主人的那句「**现在就想看到在 736 条上它会建议合并哪些**」:
// 整理器本身是低峰定时任务(design/23 §4,默认 23:00–07:00 才跑),在它
// 第一次开跑之前,库里既没有候选也没有留痕 —— 面板只能空着。本端点把同一套
// 规则**当场算一遍并把候选直接回给面板**。
//
// 四条不可走样的口径:
//
// 1. **零副作用**:跑的是 `memory.Tidier.DryRun`,它不写 memory_tidy_candidates、
// 不写 memory_tidy_runs、**绝不改 memory_items 的任何列**(单测逐行钉死)。
// 2. **不写第二套规则**:候选由既有 rule engine / tidier 代码路径产出,端点只做
// 线上投影;阈值/上限一律取既有配置(MEMORY_TIDY_RECALL_THRESHOLD /
// SCAN_LIMIT / CANDIDATE_LIMIT),端点自己**不引入任何上限**。
// 3. **诚实失败**:未接线 ⇒ `500 tidy_unavailable`;配置关闭 ⇒ `409 tidy_disabled`;
// 语义召回不可用 ⇒ `500 tidy_recall_unavailable`;超时 ⇒ `503 tidy_timeout`。
// **一律不回空候选数组假装「没有建议」** —— 那是把「没算出来」谎报成「算过了,没有」。
// 4. **只算不写 ⇒ 不审计**:它是只读计算(与 /admin/config-facts 同一档),
// 因此不写审计链,也不进 registerAdminWriteRoutes 那条 owner-only 写路径。
//
// 面板按下才跑,**不做自动轮询**:试算是一次真实规则计算(扫描 + 两两召回 +
// 包含判定),不是看板指标。
// tidyDryRunTimeout 是试算处理的墙钟上限。试算在既有护栏内是有界的
// (每分区扫描 ≤ SCAN_LIMIT、参与比对 ≤ maxTidyCompareItems),显式给一个上限
// 只为让卡住的请求(如 embedding 服务半死不活)不占着连接;超时**如实报错**。
const tidyDryRunTimeout = 3 * time.Minute
// MemoryTidyDryRunner 是记忆整理「试算」的注入出口(契约 v0.75;切片 MT1)。
// 组合根注入的就是低峰心跳跑的那个 `*memory.TidyService` —— 同一套规则、同一份
// 配置,因此试算给出的建议与今晚 23:00 那一轮是同一口径。
type MemoryTidyDryRunner interface {
// DryRun 跑一轮整理规则并返回候选,零副作用。
DryRun(ctx context.Context) (memory.TidyDryRun, error)
}
// AdminTidyDryRunCandidate 是试算响应里的一条候选(契约 v0.75 §4.12)。
// 字段与 `memory_tidy_candidates` 的落库形状同构,但**没有 status / decided\***
// —— 试算不落库,因此不存在「已决定」这回事;ID 也不下发(它没有任何后续动作)。
//
// Evidence 复用领域类型(`memory.TidyEvidence`,带 JSON tag):面板的 diff 预览
// 需要 A/B 原文、包含证据与否决原因,字段就是按它设计的。
type AdminTidyDryRunCandidate struct {
Action string `json:"action"`
SourceIDs []string `json:"sourceIds"`
TargetID string `json:"targetId,omitempty"`
PersonaID string `json:"personaId"`
Scope string `json:"scope"`
ProjectID string `json:"projectId,omitempty"`
Kind string `json:"kind,omitempty"`
ProposedContent string `json:"proposedContent,omitempty"`
MergedContent string `json:"mergedContent,omitempty"`
Reason string `json:"reason"`
RuleVersion string `json:"ruleVersion"`
ScoreMin float64 `json:"scoreMin"`
ScoreMax float64 `json:"scoreMax"`
Evidence memory.TidyEvidence `json:"evidence"`
}
// AdminTidyDryRunResponse 是 POST /api/v1/admin/memories/tidy/dry-run 的线上结构
// (契约 v0.75 §4.12)。
//
// `dryRun` 恒为 true:这份响应**没有**改动任何记忆,客户端可据此把「仅试算」的
// 文案与结构绑定,而不是只靠一段本地硬编码说明。
type AdminTidyDryRunResponse struct {
DryRun bool `json:"dryRun"`
RecallMode string `json:"recallMode"`
DurationMs int64 `json:"durationMs"`
RuleVersion string `json:"ruleVersion"`
// 观测口径(与真跑一轮同一套计数):扫描分区数 / 扫描条数 / 参与比对条数 /
// 召回对数 / 被否决对数与分布。
Partitions int `json:"partitions"`
Scanned int `json:"scanned"`
Compared int `json:"compared"`
Pairs int `json:"pairs"`
Vetoed int `json:"vetoed"`
VetoReasons map[string]int `json:"vetoReasons"`
// Truncated 列出被护栏截断的分区标签(扫描/比对/候选上限),供运维放大配额。
// 它**不是**候选;缺席表示本轮没有被截断。
Truncated []string `json:"truncated,omitempty"`
// Candidates 恒为非 null 数组:空数组 = 「算过了,这一轮没有建议」,与
// 「没算出来」(那种情况是错误响应)**必须能区分**。
Candidates []AdminTidyDryRunCandidate `json:"candidates"`
}
// adminMemoryTidyDryRun 是「试算整理建议」的处理函数。它只读语义、不审计
// (见文件头注释第 4 条),失败一律如实作答。
func (s *Server) adminMemoryTidyDryRun(w http.ResponseWriter, r *http.Request) {
if s.memoryTidy == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_unavailable", "记忆整理服务未接线"))
return
}
ctx, cancel := context.WithTimeout(r.Context(), tidyDryRunTimeout)
defer cancel()
result, err := s.memoryTidy.DryRun(ctx)
if err != nil {
s.writeTidyDryRunError(w, err)
return
}
writeJSON(w, http.StatusOK, adminTidyDryRunResponse(result))
}
// writeTidyDryRunError 把试算失败映射成**诚实**的错误码。用户可见的 message
// 只说明「哪一类原因」,原始错误(可能含内部地址/凭据片段)只进日志。
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", "记忆整理试算失败"))
}
}
// adminTidyDryRunResponse 把领域结果投影成线上结构。它**不复制**任何阈值/上限:
// 条数与截断标签都来自领域侧已经算好的结果(Candidates 已被每分区上限截断)。
func adminTidyDryRunResponse(result memory.TidyDryRun) AdminTidyDryRunResponse {
out := AdminTidyDryRunResponse{
DryRun: true,
RecallMode: result.RecallMode,
DurationMs: result.DurationMS,
RuleVersion: memory.TidyRuleVersion,
Partitions: result.Stats.Partitions,
Scanned: result.Stats.Scanned,
Compared: result.Stats.Compared,
Pairs: result.Stats.Pairs,
Vetoed: result.Stats.Vetoed,
VetoReasons: result.Stats.VetoReasons,
Truncated: result.Stats.Truncated,
Candidates: make([]AdminTidyDryRunCandidate, 0, len(result.Candidates)),
}
for _, c := range result.Candidates {
out.Candidates = append(out.Candidates, adminTidyDryRunCandidate(c))
}
return out
}
// adminTidyDryRunCandidate 投影一条候选。SourceIDs 兜成非 null 数组(面板按数组读)。
func adminTidyDryRunCandidate(c memory.TidyCandidate) AdminTidyDryRunCandidate {
ids := c.SourceIDs
if ids == nil {
ids = []string{}
}
return AdminTidyDryRunCandidate{
Action: string(c.Action),
SourceIDs: ids,
TargetID: c.TargetID,
PersonaID: c.PersonaID,
Scope: string(c.Scope),
ProjectID: c.ProjectID,
Kind: string(c.Kind),
ProposedContent: c.ProposedContent,
MergedContent: c.MergedContent,
Reason: c.Reason,
RuleVersion: c.RuleVersion,
ScoreMin: c.ScoreMin,
ScoreMax: c.ScoreMax,
Evidence: c.Evidence,
}
}
@@ -0,0 +1,236 @@
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)
}
}
+6
View File
@@ -165,4 +165,10 @@ type Deps struct {
// `*flowgraph.SQLStore`。可选;为 nil 时端点回退到只有内存的行为
// (turns 为空、turnId 只能命中内存里那一轮),内存与落盘都没接线才 500。
FlowGraphs FlowGraphStore
// MemoryTidy 是记忆整理「试算」的注入出口(契约 v0.75;切片 MT1)。组合根
// 注入的就是低峰心跳跑的那个 `*memory.TidyService` —— 同一套规则、同一份配置,
// 因此试算给出的建议与当晚那一轮同口径。试算自身**零副作用**(不写任何表)。
// 可选;为 nil(真 nil,不是带类型的 nil 指针)时端点回 500 `tidy_unavailable`,
// 绝不回空候选假装「没有建议」。
MemoryTidy MemoryTidyDryRunner
}
+5
View File
@@ -191,6 +191,11 @@ func (s *Server) registerAdminRoutes(
// 与声明默认值。只读、不审计、**不读 `.env`**、**不含任何凭据**;与消费它的面板页
// (记忆管理 / 后台思考)同门,故走 owner|admin 档而不是 viewer 监控档。
mux.Handle("GET /api/v1/admin/config-facts", writeAuth(http.HandlerFunc(s.adminConfigFacts)))
// 记忆整理「试算」(切片 MT1,**v0.75**):POST + writeAuth(owner|admin)。
// 它是**只读语义**——跑同一套整理规则把候选直接回给面板、一行都不写;因此
// 它不在 registerAdminWriteRoutes(那条路是 owner-only 的真实写入 + 审计)。
// 试算属于「算给我看」,不是「改给我看」。
mux.Handle("POST /api/v1/admin/memories/tidy/dry-run", writeAuth(http.HandlerFunc(s.adminMemoryTidyDryRun)))
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)))
+4
View File
@@ -106,6 +106,9 @@ type Server struct {
// flowGraphs 是**已落盘轮次图**的只读出口(契约 v0.73;见 Deps.FlowGraphs)。
// 为 nil 时该端点回退到只有内存的行为(turns 为空、turnId 只能命中内存那一轮)。
flowGraphs FlowGraphStore
// memoryTidy 是记忆整理「试算」的注入出口(契约 v0.75;见 Deps.MemoryTidy)。
// 为 nil 时 POST /api/v1/admin/memories/tidy/dry-run 回 500 tidy_unavailable。
memoryTidy MemoryTidyDryRunner
// envResolver 解析会话每轮的 loop 环境(其角色)。见
// persona_env.go / design/22 §4.5。
envResolver turnEnvResolver
@@ -160,6 +163,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
maxParallelTurns: deps.MaxParallelTurns,
flow: deps.Flow,
flowGraphs: deps.FlowGraphs,
memoryTidy: deps.MemoryTidy,
}
// 一个角色解析器由 Server 各处理器与 hub 共用,使每个面向 owner
// 的入口(REST、WS、语音)以同样方式解析会话角色(design/22 §4.5),