Files
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

163 lines
7.5 KiB
Go
Raw Permalink 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"
"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", "记忆整理试算失败")
}
}