Files
Cyrene_v2/internal/services/gateway/admin_write_memory_rag.go
T
AskaEth fa5d3d23bf feat(gateway): 面板人工加记忆钉 long + skill 解析去回显 + 可观测读侧脱敏
三处遗留(M37/M38/M39)同落 internal/services/gateway:

① adminAddMemory 显式传 RetentionTier=long(主人 2026-10-26 裁定「人工动作
  默认 long」)。此前不传档位,面板典型输入(短寒暄/一次性祈使/时效查询/句首
  时点/进行中)会被 resolveRetention 判 temp 并写 7 天有效期 —— 主人在面板上
  亲手加的记忆会静默消失。只钉这一条入口,Store.Add 默认仍由规则判定
  (自动抽取路径不放宽)。

② adminInstallSkillPlugin 解析失败不再打 err.Error():新建
  admin_plugins_skill_error.go,把失败折叠成「类别(yaml_syntax /
  yaml_type_mismatch / skill_invalid / skill_source)+ 行列号」,日志与 400
  响应用同一份摘要。解码器会把出错值前 7 个字符嵌进错误串(M38 #17),
  原样日志等于把上传内容写进 viewer 可读的日志面。

③ 读侧脱敏走既有 helper,不另写一套:
  - adminLogOf 的 msg 过 trace.RedactText(与写侧同一函数);
  - adminSpanOf 先过 trace.SanitizeRecord(REST 尾部与 observability_trace
    推送帧在同一处收口)。⚠️ 有意改变 admin 响应内容:props 变白名单投影 +
    omitted 摘要;字段名与帧形状不变。行数/级别/时间戳/来源、span 的
    status/耗时/时间/名字一律保住。

测试(每条都验过「删掉实现 ⇒ FAIL」):
  - 面板加记忆 ⇒ 落库 (retention_tier='long', expires_at='');对照组证明同一句
    内容走 Store.Add 仍判 temp;
  - 非法 YAML 含哨兵 ⇒ 响应与日志均无哨兵、都有类别与 line 2(内联/文件/语法/
    来源四类);
  - 日志页 msg 含 sk- 形状凭据 ⇒ [redacted],level/source/time 与非敏感正文保留;
  - trace 响应 props 正文 ⇒ omitted Digest、args_summary 打码,排障字段原样。

验收:go build ./... / go vet ./... 全绿;go test -count=1 -race
./internal/services/gateway/... 全绿。未 push(按任务口径)。
2026-10-26 13:53:59 +08:00

185 lines
7.5 KiB
Go
Raw 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
import (
"encoding/json"
"errors"
"net/http"
"strings"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 管理侧记忆 + 知识库写端点(仅 owner,带审计)。添加记忆
// 会把真实条目写入记忆存储(owner 私有,安全),而 RAG 索引
// 把文档导入项目索引使其可被检索。二者在各自
// 后端存储未接线时都退化为明确的 501。
// memoryAddRequest 是 POST /api/v1/admin/memories 的请求体。
type memoryAddRequest struct {
PersonaID string `json:"personaId"`
Content string `json:"content"`
Scope string `json:"scope"`
}
// adminAddMemory 为主人写入单条记忆。它是手动的
// 「添加记忆」路径:条目默认作用于 persona,因此行为与
// loop 已在写的「记住 X」路径一致。Scope 可省略(默认
// persona),或为 user|persona|project 之一;非法非空 scope 会被拒绝。
//
// 🔴 档位显式钉 `long`(主人 2026-10-26 裁定:「人工动作默认应为 `long`(人显式
// 加一条记忆 = 长期意图,不该被 7 天后静默清掉)」)。不传档位时 `memory.Store.Add`
// 会走 `resolveRetention` 的留存规则(N1~N7),面板最典型的输入恰恰落在反例里
// —— 短寒暄(「谢谢」)、一次性祈使、时效查询、句首时点、进行中状态都会被判
// `temp` 并写 7 天有效期,于是主人在面板上亲手加的一条记忆会静默消失。
// `resolveRetention` 对**显式档位**的处置是「以调用方为准」,因此这一位是真正的
// 钉死而不是提示。M37 已在包内钉过 `Store.RememberManual` 与
// `ManualExtractor.Extract` 两条入口;本处是同一裁定在**面板**这条入口上的落点。
//
// 只钉这一条入口:`Store.Add` 的默认值仍是「未分型 ⇒ 由规则判定」,自动抽取路径
// 绝不放宽(M37 复审口径)。
func (s *Server) adminAddMemory(w http.ResponseWriter, r *http.Request) {
var req memoryAddRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
personaID := strings.TrimSpace(req.PersonaID)
if personaID == "" {
personaID = "cyrene"
}
content := strings.TrimSpace(req.Content)
if content == "" {
s.auditClaims(r, "memory.add", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "content is required"))
return
}
rawScope := strings.TrimSpace(req.Scope)
if rawScope != "" && !isValidMemoryScope(rawScope) {
s.auditClaims(r, "memory.add", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "scope must be one of user|persona|project"))
return
}
store, ok := s.memories.(*memory.Store)
if !ok {
s.auditClaims(r, "memory.add", "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "记忆存储未配置(未注入 memory.Store)", HTTPCode: http.StatusNotImplemented})
return
}
stored, err := store.Add(r.Context(), memory.MemoryItem{
PersonaID: personaID,
Content: content,
Scope: memory.Scope(rawScope),
// 人工动作 = 长期意图,见上方注释:显式档位优先于留存规则。
RetentionTier: memory.RetentionLong,
})
if err != nil {
s.auditClaims(r, "memory.add", "error")
s.log.Error("memory add", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "memory_add_failed", "failed to add memory"))
return
}
s.auditClaims(r, "memory.add", "ok")
writeJSON(w, http.StatusCreated, map[string]any{"id": stored.ID, "scope": string(stored.Scope), "text": stored.Content})
}
// isValidMemoryScope 报告 raw 是否是已定义的记忆作用域之一。
func isValidMemoryScope(raw string) bool {
switch memory.Scope(strings.TrimSpace(raw)) {
case memory.ScopeUser, memory.ScopePersona, memory.ScopeProject:
return true
default:
return false
}
}
// ragIndexRequest 是 POST /api/v1/admin/rag 的请求体。
type ragIndexRequest struct {
Name string `json:"name"`
Desc string `json:"desc"`
Content string `json:"content"`
}
// adminIndexRag 导入一份知识文档:它把 name/description 与
// content 交给知识存储,后者切分它并索引进项目
// 索引,使文档可被关键词检索。它是管理侧的「导入文档」路径。
func (s *Server) adminIndexRag(w http.ResponseWriter, r *http.Request) {
var req ragIndexRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
s.auditClaims(r, "rag.index", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
return
}
content := strings.TrimSpace(req.Content)
if content == "" {
s.auditClaims(r, "rag.index", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "content is required"))
return
}
if s.rag == nil {
s.auditClaims(r, "rag.index", "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "知识库 RAG 服务未配置(未注入 RagStore)", HTTPCode: http.StatusNotImplemented})
return
}
if err := s.rag.IndexDocument(r.Context(), knowledge.Doc{
Name: name,
Desc: strings.TrimSpace(req.Desc),
Content: content,
}); err != nil {
s.auditClaims(r, "rag.index", "error")
s.log.Error("rag index", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "rag_index_failed", "failed to index document"))
return
}
s.auditClaims(r, "rag.index", "ok")
writeJSON(w, http.StatusCreated, map[string]any{"name": name})
}
// ragDeleteRequest 是 POST /api/v1/admin/rag/delete 的请求体。
type ragDeleteRequest struct {
ID string `json:"id"`
}
// adminDeleteRag 按 id 移除一份知识文档:存储删除
// 项目索引条目并级联其章节。文档缺失时返回 404,
// 存储无法删除时返回明确的 501,其他失败
// 返回 500 —— 绝不编造成功。该路由受 owner 门控且带审计。
func (s *Server) adminDeleteRag(w http.ResponseWriter, r *http.Request) {
if s.rag == nil {
s.auditClaims(r, "rag.delete", "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "知识库 RAG 服务未配置(未注入 RagStore)", HTTPCode: http.StatusNotImplemented})
return
}
var req ragDeleteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
id := strings.TrimSpace(req.ID)
if id == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id is required"))
return
}
if err := s.rag.DeleteDocument(r.Context(), id); err != nil {
s.auditClaims(r, "rag.delete", "error")
switch {
case errors.Is(err, knowledge.ErrDocumentNotFound):
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "知识库文档 %q 不存在", id))
case errors.Is(err, knowledge.ErrWriteUnsupported):
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "该知识库存储不支持删除", HTTPCode: http.StatusNotImplemented})
default:
s.log.Error("rag delete", "id", id, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "rag_delete_failed", "failed to delete knowledge document"))
}
return
}
s.auditClaims(r, "rag.delete", "ok")
writeJSON(w, http.StatusOK, map[string]any{"id": id, "deleted": true})
}