package gateway // 本文件承载**入库前审查(D3)的管理面**(design/23 §15 D3-8「面板可查」+ §8「回滚」; // 切片 MT-B 已把能力做进 `internal/runtime/memory`,本片补 HTTP 入口)。 // // 两条端点,分别对应 D3 留痕的**看**与**撤销**: // // - `GET /api/v1/admin/memories/reviews?limit=` —— 最近的审查留痕(谁被审、四态 // 判定、依据、合并去向、是否降级、时间)。留痕表 `memory_ingest_reviews` **不含 // 记忆正文**(列清单见 preinsert_schema.go),命中的既有 id 足以让面板回表取原文; // - `POST /api/v1/admin/memories/{id}/revert` —— 撤销一次**软合并**(把被吸收的行 // 复原为 active 并清空 merged_into,同时把它的 id 从存活行的 merged_from 里摘掉)。 // 行永不物理删除,因此这是**无损可逆**操作(design/23 §8)。 // // 门与审计:两条都走 `writeAuth`(owner|admin),与同族的 `GET /admin/memories` 同档 —— // 留痕带 itemId / personaId / projectId / sessionId,是**私有记忆面**的一部分, // design/23 §11 对同族读侧的既有取向是「含隐私正文的读侧不给 viewer,类比 // GET /admin/memories」;`viewer` 的定位是只读监控投影(design/11 §7),不是记忆面。 // 撤销是**写**,因此落审计(动作名 `memory.review.revert`,who/result 与既有口径一致)。 // // ⚠️ 与 design/23 §11 表格的一处口径差异:那张表把同族的 `candidates/{id}/revert` // 标为 owner-only。本片按任务书「两条都 writeAuth」实现 —— 若以 §11 为准,把 // routes.go 里那两行的 `writeAuth` 换成 `ownerAuth` 即可(一行一处,本文件不用改)。 import ( "context" "errors" "net/http" "strconv" "time" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" ) // ingestReviewStore 是入库前审查留痕的读写视图(design/23 §15 D3-8 + §8)。 // 生产实现是 `*memory.Store`(`ListIngestReviews` / `RevertMerged`)。 // // 与 voiceColumnReader 同一手法的可选接口:未实现它的存储(未被组合根接线的测试替身) // 得到的是**诚实**的 500 `memory_review_unavailable`,而不是一份空列表 —— // 「没接线」与「一条留痕都没有」必须能分辨。 type ingestReviewStore interface { // ListIngestReviews 返回最近的审查留痕(最新在前,至多 limit 条)。 ListIngestReviews(ctx context.Context, limit int) ([]memory.IngestReview, error) // RevertMerged 撤销一次软合并。该行不在 merged 状态时返回 memory.ErrNotFound。 RevertMerged(ctx context.Context, id string) error } const ( // memoryReviewDefaultLimit 是列表的默认页大小(未给/非正 limit)。 memoryReviewDefaultLimit = 100 // memoryReviewMaxLimit 是列表的硬上限:超出的 limit **钳到**它而不是报错 // (与渠道事件查询面同一约定)。 memoryReviewMaxLimit = 500 // memoryReviewUnavailableCode 是「留痕存储未接线」的线上错误码。两条端点共用: // 它们背后是同一份存储,未接线时不存在「一条能用一条不能用」。 memoryReviewUnavailableCode = "memory_review_unavailable" // memoryReviewRevertAction 是撤销动作的审计动作名(design/23 §16.4 的 `memory.*` // 族;`memory.review.ingest` 是审查自身的动作名,本动作名不与之冲突)。 memoryReviewRevertAction = "memory.review.revert" ) // AdminIngestReview 是一条审查留痕的线上结构(键名 camelCase,时间 UTC RFC3339, // 与同族的管理 DTO —— 渠道事件行 —— 同一风格)。**不含记忆正文**。 type AdminIngestReview struct { // ID 是留痕自身的 id(面板据此做去重/翻页键)。 ID string `json:"id"` // ItemID 是被审的那条记忆(命中的既有 id 由调用方回表取原文)。 ItemID string `json:"itemId"` // PersonaID / Scope / ProjectID 是这次审查发生的**分区**(§15 D3-7 只在同一分区内判定)。 PersonaID string `json:"personaId"` Scope string `json:"scope"` ProjectID string `json:"projectId,omitempty"` // Decision ∈ skip | merge | modify | create(四态闭集)。 Decision string `json:"decision"` // Reason 是判定依据(规则名,如完全等价/真包含/无相似)。 Reason string `json:"reason,omitempty"` // Veto 是命中的否决规则;`merge`/`modify` 时为空。它是**结构化依据**, // 面板据此聚合「为什么这一条没被合并」,而不是去解析 Reason 文案。 Veto string `json:"veto,omitempty"` // TargetID 是这次判定命中的**既有条目** id(create 时为空)。 TargetID string `json:"targetId,omitempty"` // MergedIDs 是被这次判定吸收(软合并)的行 id 列表;恒为非 null 数组。 MergedIDs []string `json:"mergedIds"` // Score 是候选召回命中的相似度;RecallMode 是这次召回的路径(语义/词项)。 Score float64 `json:"score"` RecallMode string `json:"recallMode,omitempty"` // Degraded 报告这次判定是否**降级**完成(embedder 不可用 ⇒ 回落到词项余弦)。 Degraded bool `json:"degraded"` // RuleVersion 是判定规则集版本(改规则必须同时升它,否则新旧留痕无法区分)。 RuleVersion string `json:"ruleVersion"` // SessionID 是完成这次审查的记忆管理会话(独立的 sess-memorytidy,不碰主会话)。 SessionID string `json:"sessionId,omitempty"` // CreatedAt 是审查时刻(UTC RFC3339)。 CreatedAt string `json:"createdAt"` } // adminMemoryReviews 处理 GET /api/v1/admin/memories/reviews。它只读、不审计 // (与同族只读管理端点同一档)。 func (s *Server) adminMemoryReviews(w http.ResponseWriter, r *http.Request) { store := s.ingestReviews() if store == nil { writeError(w, apperr.Wrap(apperr.ErrInternal, memoryReviewUnavailableCode, "入库前审查留痕未接线")) return } limit, ok := parseMemoryReviewLimit(r) if !ok { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit")) return } reviews, err := store.ListIngestReviews(r.Context(), limit) if err != nil { s.log.Error("list ingest reviews", "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "reviews_failed", "读取审查留痕失败")) return } out := make([]AdminIngestReview, 0, len(reviews)) for _, rev := range reviews { out = append(out, adminIngestReview(rev)) } writeJSON(w, http.StatusOK, out) } // adminMemoryRevert 处理 POST /api/v1/admin/memories/{id}/revert:撤销一次软合并。 // // 三个分支都如实作答:未接线 ⇒ 500;该行不在 merged 状态(id 拼错 / 已被回滚过) // ⇒ **404 not_found**(绝不假装回滚成功);成功 ⇒ 200 + 被回滚的 id,并落审计。 func (s *Server) adminMemoryRevert(w http.ResponseWriter, r *http.Request) { store := s.ingestReviews() if store == nil { s.auditClaims(r, memoryReviewRevertAction, "error") writeError(w, apperr.Wrap(apperr.ErrInternal, memoryReviewUnavailableCode, "入库前审查留痕未接线")) return } id := r.PathValue("id") if id == "" { s.auditClaims(r, memoryReviewRevertAction, "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "memory id is required")) return } if err := store.RevertMerged(r.Context(), id); err != nil { s.auditClaims(r, memoryReviewRevertAction, "error") if errors.Is(err, memory.ErrNotFound) { writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "记忆条目不在被合并状态,无法回滚")) return } s.log.Error("revert merged memory", "err", err, "item", id) writeError(w, apperr.Wrap(apperr.ErrInternal, "revert_failed", "回滚软合并失败")) return } s.auditClaims(r, memoryReviewRevertAction, "ok") writeJSON(w, http.StatusOK, map[string]any{"reverted": id}) } // ingestReviews 返回当前接入的审查留痕存储;`Memories` 未实现该视图时为 nil。 func (s *Server) ingestReviews() ingestReviewStore { if s.memories == nil { return nil } store, ok := s.memories.(ingestReviewStore) if !ok { return nil } return store } // parseMemoryReviewLimit 解析并归一 limit:缺省/非正 ⇒ 默认页大小;超硬上限 ⇒ 钳位; // 非整数 ⇒ ok=false(调用方回 400)。钳位而不是报错,与渠道事件查询面同一约定。 func parseMemoryReviewLimit(r *http.Request) (int, bool) { raw := r.URL.Query().Get("limit") if raw == "" { return memoryReviewDefaultLimit, true } v, err := strconv.Atoi(raw) if err != nil { return 0, false } if v <= 0 { return memoryReviewDefaultLimit, true } if v > memoryReviewMaxLimit { return memoryReviewMaxLimit, true } return v, true } // adminIngestReview 把一条领域留痕投影成线上结构。MergedIDs 兜成非 null 数组 // (面板按数组读),时间为 UTC RFC3339(与渠道事件行同口径)。 func adminIngestReview(rev memory.IngestReview) AdminIngestReview { ids := rev.MergedIDs if ids == nil { ids = []string{} } return AdminIngestReview{ ID: rev.ID, ItemID: rev.ItemID, PersonaID: rev.PersonaID, Scope: string(rev.Scope), ProjectID: rev.ProjectID, Decision: string(rev.Decision), Reason: rev.Reason, Veto: string(rev.Veto), TargetID: rev.TargetID, MergedIDs: ids, Score: rev.Score, RecallMode: rev.RecallMode, Degraded: rev.Degraded, RuleVersion: rev.RuleVer, SessionID: rev.SessionID, CreatedAt: rev.CreatedAt.UTC().Format(time.RFC3339), } }