【渠道端到端·接线(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 不浮现)。
267 lines
9.0 KiB
Go
267 lines
9.0 KiB
Go
// 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
|
||
}
|