- B5: POST /admin/rag/delete 真做(DeleteDocument→DeleteProject 级联删章节+索引清理, owner+审计, 200/404/500, 500降级兜底); users/iot scenarios+rules 诚实501(无用户表/IoT场景规则模型, 消息写明缺什么, 不伪造), 锁住 501+not_implemented
- D1: backthink/push_outlet.go PushExporter{Export}; pushOutlet 自建HTTP(2xx=送达, token仅Authorization头+error脱敏)优先→Getui兜底(未接入, 诚实日志'getui not integrated'+(false,nil)); backthink.Deps.Exporter → pusher.withExporter, Push 在WS/IM后 exportAsync(detached+5s超时, 失败仅日志, 不破坏现有投递, 限流不上报); config PushOutletConfig(PUSH_EXPORTER_* + GETUI_*占位)
- 前端 Rag.tsx/api.ts 删除后 reload + 类型; Docs/api-client v0.31 + api-reference(#23 owner/200-404, §8.4, 端点表PATCH/DELETE messages + pinned + messages?q=)
- 测试(push_outlet/admin_rag_delete/admin_honest501/knowledge store), 后端+前端build全绿+3平台交叉编译
- 刻意: Getui SDK 未接入(无SDK不硬造), 主人自建服务填 PUSH_EXPORTER_URL/TOKEN 即可
171 lines
6.5 KiB
Go
171 lines
6.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"
|
||
)
|
||
|
||
// Admin memory + knowledge-base write endpoints (owner-only, audited). The memory
|
||
// add writes a real item into the memory store (owner-private, safe), and the RAG
|
||
// index imports a document into the project index so it becomes searchable. Both
|
||
// degrade to a clear 501 when their backing store is not wired.
|
||
|
||
// memoryAddRequest is the POST /api/v1/admin/memories body.
|
||
type memoryAddRequest struct {
|
||
PersonaID string `json:"personaId"`
|
||
Content string `json:"content"`
|
||
Scope string `json:"scope"`
|
||
}
|
||
|
||
// adminAddMemory writes a single memory item for the owner. It is the manual
|
||
// "添加记忆" path: the item is scoped to the persona (default), so it behaves like
|
||
// the "记住 X" path the loop already writes. Scope may be omitted (default
|
||
// persona) or one of user|persona|project; an invalid non-empty scope is rejected.
|
||
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),
|
||
})
|
||
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 reports whether raw is one of the defined memory scopes.
|
||
func isValidMemoryScope(raw string) bool {
|
||
switch memory.Scope(strings.TrimSpace(raw)) {
|
||
case memory.ScopeUser, memory.ScopePersona, memory.ScopeProject:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// ragIndexRequest is the POST /api/v1/admin/rag body.
|
||
type ragIndexRequest struct {
|
||
Name string `json:"name"`
|
||
Desc string `json:"desc"`
|
||
Content string `json:"content"`
|
||
}
|
||
|
||
// adminIndexRag imports one knowledge document: it hands the name/description and
|
||
// content to the knowledge store, which chunks it and indexes it into the project
|
||
// index so the document becomes keyword/searchable. It is the admin "导入文档" path.
|
||
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 is the POST /api/v1/admin/rag/delete body.
|
||
type ragDeleteRequest struct {
|
||
ID string `json:"id"`
|
||
}
|
||
|
||
// adminDeleteRag removes a knowledge document by id: the store deletes the
|
||
// project-index entry and cascades its chapters. A missing document is answered
|
||
// with 404, a store that cannot delete with a clear 501, and any other failure
|
||
// with 500 — never a fabricated success. The route is owner-gated and audited.
|
||
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})
|
||
}
|