Files
Cyrene_v2/internal/runtime/knowledge/store.go
T
Aska dbcf3df8a3
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: RAG 知识库真实数据 + 渠道端到端接线(并行∦wire.go)+ 主会话收口
【渠道端到端·接线(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 不浮现)。
2026-09-06 20:09:09 +08:00

267 lines
9.0 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 knowledge implements the RAG knowledge-base data source described in
// DevDocs/design/10-后台管理面板.md §3.12 (module 8 知识库 RAG) and the
// knowledge/memory separation in DevDocs/design/03-记忆.md §3 (③ 知识库 =
// 参考资料, 记忆 = 经历).
//
// It reads the project index (项目索引 → 项目描述 → 章节内容) as the set of knowledge
// documents and answers searches over it with line-level context. When a semantic
// retriever is wired, it also folds in project-linked long-term memories as
// semantic hits, ranked by the retriever's real keyword × semantic × decay path —
// so a configured vector index (pgvector / SQLite vector) is genuinely used and
// never a shell.
//
// The store is read-only for this milestone: upload/chunk/embed and delete land
// in M10. The write methods return ErrWriteUnsupported so the admin handler can
// answer honestly instead of fabricating a success.
package knowledge
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
)
// ErrWriteUnsupported is returned by the write operations. Upload/chunk/embed and
// delete are milestone M10; the admin handler maps it to a clear 501 so the
// frontend can explain rather than show a placeholder alert.
var ErrWriteUnsupported = errors.New("knowledge: write operations (upload/chunk/embed/delete) land in M10")
// defaultTopK bounds the semantic retrieval path.
const defaultTopK = 8
// Retriever is the subset of the memory retriever the knowledge store uses for
// semantic ranking. *memory.Retriever implements it; nil means no semantic path.
type Retriever interface {
Retrieve(ctx context.Context, personaID, query string, topK int) ([]memory.Result, error)
}
// Doc is one knowledge-base document backing a project-index entry.
type Doc struct {
// ID is the project index id.
ID string
// Name is the project title (the document's display name).
Name string
// Desc is the project description (layer 2 of the hierarchy).
Desc string
// Chunks is the number of chapters the document is split into.
Chunks int
// Embedded reports whether a persistent vector index is wired for this
// deployment. This is the deployment-level semantic capability; per-document
// embedding state is not tracked yet (M10).
Embedded bool
}
// Hit is one retrieval result surfaced to the admin panel.
type Hit struct {
// DocID is the owning project index id.
DocID string
// DocName is the owning project title.
DocName string
// Chapter is the chapter title that matched (empty for a semantic/memory hit).
Chapter string
// Text is the matched content: a chapter's located line(s) with context, or
// the memory content for a semantic hit.
Text string
// Score is the relevance score (higher = more relevant).
Score float64
// Semantic reports whether the hit was ranked by the semantic/vector path.
Semantic bool
}
// Deps carries the construction inputs. Every field is nil-tolerant so a store
// without a project index or retriever degrades to read-only empty answers.
type Deps struct {
// Projects is the project index backing the document list and text search.
// Optional; when nil the document list and text search return empty.
Projects *projectindex.Store
// Retriever ranks project-linked memories semantically. Optional; the
// semantic path runs only when both Retriever and DefaultPersona are set.
Retriever Retriever
// DefaultPersona scopes semantic retrieval to one persona. Optional.
DefaultPersona string
// VectorOn reports whether a persistent vector index (pgvector / SQLite
// vector) is wired, so Doc.Embedded is honest and never a shell.
VectorOn bool
// Log is the base logger. Nil uses a discard logger tagged with this package.
Log *slog.Logger
}
// Store is the RAG data source. It is stateless and safe for concurrent use.
type Store struct {
projects *projectindex.Store
retriever Retriever
defaultPersona string
vectorOn bool
log *slog.Logger
}
// New builds a Store. Availability degrades gracefully: a nil project index means
// no documents, a nil retriever means keyword/line search only.
func New(d Deps) *Store {
log := d.Log
if log == nil {
log = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return &Store{
projects: d.Projects,
retriever: d.Retriever,
defaultPersona: strings.TrimSpace(d.DefaultPersona),
vectorOn: d.VectorOn,
log: logger.WithSource(log, "runtime/knowledge"),
}
}
// ListDocuments enumerates the knowledge-base documents (project-index entries).
// A nil project index returns an empty, non-nil slice.
func (s *Store) ListDocuments(ctx context.Context) ([]Doc, error) {
out := make([]Doc, 0)
if s.projects == nil {
return out, nil
}
projects, err := s.projects.ListProjects(ctx)
if err != nil {
return nil, fmt.Errorf("knowledge: list projects: %w", err)
}
for i := range projects {
chapters, cerr := s.projects.ListChapters(ctx, projects[i].ID)
if cerr != nil {
s.log.Error("list chapters", "project", projects[i].ID, "err", cerr)
continue
}
out = append(out, Doc{
ID: projects[i].ID,
Name: projects[i].Title,
Desc: projects[i].Desc,
Chunks: len(chapters),
Embedded: s.vectorOn,
})
}
return out, nil
}
// Search runs the knowledge-base search over the project index (line-level,
// hierarchy-aware) and, when a semantic retriever is configured, folds in
// project-linked memories ranked by the real vector path. A blank query returns
// no hits.
func (s *Store) Search(ctx context.Context, query string) ([]Hit, error) {
hits := make([]Hit, 0)
if strings.TrimSpace(query) == "" {
return hits, nil
}
if s.projects != nil {
textHits, err := s.searchProjects(ctx, query)
if err != nil {
return nil, err
}
hits = append(hits, textHits...)
}
if s.retriever != nil && s.defaultPersona != "" {
semHits, err := s.searchSemantic(ctx, query)
if err != nil {
s.log.Error("semantic search", "err", err)
} else {
hits = append(hits, semHits...)
}
}
return hits, nil
}
// searchProjects walks every project and returns chapter/item hits via
// projectindex.Search (keyword + line context). A project deleted between the
// list and the search is skipped rather than failing the whole search.
func (s *Store) searchProjects(ctx context.Context, query string) ([]Hit, error) {
projects, err := s.projects.ListProjects(ctx)
if err != nil {
return nil, fmt.Errorf("knowledge: list projects: %w", err)
}
hits := make([]Hit, 0)
for i := range projects {
res, err := s.projects.Search(ctx, projects[i].ID, query)
if errors.Is(err, projectindex.ErrProjectNotFound) {
continue
}
if err != nil {
return nil, fmt.Errorf("knowledge: search project %q: %w", projects[i].ID, err)
}
if res.Project != nil {
hits = append(hits, Hit{
DocID: res.Project.ID,
DocName: res.Project.Title,
Text: describeProject(res.Project),
Score: float64(len(res.Chapters) + 1),
})
}
for _, ch := range res.Chapters {
hits = append(hits, chapterHit(projects[i], ch))
}
}
return hits, nil
}
// searchSemantic ranks memories via the retriever's real retrieval path and maps
// each project-linked result to a Hit (marked Semantic so the panel can show it).
func (s *Store) searchSemantic(ctx context.Context, query string) ([]Hit, error) {
results, err := s.retriever.Retrieve(ctx, s.defaultPersona, query, defaultTopK)
if err != nil {
return nil, fmt.Errorf("knowledge: retrieve: %w", err)
}
hits := make([]Hit, 0, len(results))
for _, r := range results {
if r.Project == nil {
continue
}
hits = append(hits, Hit{
DocID: r.Project.ID,
DocName: r.Project.Title,
Text: r.Item.Content,
Score: r.Score,
Semantic: true,
})
}
return hits, nil
}
// chapterHit maps a matched chapter to a Hit. Text is the located line plus its
// surrounding context (projectindex.LineMatch.Context), and Score reflects how
// many lines in the chapter matched.
func chapterHit(p projectindex.Project, ch projectindex.ChapterHit) Hit {
text := ""
if len(ch.Matches) > 0 {
text = ch.Matches[0].Context
}
return Hit{
DocID: p.ID,
DocName: p.Title,
Chapter: ch.Chapter.Title,
Text: text,
Score: float64(len(ch.Matches)),
}
}
// describeProject summarizes a project-index hit (title/desc match) for the panel.
func describeProject(p *projectindex.Project) string {
if p.Desc != "" {
return p.Desc
}
return p.Title
}
// IndexDocument is not implemented in this milestone. It returns
// ErrWriteUnsupported so the handler answers honestly (upload/chunk/embed = M10).
func (s *Store) IndexDocument(_ context.Context, _ Doc) error {
return ErrWriteUnsupported
}
// DeleteDocument is not implemented in this milestone. It returns
// ErrWriteUnsupported so the handler answers honestly (delete = M10).
func (s *Store) DeleteDocument(_ context.Context, _ string) error {
return ErrWriteUnsupported
}