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}) }