(a) 记忆入库前审查的两条 admin 端点(主人判「有必要就做」):
GET /api/v1/admin/memories/reviews?limit= (留痕读侧,不含记忆正文)
POST /api/v1/admin/memories/{id}/revert (回滚软合并,落审计 memory.review.revert)
两条 writeAuth(裁定 DECISIONS #10:留痕属私有面,不给 viewer)
(b) 语音用户可见面:DTO voice(含服务端派生 url)+ GET /api/v1/media/{name}
(200/206/304/416/HEAD、Accept-Ranges、非法名/穿越统一 404 media_not_found)
+ documents.Store.SaveAudio(内容寻址、$DATA_DIR 0600)+ history voice 列读写与引用计数差动
缺口(如实):message_persisted/握手回放仍不带 voice(文件面外,需给 history.Message 加字段);
AMR 需一行 VoiceTranscoder 注入(DECISIONS #11 已排)。4 个变异自检(含一条自认的等价变异)。
213 lines
9.5 KiB
Go
213 lines
9.5 KiB
Go
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),
|
||
}
|
||
}
|