【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 无该文件权限,由我按其代码语义补写)。
163 lines
7.5 KiB
Go
163 lines
7.5 KiB
Go
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", "记忆整理试算失败")
|
||
}
|
||
}
|