Files
weichen 716fe9541c Enforce system-managed settings scope with admin system settings API
Why: P2——模型、密钥、Tavily、遥测与运行基础设施属系统受管项;user/project 层须整笔拒绝(含嵌套、点路径与子树覆盖),生效链剥除存量覆盖,管理员经新系统面统一配置。
Evidence: go test ./... 全绿;settings 单测覆盖嵌套/点路径/祖先标量拒绝、系统层生效、读面剥除;server e2e 覆盖 400/系统面读写/掩码;认证档 admin 门测试(普通 403、admin 200、密钥不泄漏)。
Impact: settings.go 受管标记与 mergeScope 拦截、Effective 增系统层、server 新增 GET/PUT /api/settings/system(无认证档本机即管理员)、SecretLookup 系统行回退、插件 schema 受管注入;测试基建 mock seed 与三处用例迁到系统层;svc.sh 播种改走系统面(仅 admin,无管理员时明确跳过)。Core gitlink 升至 9150e6f。
Refs: modules/documents/docs/proposals/2026-10-13-rdm-settings-members-duties-perms-increment.md P2
2026-10-13 04:05:15 +00:00

297 lines
9.7 KiB
Go
Raw Permalink 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 rag
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// fakeEmbedder 确定性向量:按首字符映射,验证混合检索排序无需真实模型。
type fakeEmbedder struct{ fail bool }
func (f fakeEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
if f.fail {
return nil, errors.New("embed down")
}
out := make([][]float32, len(texts))
for i, t := range texts {
if strings.HasPrefix(t, "A") {
out[i] = []float32{1, 0}
} else {
out[i] = []float32{0, 1}
}
}
return out, nil
}
type fakeReranker struct{ called *bool }
func (f fakeReranker) Rerank(_ context.Context, _ string, docs []core.KnowledgeDoc, topK int) ([]core.KnowledgeDoc, error) {
*f.called = true
// 反转顺序以验证 rerank 生效。
out := make([]core.KnowledgeDoc, 0, len(docs))
for i := len(docs) - 1; i >= 0; i-- {
out = append(out, docs[i])
}
if len(out) > topK {
out = out[:topK]
}
return out, nil
}
func newTestPipeline(t *testing.T, embedder core.EmbeddingProvider, reranker core.Reranker) (*Pipeline, *store.Store) {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
set := settings.NewService(st)
return NewPipeline(st, set, embedder, reranker), st
}
func TestChunkerPacksParagraphs(t *testing.T) {
text := strings.Repeat("段", 300) + "\n\n" + strings.Repeat("落", 300) + "\n\n" + strings.Repeat("字", 300)
chunks := chunkText(text, 500, 80)
if len(chunks) < 2 {
t.Fatalf("expected packing into >=2 chunks, got %d", len(chunks))
}
total := 0
for i, c := range chunks {
if c.Index != i {
t.Fatalf("chunk index %d != %d", c.Index, i)
}
total += len([]rune(c.Text))
if len([]rune(c.Text)) > 500+1 { // 允许连接换行
t.Fatalf("chunk %d oversized: %d", i, len([]rune(c.Text)))
}
}
if total == 0 {
t.Fatal("no content")
}
}
func TestChunkerHardSplitsLongParagraphWithOverlap(t *testing.T) {
text := strings.Repeat("长", 1200)
chunks := chunkText(text, 500, 100)
if len(chunks) < 3 {
t.Fatalf("expected >=3 window chunks, got %d", len(chunks))
}
// 相邻窗口有重叠上下文。
a := []rune(chunks[0].Text)
b := []rune(chunks[1].Text)
if len(a) < 500 || len(b) == 0 {
t.Fatalf("window sizes: %d %d", len(a), len(b))
}
tail := string(a[len(a)-100:])
head := string(b[:min(100, len(b))])
if tail != head {
t.Fatalf("overlap mismatch:\n%q\n%q", tail, head)
}
}
func TestChunkerEmpty(t *testing.T) {
if got := chunkText(" \n\n ", 500, 80); len(got) != 0 {
t.Fatalf("empty input should produce no chunks, got %d", len(got))
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// 索引:分块入全文库;embedding 配置时向量入库;失败降级不阻断。
func TestIndexDocumentChunksAndVectors(t *testing.T) {
pipe, st := newTestPipeline(t, fakeEmbedder{}, nil)
text := strings.Repeat("Alpha 文档内容。", 120) // 约 1200 字 → 多块
if err := pipe.IndexDocument("o", "p", "upload", "a.md", "art_1", text); err != nil {
t.Fatal(err)
}
hits, err := st.SearchKnowledge("o", core.RetrievalQuery{Text: "文档"})
if err != nil {
t.Fatal(err)
}
if len(hits) < 2 {
t.Fatalf("chunked fulltext expected >=2 hits, got %d", len(hits))
}
// 向量已入库(fakeEmbedder 首字符 'A' → [1,0])。
vecHits, err := st.SearchKnowledgeVectors(context.Background(), "o", []float32{1, 0}, 5, core.RetrievalQuery{})
if err != nil {
t.Fatal(err)
}
if len(vecHits) == 0 || vecHits[0].Kind != "vector" {
t.Fatalf("vector hits: %+v", vecHits)
}
}
func TestIndexDocumentEmbeddingFailureDegrades(t *testing.T) {
pipe, st := newTestPipeline(t, fakeEmbedder{fail: true}, nil)
if err := pipe.IndexDocument("o", "p", "upload", "a.md", "art_1", "Beta 内容"); err != nil {
t.Fatalf("embedding failure must degrade, got %v", err)
}
vecHits, _ := st.SearchKnowledgeVectors(context.Background(), "o", []float32{1, 0}, 5, core.RetrievalQuery{})
if len(vecHits) != 0 {
t.Fatalf("no vectors expected on embedding failure, got %d", len(vecHits))
}
fhits, _ := st.SearchKnowledge("o", core.RetrievalQuery{Text: "Beta"})
if len(fhits) == 0 {
t.Fatal("fulltext must still have the doc")
}
}
// 检索:未配置 embedding → 纯全文;配置 → 向量+全文 RRF;rerank 生效。
func TestRetrieveFallbackAndHybrid(t *testing.T) {
// 无 embedder:纯全文路径。
pipe, _ := newTestPipeline(t, nil, nil)
pipe.IndexDocument("o", "p", "upload", "d", "r1", "Alpha 目标文档")
hits, err := pipe.Retrieve(context.Background(), "o", core.RetrievalQuery{Text: "目标"})
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 || hits[0].Kind != "fulltext" {
t.Fatalf("fulltext fallback: %+v", hits)
}
// 有 embedder 且路由已配置:查询向量 A 类 → A 文档排前(RRF 向量路主导)。
// P2 受管:models.* 是系统受管项,配置写入系统层行(owner=OwnerSystemRow)。
pipe2, st2 := newTestPipeline(t, fakeEmbedder{}, nil)
if err := st2.MergeSettingsScope(settings.OwnerSystemRow, settings.ScopeSystem, map[string]any{
"models": map[string]any{"embedding": map[string]any{"baseUrl": "http://fake.internal", "model": "e-test"}},
}); err != nil {
t.Fatal(err)
}
pipe2.IndexDocument("o", "", "upload", "d1", "rA", "Alpha 语义相关文档")
pipe2.IndexDocument("o", "", "upload", "d2", "rB", "Beta 另一个主题文档")
hits2, err := pipe2.Retrieve(context.Background(), "o", core.RetrievalQuery{Text: "A 语义查询", TopK: 2})
if err != nil {
t.Fatal(err)
}
if len(hits2) == 0 {
t.Fatal("hybrid hits empty")
}
if hits2[0].Doc.Ref != "rA" {
t.Fatalf("vector path should rank rA first, got ref=%s", hits2[0].Doc.Ref)
}
if hits2[0].Kind != "vector" {
t.Fatalf("top hit kind: %s", hits2[0].Kind)
}
}
func TestRetrieveRerankReorders(t *testing.T) {
called := false
pipe, _ := newTestPipeline(t, fakeEmbedder{}, fakeReranker{called: &called})
pipe.IndexDocument("o", "", "upload", "d1", "rA", "Alpha 文档一")
pipe.IndexDocument("o", "", "upload", "d2", "rB", "Beta 文档二")
// rerank 未配置路由(settings 无 models.rerank)→ 不调用;此处直接验证 Reranker
// 组件行为(管线内调用受路由开关,见 Retrieve 实现)。
var docs []core.KnowledgeDoc
docs = append(docs, core.KnowledgeDoc{ID: "a", Chunk: "A"}, core.KnowledgeDoc{ID: "b", Chunk: "B"})
out, err := fakeReranker{called: &called}.Rerank(context.Background(), "q", docs, 2)
if err != nil || !called {
t.Fatalf("reranker: %v called=%v", err, called)
}
if out[0].ID != "b" || out[1].ID != "a" {
t.Fatalf("rerank order: %v", out)
}
}
// 租户边界:向量检索不越权。
func TestVectorTenantIsolation(t *testing.T) {
pipe, st := newTestPipeline(t, fakeEmbedder{}, nil)
pipe.IndexDocument("alice", "", "upload", "d", "rA", "Alice 私有文档")
vecHits, err := st.SearchKnowledgeVectors(context.Background(), "bob", []float32{1, 0}, 5, core.RetrievalQuery{})
if err != nil {
t.Fatal(err)
}
if len(vecHits) != 0 {
t.Fatalf("cross-tenant vector leak: %+v", vecHits)
}
}
// 维度不匹配的向量不参与检索(换 embedding 模型后旧向量自然失效)。
func TestVectorDimMismatchSkipped(t *testing.T) {
pipe, st := newTestPipeline(t, fakeEmbedder{}, nil)
pipe.IndexDocument("o", "", "upload", "d", "r", "Alpha 文档")
hits, err := st.SearchKnowledgeVectors(context.Background(), "o", []float32{1, 0, 0}, 5, core.RetrievalQuery{})
if err != nil {
t.Fatal(err)
}
if len(hits) != 0 {
t.Fatalf("dim mismatch must skip, got %+v", hits)
}
}
// ---- EXT-1:IndexChunks(KnowledgeWrite embed=true 的底层路径) ----
func TestIndexChunksWritesFulltextAndVectors(t *testing.T) {
p, st := newTestPipeline(t, fakeEmbedder{}, nil)
docs := []core.KnowledgeDoc{
{Owner: "alice", Source: "plugin:rdm", Title: "规格", Ref: "spec-1", Chunk: "Alpha 内容甲"},
{Owner: "alice", Source: "plugin:rdm", Title: "规格", Ref: "spec-1", Chunk: "beta 内容乙"},
}
if err := p.IndexChunks(context.Background(), "alice", docs); err != nil {
t.Fatal(err)
}
// ID 已被 AddKnowledge 回填,向量按回填 ID 关联。
for _, d := range docs {
if d.ID == "" {
t.Fatal("doc ID not backfilled")
}
}
// 全文行落库。
hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "内容甲"})
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Fatal("fulltext row missing")
}
// 向量行落库(fakeEmbedder:非 A 前缀 → [0,1])。
vhits, err := st.SearchKnowledgeVectors(context.Background(), "alice", []float32{0, 1}, 5, core.RetrievalQuery{})
if err != nil {
t.Fatal(err)
}
if len(vhits) == 0 {
t.Fatal("vector rows missing")
}
// owner 作用域:bob 检索不到 alice 的向量。
bhits, err := st.SearchKnowledgeVectors(context.Background(), "bob", []float32{0, 1}, 5, core.RetrievalQuery{})
if err != nil {
t.Fatal(err)
}
if len(bhits) != 0 {
t.Fatalf("owner leak: %+v", bhits)
}
}
func TestIndexChunksEmbedFailureDegrades(t *testing.T) {
p, st := newTestPipeline(t, fakeEmbedder{fail: true}, nil)
docs := []core.KnowledgeDoc{{Owner: "alice", Source: "plugin:rdm", Ref: "spec-2", Chunk: "降级内容"}}
if err := p.IndexChunks(context.Background(), "alice", docs); err != nil {
t.Fatalf("embed failure must degrade to fulltext, got %v", err)
}
hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "降级"})
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Fatal("fulltext row missing after degrade")
}
vhits, err := st.SearchKnowledgeVectors(context.Background(), "alice", []float32{0, 1}, 5, core.RetrievalQuery{})
if err != nil {
t.Fatal(err)
}
if len(vhits) != 0 {
t.Fatalf("no vectors expected after degrade, got %+v", vhits)
}
}