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", "记忆整理试算失败") } }