feat: RAG 知识库真实数据 + 渠道端到端接线(并行∦wire.go)+ 主会话收口
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

【渠道端到端·接线(agent-72)】
- 新建 internal/services/channelbridge/:桥接服务(Start 对配置渠道 Connect(10s超时/warn)+SetMessageHandler+ReadMessages;
  入站 UnifiedMessage→sessions.GetOrCreate(ResolveActor 白名单结构性识别)→loop.Run→SendMessage 回执,chat 文本)。
- cmd/cyrene/channels.go:从 CHANNEL_* env 读 4 渠道凭据(telegram/qq_obv11/weixin_ilink/webhook)。
- wire.go:app 加 channelBridge 字段,wireChannels 构造适配器+bridge.Start(),close() 收尾。
- 无真凭据降级:无 CHANNEL_* → configured()false 跳过不 dial;Connect 失败 Warn 不阻启动;
  webhook 仅入站(回执 ErrUnsupported 记 debug)。身份识别绝不靠提示词。收发端到端待凭据验证。

【RAG 知识库(agent-71)】
- internal/runtime/knowledge/:真实数据源(doc=资料/记忆=经历 分离 design/03 §3③)。
  ListDocuments 走 projectindex.ListProjects(+ListChapters)、Search 走 projectindex.Search(标题/描述+章节
  命中行+上下文)+retriever.Retrieve(配 vector 真语义,命中标 semantic:true)。
- gateway/server.go:RagStore 接口+Deps.RAG 可空+3 路由(GET /admin/rag、POST /admin/rag/query、/delete)。
- admin.go:adminListRag/adminSearchRag(空 query 400)/adminDeleteRag(写 501 not_implemented,M10)。
- projectindex/store.go:ListProjects/ListChapters。
- 前端 Rag.tsx:docs 从 GET /admin/rag 拉、检索走 POST /admin/rag/query、删除调真端点(501 显示 Notice),
  上传/分块/嵌入显示 M10 Notice;api.ts/types.ts 加 adminListRag/adminSearchRag/adminDeleteRag。

【主会话收口 wire.go】projStore 提到 app struct+wireMemory 赋值;knowledge.New(...) 注入 gateway.Deps.RAG。

实机验证:admin/rag 200(documents[] 空表非壳)、channels 枚举 4 渠道、health ok。
go build/vet/test 全绿 + 前端 tsc。wire.go 567 行。

遗留(记录):① 群聊/陌生人 MemWritable 记忆隔离未接 loop(AGENTS.md 红线,待修) ② webhook InboundHandler
需 gateway 挂载 ③ RAG 语义路径 persona-scoped(project 记忆其他 persona 不浮现)。
This commit is contained in:
Aska committed 2026-09-06 20:09:09 +08:00
1 parent 518bd0a230
commit dbcf3df8a3
13 files changed
+1510 -104

No files matched your search

