M27 体检(DevDocs/audit/记忆库价值体检-2026-10-24.md)两条根治项:
1) 新增 `dedupe_group` 候选动作(不放大 containNone):
- 簇只认「同 scope+kind+完全相同 tag 集」或「同 scope+kind+前 12 字相同」
(后者附加词项余弦 ≥0.70 与同 kind 收窄);
- 每簇保留超集:内容最长 > merge_count 大 > created_at 早 > id;
- 安全门与既有合并路径同源:受限来源/绝对时刻/时效状态/低置信一律不进簇,
数值与否定与存活行不一致的成员被逐条剔出;临时层行(expires_at 非空)
两个方向都不进簇(存活行被清扫会给被吸收行留悬空 merged_into);
- 已被 archive/merge 候选覆盖的条目不重复出第二条待办(抑制);
- 落库形态与 merge 同构:source_ids 多条 + target_id,应用为整簇原子的软合并,
回滚复用 revertTidyMerge(逐条复原);
- 批量应用按**行**计上限 200 行/批(maxTidyApplyRowsPerBatch),超限如实 truncated;
- 不进自动档(人工/批量点);试算(DryRun)零写库给出簇数与可减少行数。
2) 留存闸改组合口径(治根)+ N6/N7:
- 护栏②从「含习惯/约定词就一票放行」改为「锚点词 **且** 含具体约束
(数字/禁忌/频次规律)」;无约束的锚点交给更窄的规则判;
- N1/N3/N4/N5 这几条词项规则改为「无锚点时才开火」(`主人喜欢说晚安`
这类偏好不再因寒暄词被判临时);
- 新增 N6(指示性时点,句首前 12 字内)与 N7(进行中状态),安全阀:习惯词、
硬性长期事实词、数值/约束、寒暄/情绪词一律按回去;
- 命中即第五态 defer,`expires_at = 判定时刻 + tempTtlSec`(默认 7 天,
已落在 $DATA_DIR/config/memory-tidy.json,零新 .env 键);**只开新增侧**,
存量一条不补写(用例钉住)。
契约(加性,不改既有字段语义):tidy dry-run/run 回执加 dedupeGroups /
dedupeRowsReducible / dedupeTruncated;批量应用回执加 deduped / rowsAffected。
实测(生产库只读 mode=ro,active 3667):
- 闸:改前 2 条(0.05%) → 改后 89 条(2.43%):n6 58 / n7 29 / n1 2;
89 条里 30 条含锚点词,逐条人读均为「约定明天中午见」「约定下周留个午后」
这类到期即失效的安排(唯一可议:1 条把「中午互动的日常」一起带进临时层)。
- dedupe_group:281 簇 / 可减 594 行(16.2% of active);tag_set 265 / prefix12 16;
簇大小 2:170 3:48 4:27 5:10 6:10 7:7 8:2 9:2 10:1。
验收:go build ./... + go vet ./... + go test ./... 全绿。
343 lines
15 KiB
Go
343 lines
15 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"net/http"
|
||
"strconv"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||
)
|
||
|
||
// 本文件承载记忆整理**应用路径**的管理面(切片 TIDY-1;design/23 §7/§8/§11)。
|
||
// 动因是主人的实测口径:整理只产候选、两天一条没应用(77 条全 pending),而现有
|
||
// `POST …/tidy/dry-run` 是**只读试算**,改不了任何东西。
|
||
//
|
||
// 端点分工(DTO 在 admin_memory_tidy_apply_dto.go,手动真跑在
|
||
// admin_memory_tidy_run.go —— 拆开只为守 AGENTS §10 的文件行数上限):
|
||
//
|
||
// GET /api/v1/admin/memories/candidates 候选列表(含 id/runId/证据)
|
||
// POST /api/v1/admin/memories/candidates/{id}/apply 按候选 id 应用(写记忆)
|
||
// POST /api/v1/admin/memories/candidates/apply?runId=&limit= 批量应用(按 run / 全部待定)
|
||
// POST /api/v1/admin/memories/candidates/{id}/revert 回滚一条已应用候选
|
||
// POST /api/v1/admin/memories/tidy/run 手动**真跑**一轮(写候选)
|
||
//
|
||
// 口径:
|
||
//
|
||
// 1. **门同族**:全部走 `writeAuth`(owner|admin),与同族的 `GET /admin/memories`、
|
||
// `POST …/tidy/dry-run`、`POST …/memories/{id}/revert` 同档 —— 候选正文含隐私,
|
||
// design/23 §11 的既有取向是「含隐私正文的读侧不给 viewer」。⚠️ §11 表格把
|
||
// candidates 写回滚标为 owner-only:那只需把 routes.go 里对应行的 `writeAuth`
|
||
// 换成 `ownerAuth`(一行一处,本文件不用改)。
|
||
// 2. **写动作落审计**:动作名 `memory.tidy.apply` / `memory.tidy.revert`(design/23
|
||
// §8 的 `memory.tidy.*` 族),who 来自请求身份;审计只带计数/动作,**不带记忆正文**。
|
||
// `POST …/tidy/run` 的审计由**领域侧**(TidyService 的 Audit 出口,design §8 的
|
||
// `memory.tidy.run`,result 里带 trigger=manual + run_id + 计数)写,本文件不重复记
|
||
// —— 一次手动点击只该有一条 memory.tidy.run。
|
||
// 3. **诚实失败**:未接线 ⇒ 500 `tidy_unavailable`;候选不存在 ⇒ 404;
|
||
// runId 查不到 ⇒ 404 `tidy_run_not_found`;配置关闭 ⇒ 409 `tidy_disabled`;
|
||
// 超时 ⇒ 503 `tidy_timeout`。**绝不**把「没做」回成一份空结果。
|
||
// 4. **不写第二套语义**:应用/回滚的真实动作全在 `memory.Store` 里(候选状态条件更新
|
||
// 是幂等闸),端点只做鉴权、取参、投影与审计。
|
||
// 5. **不加任何上限**:批量上限由领域侧(memory.DefaultTidyApplyBatchLimit 等代码
|
||
// 默认值)决定,端点只解析 `limit` 并交给领域侧钳位(`.env` 冻结,不新增键)。
|
||
|
||
// MemoryTidyRunner 是记忆整理「真跑」的注入出口(切片 TIDY-1)。组合根注入的
|
||
// 就是低峰心跳跑的那个 `*memory.TidyService`(与试算同一份配置、同一套规则)。
|
||
//
|
||
// 它与 `MemoryTidyDryRunner` 分开两个接口是**刻意**的:试算只读、真跑写候选 + 留痕,
|
||
// 两者可分别接线;未实现本接口的替身(例如只做试算的测试桩)得到的是诚实的
|
||
// 「真跑未接线」,而不是一条静默成功的假回执。
|
||
type MemoryTidyRunner interface {
|
||
// ManualRun 立即跑一轮**真跑**(写候选 + 写运行留痕 + 审计)。配置关闭时
|
||
// 返回 memory.ErrTidyDisabled。
|
||
ManualRun(ctx context.Context) (memory.TidyStats, error)
|
||
}
|
||
|
||
// tidyApplyStore 是整理候选写面的窄视图,由 `*memory.Store` 实现。未实现它的
|
||
// `Deps.Memories` 让本组端点回 500 `tidy_unavailable`(「没接线」与「没有候选」
|
||
// 必须能分辨,与同族的 ingestReviewStore 同一手法)。
|
||
type tidyApplyStore interface {
|
||
ListTidyCandidates(ctx context.Context, status string, limit int) ([]memory.TidyCandidate, error)
|
||
ApplyTidyCandidate(ctx context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error)
|
||
ApplyTidyCandidates(ctx context.Context, runID string, opts memory.TidyApplyOptions) (memory.TidyApplyBatch, error)
|
||
RevertTidyCandidate(ctx context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error)
|
||
RejectTidyCandidatesByAction(ctx context.Context, action memory.TidyAction, opts memory.TidyApplyOptions) (memory.TidyRejectBatch, error)
|
||
}
|
||
|
||
const (
|
||
// tidyUnavailableCode 是「整理写面未接线」的线上错误码(与试算组的
|
||
// tidy_unavailable 同名同义:它们背后是同一件事)。
|
||
tidyUnavailableCode = "tidy_unavailable"
|
||
// tidyApplyAction 是应用动作的审计动作名(design/23 §8)。
|
||
tidyApplyAction = "memory.tidy.apply"
|
||
// tidyRevertAction 是候选回滚动作的审计动作名。它与 D3 的
|
||
// `memory.review.revert`(按**记忆 id**撤销一次软合并)**不同**:本条按**候选 id**
|
||
// 回滚,并把候选退回 pending,因此不复用。
|
||
tidyRevertAction = "memory.tidy.revert"
|
||
// tidyRejectAction 是候选**批量拒绝**的审计动作名(design/23 §8 的 `memory.tidy.*`
|
||
// 族)。它与「拒绝」在设计里的定位一致:`reject` 记入审计,用于后续调参与防重复提议。
|
||
tidyRejectAction = "memory.tidy.reject"
|
||
// tidyRunTimeout 是手动真跑的墙钟上限。真跑要写候选 + 留痕,比试算多几次写入;
|
||
// 上限存在的意义是「不让卡住的请求占着连接」,超时**如实报错**(503 tidy_timeout)。
|
||
tidyRunTimeout = 5 * time.Minute
|
||
)
|
||
|
||
// adminTidyCandidatesList 处理 GET /api/v1/admin/memories/candidates。
|
||
// 只读、不审计(与同族只读管理端点同一档)。
|
||
func (s *Server) adminTidyCandidatesList(w http.ResponseWriter, r *http.Request) {
|
||
store := s.tidyCandidates()
|
||
if store == nil {
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线"))
|
||
return
|
||
}
|
||
// limit 复用同族读侧那一个解析器(默认 100 / 钳到 500 / 非法 400)——
|
||
// 本切片不复制第三份 limit 解析(AGENTS §10「三次复制必须抽取」,此处是第二次)。
|
||
limit, ok := parseMemoryReviewLimit(r)
|
||
if !ok {
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit"))
|
||
return
|
||
}
|
||
cands, err := store.ListTidyCandidates(r.Context(), r.URL.Query().Get("status"), limit)
|
||
if err != nil {
|
||
s.log.Error("list tidy candidates", "err", err)
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_candidates_failed", "读取整理候选失败"))
|
||
return
|
||
}
|
||
out := AdminTidyCandidatesResponse{
|
||
Candidates: make([]AdminTidyCandidate, 0, len(cands)),
|
||
Count: len(cands),
|
||
}
|
||
for _, c := range cands {
|
||
out.Candidates = append(out.Candidates, adminTidyCandidate(c))
|
||
}
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
// adminTidyCandidateApply 处理 POST /api/v1/admin/memories/candidates/{id}/apply:
|
||
// 应用**一条**候选(唯一真正改记忆的粒度之一)。三种结果都如实作答:
|
||
// 不存在 ⇒ 404;已决定/不可应用 ⇒ 200 + `status:skipped`(幂等不是错误);
|
||
// 真的改了 ⇒ 200 + `status:applied` + `affectedIds`。
|
||
func (s *Server) adminTidyCandidateApply(w http.ResponseWriter, r *http.Request) {
|
||
store := s.tidyCandidates()
|
||
if store == nil {
|
||
s.auditClaims(r, tidyApplyAction, "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线"))
|
||
return
|
||
}
|
||
id := r.PathValue("id")
|
||
if id == "" {
|
||
s.auditClaims(r, tidyApplyAction, "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "candidate id is required"))
|
||
return
|
||
}
|
||
res, err := store.ApplyTidyCandidate(r.Context(), id, memory.TidyApplyOptions{Actor: s.tidyActor(r)})
|
||
if err != nil {
|
||
s.auditClaims(r, tidyApplyAction, "error")
|
||
if errors.Is(err, memory.ErrTidyCandidateNotFound) {
|
||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "整理候选 %q 不存在", id))
|
||
return
|
||
}
|
||
s.log.Error("apply tidy candidate", "err", err, "candidate", id)
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_apply_failed", "应用整理候选失败"))
|
||
return
|
||
}
|
||
s.auditClaims(r, tidyApplyAction, tidyApplyAuditResult(res))
|
||
writeJSON(w, http.StatusOK, adminTidyApplyResult(res))
|
||
}
|
||
|
||
// adminTidyCandidatesApplyBatch 处理 POST /api/v1/admin/memories/candidates/apply:
|
||
// **批量**应用。`runId` 给出时只应用该轮产出的候选(按 run);省略时应用全部待定
|
||
// (「全部待定应用」这一档)。`limit` 省略 ⇒ 领域侧默认值。
|
||
//
|
||
// 逐条结果都在 `results` 里(失败隔离),超限如实回报 `truncated` + `total`。
|
||
func (s *Server) adminTidyCandidatesApplyBatch(w http.ResponseWriter, r *http.Request) {
|
||
store := s.tidyCandidates()
|
||
if store == nil {
|
||
s.auditClaims(r, tidyApplyAction, "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线"))
|
||
return
|
||
}
|
||
limit, ok := parseTidyApplyLimit(r)
|
||
if !ok {
|
||
s.auditClaims(r, tidyApplyAction, "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit"))
|
||
return
|
||
}
|
||
runID := r.URL.Query().Get("runId")
|
||
batch, err := store.ApplyTidyCandidates(r.Context(), runID, memory.TidyApplyOptions{
|
||
Actor: s.tidyActor(r),
|
||
Limit: limit,
|
||
})
|
||
if err != nil {
|
||
s.auditClaims(r, tidyApplyAction, "error")
|
||
if errors.Is(err, memory.ErrTidyRunRecordNotFound) {
|
||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "tidy_run_not_found", "整理运行 %q 不存在", runID))
|
||
return
|
||
}
|
||
s.log.Error("apply tidy candidates", "err", err, "run_id", runID)
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_apply_failed", "批量应用整理候选失败"))
|
||
return
|
||
}
|
||
s.auditClaims(r, tidyApplyAction, tidyBatchAuditResult(batch))
|
||
writeJSON(w, http.StatusOK, adminTidyApplyBatch(batch))
|
||
}
|
||
|
||
// adminTidyCandidateRevert 处理 POST /api/v1/admin/memories/candidates/{id}/revert:
|
||
// 回滚一条已应用候选(记忆行复原 + 候选退回 pending,行永不物理删除,design §8)。
|
||
func (s *Server) adminTidyCandidateRevert(w http.ResponseWriter, r *http.Request) {
|
||
store := s.tidyCandidates()
|
||
if store == nil {
|
||
s.auditClaims(r, tidyRevertAction, "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线"))
|
||
return
|
||
}
|
||
id := r.PathValue("id")
|
||
if id == "" {
|
||
s.auditClaims(r, tidyRevertAction, "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "candidate id is required"))
|
||
return
|
||
}
|
||
res, err := store.RevertTidyCandidate(r.Context(), id, memory.TidyApplyOptions{Actor: s.tidyActor(r)})
|
||
if err != nil {
|
||
s.auditClaims(r, tidyRevertAction, "error")
|
||
if errors.Is(err, memory.ErrTidyCandidateNotFound) {
|
||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "整理候选 %q 不存在", id))
|
||
return
|
||
}
|
||
s.log.Error("revert tidy candidate", "err", err, "candidate", id)
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_revert_failed", "回滚整理候选失败"))
|
||
return
|
||
}
|
||
s.auditClaims(r, tidyRevertAction, tidyApplyAuditResult(res))
|
||
writeJSON(w, http.StatusOK, adminTidyApplyResult(res))
|
||
}
|
||
|
||
// tidyCandidates 返回当前接入的整理候选写面;`Memories` 未实现该视图时为 nil。
|
||
func (s *Server) tidyCandidates() tidyApplyStore {
|
||
if s.memories == nil {
|
||
return nil
|
||
}
|
||
store, ok := s.memories.(tidyApplyStore)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
return store
|
||
}
|
||
|
||
// tidyActor 返回决定者的审计标签(落 memory_tidy_candidates.decided_by)。
|
||
// 与 auditClaimsAccount 的 who 同口径,使面板「谁批的」与审计链能对上。
|
||
func (s *Server) tidyActor(r *http.Request) string {
|
||
if claims, ok := auth.ClaimsFromContext(r.Context()); ok {
|
||
return claims.Sub + ":" + string(claims.Role)
|
||
}
|
||
return "anonymous"
|
||
}
|
||
|
||
// parseTidyApplyLimit 解析批量的 limit:缺省/空 ⇒ 0(交给领域侧默认值),
|
||
// 非整数或负数 ⇒ ok=false(调用方回 400)。上限由**领域侧**钳位,端点不复制那个常量。
|
||
func parseTidyApplyLimit(r *http.Request) (int, bool) {
|
||
raw := r.URL.Query().Get("limit")
|
||
if raw == "" {
|
||
return 0, true
|
||
}
|
||
v, err := strconv.Atoi(raw)
|
||
if err != nil || v < 0 {
|
||
return 0, false
|
||
}
|
||
return v, true
|
||
}
|
||
|
||
// adminTidyCandidate 投影一条落库候选。SourceIDs 兜成非 null 数组;
|
||
// 时间为 UTC RFC3339(与同族管理 DTO 同一口径)。
|
||
func adminTidyCandidate(c memory.TidyCandidate) AdminTidyCandidate {
|
||
ids := c.SourceIDs
|
||
if ids == nil {
|
||
ids = []string{}
|
||
}
|
||
return AdminTidyCandidate{
|
||
ID: c.ID,
|
||
Action: string(c.Action),
|
||
Status: c.Status,
|
||
SourceIDs: ids,
|
||
TargetID: c.TargetID,
|
||
PersonaID: c.PersonaID,
|
||
Scope: string(c.Scope),
|
||
ProjectID: c.ProjectID,
|
||
Kind: string(c.Kind),
|
||
ProposedContent: c.ProposedContent,
|
||
MergedContent: c.MergedContent,
|
||
Reason: c.Reason,
|
||
RuleVersion: c.RuleVersion,
|
||
RunID: c.RunID,
|
||
ScoreMin: c.ScoreMin,
|
||
ScoreMax: c.ScoreMax,
|
||
CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339),
|
||
Evidence: c.Evidence,
|
||
}
|
||
}
|
||
|
||
// adminTidyApplyResult 投影一条应用/回滚结果。
|
||
func adminTidyApplyResult(res memory.TidyApplyResult) AdminTidyApplyResult {
|
||
ids := res.AffectedIDs
|
||
if ids == nil {
|
||
ids = []string{}
|
||
}
|
||
return AdminTidyApplyResult{
|
||
CandidateID: res.CandidateID,
|
||
Action: string(res.Action),
|
||
Status: string(res.Status),
|
||
Reason: res.Reason,
|
||
TargetID: res.TargetID,
|
||
AffectedIDs: ids,
|
||
}
|
||
}
|
||
|
||
// adminTidyApplyBatch 投影批量回执(results 恒为非 null 数组)。
|
||
func adminTidyApplyBatch(batch memory.TidyApplyBatch) AdminTidyApplyBatchResponse {
|
||
out := AdminTidyApplyBatchResponse{
|
||
RunID: batch.RunID,
|
||
Limit: batch.Limit,
|
||
Total: batch.Total,
|
||
Truncated: batch.Truncated,
|
||
Applied: batch.Applied,
|
||
Skipped: batch.Skipped,
|
||
Failed: batch.Failed,
|
||
Merged: batch.Merged,
|
||
Archived: batch.Archived,
|
||
Reclassified: batch.Reclassified,
|
||
Deduped: batch.Deduped,
|
||
RowsAffected: batch.RowsAffected,
|
||
Results: make([]AdminTidyApplyResult, 0, len(batch.Results)),
|
||
}
|
||
for _, res := range batch.Results {
|
||
out.Results = append(out.Results, adminTidyApplyResult(res))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// tidyApplyAuditResult 渲染单条应用/回滚的审计 result。它**以 ok 开头**(与
|
||
// 同族「result=ok/error」的既有口径一致,面板按前缀就能分桶),其后才挂动作与
|
||
// 结果类别、受影响行数 —— 绝不带记忆正文或候选正文(design §8)。
|
||
func tidyApplyAuditResult(res memory.TidyApplyResult) string {
|
||
return "ok action=" + string(res.Action) +
|
||
" status=" + string(res.Status) +
|
||
" reason=" + res.Reason +
|
||
" affected=" + strconv.Itoa(len(res.AffectedIDs))
|
||
}
|
||
|
||
// tidyBatchAuditResult 渲染批量应用的审计 result(同样以 ok 开头):带真总数与
|
||
// 超限标记,因此审计能回答「这一轮到底批了多少、有没有被上限截住」。
|
||
func tidyBatchAuditResult(batch memory.TidyApplyBatch) string {
|
||
return "ok run_id=" + batch.RunID +
|
||
" limit=" + strconv.Itoa(batch.Limit) +
|
||
" total=" + strconv.Itoa(batch.Total) +
|
||
" truncated=" + strconv.FormatBool(batch.Truncated) +
|
||
" applied=" + strconv.Itoa(batch.Applied) +
|
||
" skipped=" + strconv.Itoa(batch.Skipped) +
|
||
" failed=" + strconv.Itoa(batch.Failed)
|
||
}
|