Files
AskaEth 1420e8aaa6
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): D3 审查读侧/revert 端点 + 语音用户可见面(L45)
(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 个变异自检(含一条自认的等价变异)。
2026-10-06 14:12:27 +08:00

213 lines
9.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
// 本文件承载**入库前审查(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),
}
}