三处遗留(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(按任务口径)。
185 lines
7.5 KiB
Go
185 lines
7.5 KiB
Go
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})
|
||
}
|