+146
View File
@@ -6,11 +6,13 @@ import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
@@ -251,6 +253,150 @@ func (s *Server) adminListBackthink(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// AdminRagDoc is the wire shape of one knowledge-base document (project-index
// entry) in the admin RAG list.
type AdminRagDoc struct {
ID string `json:"id"`
Name string `json:"name"`
Desc string `json:"desc,omitempty"`
Chunks int `json:"chunks"`
Embedded bool `json:"embedded"`
}
// AdminRagHit is the wire shape of one RAG retrieval hit.
type AdminRagHit struct {
DocID string `json:"docId"`
DocName string `json:"docName"`
Chapter string `json:"chapter,omitempty"`
Text string `json:"text"`
Score float64 `json:"score"`
Semantic bool `json:"semantic"`
}
// AdminRagResponse is the wire shape of GET /api/v1/admin/rag. Hits is present
// only when a query parameter was supplied, so callers distinguish "no search
// requested" from "searched and found nothing".
type AdminRagResponse struct {
Documents []AdminRagDoc `json:"documents"`
Hits []AdminRagHit `json:"hits,omitempty"`
}
// ragQueryRequest is the POST /api/v1/admin/rag/query body.
type ragQueryRequest struct {
Query string `json:"query"`
}
// ragDeleteRequest is the POST /api/v1/admin/rag/delete body.
type ragDeleteRequest struct {
ID string `json:"id"`
}
// adminListRag lists the knowledge-base documents and, when ?query= is present,
// returns the matching hits too. Without a wired RagStore it degrades to an empty
// list (backward compatible — never a 500).
func (s *Server) adminListRag(w http.ResponseWriter, r *http.Request) {
out := AdminRagResponse{Documents: make([]AdminRagDoc, 0)}
if s.rag == nil {
writeJSON(w, http.StatusOK, out)
return
}
docs, err := s.rag.ListDocuments(r.Context())
if err != nil {
s.log.Error("list rag", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "rag_failed", "failed to list knowledge documents"))
return
}
out.Documents = ragDocsFrom(docs)
if query := strings.TrimSpace(r.URL.Query().Get("query")); query != "" {
hits, herr := s.rag.Search(r.Context(), query)
if herr != nil {
s.log.Error("search rag", "err", herr)
writeError(w, apperr.Wrap(apperr.ErrInternal, "rag_search_failed", "failed to search knowledge base"))
return
}
out.Hits = ragHitsFrom(hits)
}
writeJSON(w, http.StatusOK, out)
}
// adminSearchRag runs a knowledge-base query and returns the matching hits.
func (s *Server) adminSearchRag(w http.ResponseWriter, r *http.Request) {
if s.rag == nil {
s.auditClaims(r, "rag.query", "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "知识库 RAG 服务未配置(未注入 RagStore)", HTTPCode: http.StatusNotImplemented})
return
}
var req ragQueryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Query) == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "query is required"))
return
}
hits, err := s.rag.Search(r.Context(), req.Query)
if err != nil {
s.log.Error("search rag", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "rag_search_failed", "failed to search knowledge base"))
return
}
s.auditClaims(r, "rag.query", "ok")
writeJSON(w, http.StatusOK, map[string]any{"query": req.Query, "hits": ragHitsFrom(hits)})
}
// adminDeleteRag answers the document delete honestly: write operations
// (upload/chunk/embed/delete) are milestone M10, so it returns a clear 501 rather
// than a fabricated success. The route is audited as an admin write.
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
}
if strings.TrimSpace(req.ID) == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id is required"))
return
}
if err := s.rag.DeleteDocument(r.Context(), req.ID); err != nil {
s.auditClaims(r, "rag.delete", "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "知识库写操作(上传/分块/嵌入/删除)由 M10 后端提供", HTTPCode: http.StatusNotImplemented})
return
}
s.auditClaims(r, "rag.delete", "ok")
writeJSON(w, http.StatusOK, map[string]any{"id": req.ID})
}
// ragDocsFrom maps knowledge docs to their admin wire shape.
func ragDocsFrom(docs []knowledge.Doc) []AdminRagDoc {
out := make([]AdminRagDoc, 0, len(docs))
for _, d := range docs {
out = append(out, AdminRagDoc{ID: d.ID, Name: d.Name, Desc: d.Desc, Chunks: d.Chunks, Embedded: d.Embedded})
}
return out
}
// ragHitsFrom maps knowledge hits to their admin wire shape.
func ragHitsFrom(hits []knowledge.Hit) []AdminRagHit {
out := make([]AdminRagHit, 0, len(hits))
for _, h := range hits {
out = append(out, AdminRagHit{
DocID: h.DocID,
DocName: h.DocName,
Chapter: h.Chapter,
Text: h.Text,
Score: h.Score,
Semantic: h.Semantic,
})
}
return out
}
// adminSetPolicy upserts the reachability markers for a capability. It is the
// audited admin write example (owner-only, enforced by the route registration).
func (s *Server) adminSetPolicy(w http.ResponseWriter, r *http.Request) {
+145
View File
@@ -22,6 +22,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
@@ -188,6 +189,21 @@ type stubMemories struct {
func (s *stubMemories) List(ctx context.Context) ([]memory.MemoryItem, error) { return s.list, s.err }
// stubRag mimics RagStore, returning domain docs/hits the gateway maps to its
// admin wire shape.
type stubRag struct {
docs []knowledge.Doc
hits []knowledge.Hit
err error
}
func (s *stubRag) ListDocuments(ctx context.Context) ([]knowledge.Doc, error) { return s.docs, s.err }
func (s *stubRag) Search(ctx context.Context, query string) ([]knowledge.Hit, error) {
return s.hits, s.err
}
func (s *stubRag) IndexDocument(ctx context.Context, doc knowledge.Doc) error { return s.err }
func (s *stubRag) DeleteDocument(ctx context.Context, id string) error { return s.err }
// stubIoT mimics IoTStore.
type stubIoT struct {
list []iot.Device
@@ -1219,3 +1235,132 @@ func reqWithOrigin(origin string) *http.Request {
func reqWithQuery(k, v string) *http.Request {
return httptest.NewRequest(http.MethodGet, "/ws/chat?"+k+"="+v, nil)
}
// --- RAG knowledge base ----------------------------------------------------
func TestAdminRagListsAndSearches(t *testing.T) {
t.Parallel()
docs := []knowledge.Doc{{ID: "p1", Name: "开发时间线.md", Desc: "milestones", Chunks: 3, Embedded: true}}
hits := []knowledge.Hit{{DocID: "p1", DocName: "开发时间线.md", Chapter: "M4", Text: "RAG 检索测试", Score: 1.0}}
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: &stubRag{docs: docs, hits: hits}})
_ = srv
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// List only.
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var list struct {
Documents []AdminRagDoc `json:"documents"`
Hits []AdminRagHit `json:"hits"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &list); err != nil {
t.Fatalf("decode list: %v (body=%s)", err, body)
}
if len(list.Documents) != 1 || list.Documents[0].Name != "开发时间线.md" {
t.Fatalf("documents = %+v, want one 开发时间线.md", list.Documents)
}
if list.Hits != nil {
t.Fatalf("hits = %+v, want omitted without query", list.Hits)
}
// List + search via ?query=.
resp2, body2 := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag?query=rag", nil, tok)
if resp2.StatusCode != http.StatusOK {
t.Fatalf("search status = %d, want 200 (body=%s)", resp2.StatusCode, body2)
}
var search struct {
Documents []AdminRagDoc `json:"documents"`
Hits []AdminRagHit `json:"hits"`
}
if err := json.Unmarshal(decodeBody(t, body2)["data"], &search); err != nil {
t.Fatalf("decode search: %v (body=%s)", err, body2)
}
if len(search.Hits) != 1 || search.Hits[0].DocName != "开发时间线.md" {
t.Fatalf("hits = %+v, want one hit", search.Hits)
}
}
func TestAdminRagQueryPost(t *testing.T) {
t.Parallel()
hits := []knowledge.Hit{{DocID: "p1", DocName: "开发时间线.md", Text: "RAG 检索测试", Score: 1.0}}
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: &stubRag{hits: hits}})
_ = srv
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/query", map[string]any{"query": "rag"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var res struct {
Query string `json:"query"`
Hits []AdminRagHit `json:"hits"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
t.Fatalf("decode: %v (body=%s)", err, body)
}
if res.Query != "rag" || len(res.Hits) != 1 {
t.Fatalf("response = %+v, want query=rag with 1 hit", res)
}
}
func TestAdminRagEmptyWhenUnwired(t *testing.T) {
t.Parallel()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
_ = srv
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// List degrades to empty (never a 500).
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var list struct {
Documents []AdminRagDoc `json:"documents"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &list); err != nil {
t.Fatalf("decode: %v (body=%s)", err, body)
}
if len(list.Documents) != 0 {
t.Fatalf("documents = %+v, want empty", list.Documents)
}
// Query reports a clear 501 (backend not implemented).
resp2, body2 := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/query", map[string]any{"query": "rag"}, tok)
if resp2.StatusCode != http.StatusNotImplemented {
t.Fatalf("query status = %d, want 501 (body=%s)", resp2.StatusCode, body2)
}
wantErrorShape(t, body2)
if !strings.Contains(string(body2), "not_implemented") {
t.Fatalf("error body = %s, want not_implemented code", body2)
}
}
func TestAdminRagDeleteNotImplemented(t *testing.T) {
t.Parallel()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: &stubRag{err: knowledge.ErrWriteUnsupported}})
_ = srv
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/delete", map[string]any{"id": "p1"}, tok)
if resp.StatusCode != http.StatusNotImplemented {
t.Fatalf("status = %d, want 501 (body=%s)", resp.StatusCode, body)
}
wantErrorShape(t, body)
if !strings.Contains(string(body), "M10") {
t.Fatalf("error body = %s, want M10 write message", body)
}
}
func TestAdminRagDeleteRequiresID(t *testing.T) {
t.Parallel()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: &stubRag{}})
_ = srv
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/delete", map[string]any{"id": " "}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
+26
View File
@@ -35,6 +35,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
@@ -132,6 +133,23 @@ type BackthinkStore interface {
List(ctx context.Context, limit int) ([]backthink.Record, error)
}
// RagStore backs the admin knowledge-base RAG module (design/10 §3.12 module 8).
// *runtime/knowledge.Store is the production implementation. Every method is
// read-only for this milestone: IndexDocument/DeleteDocument return a clear
// "write lands in M10" error rather than a fabricated success.
type RagStore interface {
// ListDocuments enumerates the knowledge-base documents (project-index
// entries).
ListDocuments(ctx context.Context) ([]knowledge.Doc, error)
// Search answers a knowledge-base query (line-level hierarchy + optional
// semantic/vector ranking).
Search(ctx context.Context, query string) ([]knowledge.Hit, error)
// IndexDocument uploads/chunks/embeds a document. M10; returns an error.
IndexDocument(ctx context.Context, doc knowledge.Doc) error
// DeleteDocument removes a document. M10; returns an error.
DeleteDocument(ctx context.Context, id string) error
}
// Deps carries the gateway's injected dependencies. Every field is nil-tolerant
// except Auth, which is required (authentication backs login and every RBAC
// decision). Registry/Loop are satisfied by the concrete *capability.Registry and
@@ -170,6 +188,9 @@ type Deps struct {
Users UserStore
// Backthink lists background-think records. Optional.
Backthink BackthinkStore
// RAG backs the admin knowledge-base RAG module. Optional; list/search
// return empty when unset, and write endpoints report a clear 501.
RAG RagStore
// Streaming marks the injected Loop as streaming-capable (its OnStream is
// wired to publish ws.stream_chunk). When set the hub skips its own single
// final stream_chunk emission, since deltas already stream through OnStream.
@@ -197,6 +218,7 @@ type Server struct {
automations AutomationStore
users UserStore
backthink BackthinkStore
rag RagStore
voice VoiceService
streaming bool
frontend fs.FS
@@ -229,6 +251,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
automations: deps.Automations,
users: deps.Users,
backthink: deps.Backthink,
rag: deps.RAG,
streaming: deps.Streaming,
frontend: deps.Frontend,
log: sourcedLog(deps.Log),
@@ -269,6 +292,9 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/v1/admin/automations", adminAuth(http.HandlerFunc(s.adminListAutomations)))
mux.Handle("GET /api/v1/admin/users", adminAuth(http.HandlerFunc(s.adminListUsers)))
mux.Handle("GET /api/v1/admin/backthink", adminAuth(http.HandlerFunc(s.adminListBackthink)))
mux.Handle("GET /api/v1/admin/rag", adminAuth(http.HandlerFunc(s.adminListRag)))
mux.Handle("POST /api/v1/admin/rag/query", adminAuth(http.HandlerFunc(s.adminSearchRag)))
mux.Handle("POST /api/v1/admin/rag/delete", adminAuth(http.HandlerFunc(s.adminDeleteRag)))
mux.Handle("POST /api/v1/admin/plugins/policy", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.adminSetPolicy)))
var h http.Handler = mux