feat(rag): pgvector knowledge base with API and LLM tools
- services/rag: knowledge_docs store — vector search via the shared embedder (HNSW index, graceful ILIKE fallback), list/delete scoped to the user, search intentionally global for the single-user deployment - gateway: GET/POST /knowledge/docs, DELETE /knowledge/docs/:id, POST /knowledge/search - plugins/knowledge: knowledge_search and knowledge_ingest tools so the persona can save and recall information mid-conversation - main: migrations, store wiring, plugin registration
This commit is contained in:
1 parent
ac0c2b2c73
commit
9c8d5a9e8d
6 files changed
+432
-2
No files matched your search
@@ -0,0 +1,93 @@
|
||||
// Package knowledge exposes the knowledge base as LLM tools.
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/services/rag"
|
||||
)
|
||||
|
||||
// Store is the subset of rag.Store used by the tools.
|
||||
type Store interface {
|
||||
Ingest(ctx context.Context, userID, title, content string) (*rag.Document, error)
|
||||
Search(ctx context.Context, userID, query string, topK int) ([]rag.Document, error)
|
||||
}
|
||||
|
||||
type Plugin struct{ store Store }
|
||||
|
||||
func New(store Store) *Plugin { return &Plugin{store: store} }
|
||||
|
||||
func (p *Plugin) Name() string { return "knowledge" }
|
||||
func (p *Plugin) Version() string { return "1.0.0" }
|
||||
func (p *Plugin) Description() string { return "知识库检索与写入" }
|
||||
func (p *Plugin) Init(_ context.Context, _ plugin.PluginConfig) error { return nil }
|
||||
func (p *Plugin) Close() error { return nil }
|
||||
func (p *Plugin) Enabled() bool { return p.store != nil }
|
||||
|
||||
func (p *Plugin) Tools() []plugin.ToolDefinition {
|
||||
return []plugin.ToolDefinition{
|
||||
{
|
||||
Name: "knowledge_search",
|
||||
Description: "Search the knowledge base for relevant documents.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]string{"type": "string", "description": "Search query"},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "knowledge_ingest",
|
||||
Description: "Save a piece of information into the knowledge base for later retrieval.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"title": map[string]string{"type": "string", "description": "Document title"},
|
||||
"content": map[string]string{"type": "string", "description": "Document content"},
|
||||
},
|
||||
"required": []string{"title", "content"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Execute(ctx context.Context, toolName string, params map[string]any) (string, error) {
|
||||
if p.store == nil {
|
||||
return "", fmt.Errorf("knowledge store unavailable")
|
||||
}
|
||||
switch toolName {
|
||||
case "knowledge_search":
|
||||
query, _ := params["query"].(string)
|
||||
if query == "" {
|
||||
return "", fmt.Errorf("missing query")
|
||||
}
|
||||
docs, err := p.store.Search(ctx, "", query, 5)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
return "知识库中没有相关内容", nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
for i, d := range docs {
|
||||
fmt.Fprintf(&sb, "%d. 《%s》\n%s\n\n", i+1, d.Title, d.Content)
|
||||
}
|
||||
return sb.String(), nil
|
||||
|
||||
case "knowledge_ingest":
|
||||
title, _ := params["title"].(string)
|
||||
content, _ := params["content"].(string)
|
||||
if title == "" || content == "" {
|
||||
return "", fmt.Errorf("missing title or content")
|
||||
}
|
||||
if _, err := p.store.Ingest(ctx, "", title, content); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("已收录《%s》", title), nil
|
||||
}
|
||||
return "", fmt.Errorf("unknown tool %q", toolName)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/services/rag"
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
docs []rag.Document
|
||||
}
|
||||
|
||||
func (f *fakeStore) Ingest(_ context.Context, userID, title, content string) (*rag.Document, error) {
|
||||
d := &rag.Document{ID: int64(len(f.docs) + 1), UserID: userID, Title: title, Content: content}
|
||||
f.docs = append(f.docs, *d)
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Search(_ context.Context, _, query string, topK int) ([]rag.Document, error) {
|
||||
var out []rag.Document
|
||||
for _, d := range f.docs {
|
||||
if strings.Contains(d.Content, query) || strings.Contains(d.Title, query) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestIngestAndSearch(t *testing.T) {
|
||||
p := New(&fakeStore{})
|
||||
ctx := context.Background()
|
||||
|
||||
out, err := p.Execute(ctx, "knowledge_ingest", map[string]any{"title": "火锅攻略", "content": "楼下那家火锅店周三半价"})
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "火锅攻略") {
|
||||
t.Errorf("ingest output: %q", out)
|
||||
}
|
||||
|
||||
out, err = p.Execute(ctx, "knowledge_search", map[string]any{"query": "火锅"})
|
||||
if err != nil {
|
||||
t.Fatalf("search: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "周三半价") {
|
||||
t.Errorf("search output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEmpty(t *testing.T) {
|
||||
p := New(&fakeStore{})
|
||||
out, err := p.Execute(context.Background(), "knowledge_search", map[string]any{"query": "不存在"})
|
||||
if err != nil || !strings.Contains(out, "没有相关内容") {
|
||||
t.Errorf("out=%q err=%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnavailableStore(t *testing.T) {
|
||||
p := New(nil)
|
||||
if p.Enabled() {
|
||||
t.Error("plugin should be disabled without store")
|
||||
}
|
||||
if _, err := p.Execute(context.Background(), "knowledge_search", map[string]any{"query": "x"}); err == nil {
|
||||
t.Error("expected error with nil store")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user