diff --git a/ax7_chunk_core_test.go b/ax7_chunk_core_test.go new file mode 100644 index 0000000..0b6718b --- /dev/null +++ b/ax7_chunk_core_test.go @@ -0,0 +1,211 @@ +package rag + +import core "dappco.re/go" + +func TestAX7_Category_Good(t *core.T) { + category := Category("docs/flux/button.md") + + core.AssertEqual(t, "ui-component", category) + core.AssertNotEqual(t, "documentation", category) +} + +func TestAX7_Category_Bad(t *core.T) { + category := Category("") + + core.AssertEqual(t, "documentation", category) + core.AssertNotEqual(t, "task", category) +} + +func TestAX7_Category_Ugly(t *core.T) { + category := Category("BRAND/MASCOT/README.MD") + + core.AssertEqual(t, "brand", category) + core.AssertNotEqual(t, "documentation", category) +} + +func TestAX7_ChunkID_Good(t *core.T) { + id := ChunkID("docs/guide.md", 1, "Go uses goroutines.") + + core.AssertNotEmpty(t, id) + core.AssertEqual(t, id, ChunkID("docs/guide.md", 1, "Go uses goroutines.")) +} + +func TestAX7_ChunkID_Bad(t *core.T) { + id := ChunkID("docs/guide.md", 1, "Go uses goroutines.") + + core.AssertNotEqual(t, id, ChunkID("docs/guide.md", 2, "Go uses goroutines.")) + core.AssertNotEqual(t, id, ChunkID("other.md", 1, "Go uses goroutines.")) +} + +func TestAX7_ChunkID_Ugly(t *core.T) { + prefix := core.Concat("x", repeatString("a", 120)) + id := ChunkID("unicode.md", 0, prefix+"left") + + core.AssertEqual(t, id, ChunkID("unicode.md", 0, prefix+"right")) + core.AssertLen(t, id, 32) +} + +func TestAX7_ChunkMarkdown_Good(t *core.T) { + chunks := ChunkMarkdown("# Title\n\nThis is body text.", ChunkConfig{Size: 100, Overlap: 0}) + + core.AssertLen(t, chunks, 1) + core.AssertEqual(t, "Title", chunks[0].Section) + core.AssertContains(t, chunks[0].Text, "body text") +} + +func TestAX7_ChunkMarkdown_Bad(t *core.T) { + chunks := ChunkMarkdown("", DefaultChunkConfig()) + + core.AssertEmpty(t, chunks) + core.AssertEqual(t, 0, len(chunks)) +} + +func TestAX7_ChunkMarkdown_Ugly(t *core.T) { + text := "## Long\n\n" + repeatString("oversized ", 80) + chunks := ChunkMarkdown(text, ChunkConfig{Size: 40, Overlap: 10}) + + core.AssertGreater(t, len(chunks), 1) + core.AssertEqual(t, "Long", chunks[0].Section) +} + +func TestAX7_ChunkMarkdownSeq_Good(t *core.T) { + var chunks []Chunk + for chunk := range ChunkMarkdownSeq("## A\n\nAlpha.\n\n## B\n\nBeta.", ChunkConfig{Size: 80}) { + chunks = append(chunks, chunk) + } + + core.AssertLen(t, chunks, 2) + core.AssertEqual(t, "A", chunks[0].Section) +} + +func TestAX7_ChunkMarkdownSeq_Bad(t *core.T) { + var chunks []Chunk + for chunk := range ChunkMarkdownSeq(" \n\n\t", DefaultChunkConfig()) { + chunks = append(chunks, chunk) + } + + core.AssertEmpty(t, chunks) + core.AssertEqual(t, 0, len(chunks)) +} + +func TestAX7_ChunkMarkdownSeq_Ugly(t *core.T) { + count := 0 + for range ChunkMarkdownSeq("## A\n\n"+repeatString("word ", 50), ChunkConfig{Size: 20}) { + count++ + break + } + + core.AssertEqual(t, 1, count) + core.AssertTrue(t, count < 2) +} + +func TestAX7_ChunkBySentencesSeq_Good(t *core.T) { + var chunks []Chunk + for chunk := range ChunkBySentencesSeq("One sentence. Two sentence.", ChunkConfig{Size: 20}) { + chunks = append(chunks, chunk) + } + + core.AssertGreaterOrEqual(t, len(chunks), 1) + core.AssertContains(t, chunks[0].Text, "One") +} + +func TestAX7_ChunkBySentencesSeq_Bad(t *core.T) { + var chunks []Chunk + for chunk := range ChunkBySentencesSeq("", DefaultChunkConfig()) { + chunks = append(chunks, chunk) + } + + core.AssertEmpty(t, chunks) + core.AssertEqual(t, 0, len(chunks)) +} + +func TestAX7_ChunkBySentencesSeq_Ugly(t *core.T) { + var chunks []Chunk + for chunk := range ChunkBySentencesSeq("Alpha. Beta. Gamma.", ChunkConfig{Size: 8, Overlap: 6}) { + chunks = append(chunks, chunk) + } + + core.AssertGreater(t, len(chunks), 1) + core.AssertContains(t, chunks[1].Text, "Alpha") +} + +func TestAX7_ChunkByParagraphsSeq_Good(t *core.T) { + var chunks []Chunk + for chunk := range ChunkByParagraphsSeq("First paragraph.\n\nSecond paragraph.", ChunkConfig{Size: 80}) { + chunks = append(chunks, chunk) + } + + core.AssertLen(t, chunks, 1) + core.AssertContains(t, chunks[0].Text, "Second paragraph") +} + +func TestAX7_ChunkByParagraphsSeq_Bad(t *core.T) { + var chunks []Chunk + for chunk := range ChunkByParagraphsSeq("\n\n", DefaultChunkConfig()) { + chunks = append(chunks, chunk) + } + + core.AssertEmpty(t, chunks) + core.AssertEqual(t, 0, len(chunks)) +} + +func TestAX7_ChunkByParagraphsSeq_Ugly(t *core.T) { + var chunks []Chunk + for chunk := range ChunkByParagraphsSeq("One paragraph.\n\n"+repeatString("long paragraph ", 20), ChunkConfig{Size: 30, Overlap: 8}) { + chunks = append(chunks, chunk) + } + + core.AssertGreater(t, len(chunks), 1) + core.AssertEqual(t, 0, chunks[0].Index) +} + +func TestAX7_DefaultChunkConfig_Bad(t *core.T) { + cfg := DefaultChunkConfig() + + core.AssertNotEqual(t, 0, cfg.Size) + core.AssertNotEqual(t, 0, cfg.Overlap) +} + +func TestAX7_DefaultChunkConfig_Ugly(t *core.T) { + cfg := DefaultChunkConfig() + cfg.Size = -1 + + core.AssertEqual(t, 500, DefaultChunkConfig().Size) + core.AssertEqual(t, -1, cfg.Size) +} + +func TestAX7_FileExtensions_Bad(t *core.T) { + extensions := FileExtensions() + + core.AssertFalse(t, core.Contains(core.Join(",", extensions...), ".go")) + core.AssertFalse(t, core.Contains(core.Join(",", extensions...), ".exe")) +} + +func TestAX7_FileExtensions_Ugly(t *core.T) { + extensions := FileExtensions() + extensions[0] = ".mutated" + + core.AssertEqual(t, ".md", FileExtensions()[0]) + core.AssertEqual(t, ".mutated", extensions[0]) +} + +func TestAX7_ShouldProcess_Good(t *core.T) { + ok := ShouldProcess("docs/guide.md") + + core.AssertTrue(t, ok) + core.AssertTrue(t, ShouldProcess("notes.txt")) +} + +func TestAX7_ShouldProcess_Bad(t *core.T) { + ok := ShouldProcess("cmd/main.go") + + core.AssertFalse(t, ok) + core.AssertFalse(t, ShouldProcess("Makefile")) +} + +func TestAX7_ShouldProcess_Ugly(t *core.T) { + ok := ShouldProcess("DOCS/README.MARKDOWN") + + core.AssertTrue(t, ok) + core.AssertTrue(t, ShouldProcess("archive.PDF")) +} diff --git a/ax7_ingest_collections_test.go b/ax7_ingest_collections_test.go new file mode 100644 index 0000000..8178200 --- /dev/null +++ b/ax7_ingest_collections_test.go @@ -0,0 +1,322 @@ +package rag + +import core "dappco.re/go" + +type ax7DefaultQdrant struct { + *mockVectorStore + healthErr error + closeErr error +} + +func (s *ax7DefaultQdrant) HealthCheck(core.Context) error { + return s.healthErr +} + +func (s *ax7DefaultQdrant) Close() error { + return s.closeErr +} + +type ax7DefaultOllama struct { + *mockEmbedder + verifyErr error +} + +func (e *ax7DefaultOllama) VerifyModel(core.Context) error { + return e.verifyErr +} + +func ax7InstallDefaultClients(t *core.T, store defaultQdrantClient, embedder defaultOllamaClient) { + t.Helper() + oldQdrant := newDefaultQdrantClient + oldOllama := newDefaultOllamaClient + newDefaultQdrantClient = func() (defaultQdrantClient, error) { return store, nil } + newDefaultOllamaClient = func() (defaultOllamaClient, error) { return embedder, nil } + t.Cleanup(func() { + newDefaultQdrantClient = oldQdrant + newDefaultOllamaClient = oldOllama + }) +} + +func ax7WriteFile(t *core.T, path string, content string) { + t.Helper() + result := core.WriteFile(path, []byte(content), 0o644) + core.AssertTrue(t, result.OK, result.Error()) +} + +func TestAX7_DefaultIngestConfig_Bad(t *core.T) { + cfg := DefaultIngestConfig() + + core.AssertNotEqual(t, "", cfg.Collection) + core.AssertNotEqual(t, 0, cfg.BatchSize) +} + +func TestAX7_DefaultIngestConfig_Ugly(t *core.T) { + cfg := DefaultIngestConfig() + cfg.Collection = "mutated" + + core.AssertEqual(t, "hostuk-docs", DefaultIngestConfig().Collection) + core.AssertEqual(t, "mutated", cfg.Collection) +} + +func TestAX7_ListCollections_Bad(t *core.T) { + store := newMockVectorStore() + store.listErr = core.NewError("list failed") + names, err := ListCollections(core.Background(), store) + + core.AssertError(t, err) + core.AssertNil(t, names) +} + +func TestAX7_ListCollections_Ugly(t *core.T) { + store := newMockVectorStore() + names, err := ListCollections(core.Background(), store) + + core.AssertNoError(t, err) + core.AssertEmpty(t, names) +} + +func TestAX7_ListCollectionsSeq_Bad(t *core.T) { + store := newMockVectorStore() + store.listErr = core.NewError("list failed") + seq, err := ListCollectionsSeq(core.Background(), store) + + core.AssertError(t, err) + core.AssertNil(t, seq) +} + +func TestAX7_ListCollectionsSeq_Ugly(t *core.T) { + store := newMockVectorStore() + seq, err := ListCollectionsSeq(core.Background(), store) + count := 0 + for range seq { + count++ + } + + core.AssertNoError(t, err) + core.AssertEqual(t, 0, count) +} + +func TestAX7_DeleteCollection_Bad(t *core.T) { + store := newMockVectorStore() + store.deleteErr = core.NewError("delete failed") + err := DeleteCollection(core.Background(), store, "docs") + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "delete failed") +} + +func TestAX7_DeleteCollection_Ugly(t *core.T) { + store := newMockVectorStore() + err := DeleteCollection(core.Background(), store, "") + + core.AssertNoError(t, err) + core.AssertLen(t, store.deleteCalls, 1) +} + +func TestAX7_CollectionStats_Bad(t *core.T) { + store := newMockVectorStore() + store.infoErr = core.NewError("info failed") + info, err := CollectionStats(core.Background(), store, "docs") + + core.AssertError(t, err) + core.AssertNil(t, info) +} + +func TestAX7_CollectionStats_Ugly(t *core.T) { + store := newMockVectorStore() + store.collections["empty"] = 384 + info, err := CollectionStats(core.Background(), store, "empty") + + core.AssertNoError(t, err) + core.AssertEqual(t, uint64(0), info.PointCount) +} + +func TestAX7_Ingest_Bad(t *core.T) { + dir := t.TempDir() + path := core.PathJoin(dir, "not-dir.md") + ax7WriteFile(t, path, "content") + _, err := Ingest(core.Background(), newMockVectorStore(), newMockEmbedder(2), IngestConfig{Directory: path, Collection: "docs", Chunk: DefaultChunkConfig()}, nil) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "not a directory") +} + +func TestAX7_Ingest_Ugly(t *core.T) { + dir := t.TempDir() + _, err := Ingest(core.Background(), newMockVectorStore(), newMockEmbedder(2), IngestConfig{Directory: dir, Collection: "docs", Chunk: DefaultChunkConfig()}, nil) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no matching files") +} + +func TestAX7_IngestFile_Bad(t *core.T) { + count, err := IngestFile(core.Background(), newMockVectorStore(), newMockEmbedder(2), "docs", core.PathJoin(t.TempDir(), "missing.md"), DefaultChunkConfig()) + + core.AssertError(t, err) + core.AssertEqual(t, 0, count) +} + +func TestAX7_IngestFile_Ugly(t *core.T) { + path := core.PathJoin(t.TempDir(), "empty.md") + ax7WriteFile(t, path, " \n\t") + count, err := IngestFile(core.Background(), newMockVectorStore(), newMockEmbedder(2), "docs", path, DefaultChunkConfig()) + + core.AssertNoError(t, err) + core.AssertEqual(t, 0, count) +} + +func TestAX7_IngestDirWith_Bad(t *core.T) { + err := IngestDirWith(core.Background(), newMockVectorStore(), newMockEmbedder(2), core.PathJoin(t.TempDir(), "missing"), "docs", false) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "accessing directory") +} + +func TestAX7_IngestDirWith_Ugly(t *core.T) { + dir := t.TempDir() + err := IngestDirWith(core.Background(), newMockVectorStore(), newMockEmbedder(2), dir, "docs", true) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no matching files") +} + +func TestAX7_IngestFileWith_Bad(t *core.T) { + count, err := IngestFileWith(core.Background(), newMockVectorStore(), newMockEmbedder(2), core.PathJoin(t.TempDir(), "missing.md"), "docs") + + core.AssertError(t, err) + core.AssertEqual(t, 0, count) +} + +func TestAX7_IngestFileWith_Ugly(t *core.T) { + path := core.PathJoin(t.TempDir(), "empty.md") + ax7WriteFile(t, path, "") + count, err := IngestFileWith(core.Background(), newMockVectorStore(), newMockEmbedder(2), path, "docs") + + core.AssertNoError(t, err) + core.AssertEqual(t, 0, count) +} + +func TestAX7_QueryDocs_Good(t *core.T) { + store := &ax7DefaultQdrant{mockVectorStore: newMockVectorStore()} + store.searchFunc = func(string, []float32, uint64, map[string]string) ([]SearchResult, error) { + return []SearchResult{{Score: 1, Payload: map[string]any{"text": "answer", "source": "a.md", "chunk_index": 0}}}, nil + } + ax7InstallDefaultClients(t, store, &ax7DefaultOllama{mockEmbedder: newMockEmbedder(2)}) + results, err := QueryDocs(core.Background(), "question", "docs", 3) + + core.AssertNoError(t, err) + core.AssertEqual(t, "answer", results[0].Text) +} + +func TestAX7_QueryDocs_Bad(t *core.T) { + oldQdrant := newDefaultQdrantClient + newDefaultQdrantClient = func() (defaultQdrantClient, error) { return nil, core.NewError("factory failed") } + t.Cleanup(func() { newDefaultQdrantClient = oldQdrant }) + results, err := QueryDocs(core.Background(), "question", "docs", 3) + + core.AssertError(t, err) + core.AssertNil(t, results) +} + +func TestAX7_QueryDocs_Ugly(t *core.T) { + store := &ax7DefaultQdrant{mockVectorStore: newMockVectorStore(), closeErr: core.NewError("close ignored")} + store.searchFunc = func(string, []float32, uint64, map[string]string) ([]SearchResult, error) { return nil, nil } + ax7InstallDefaultClients(t, store, &ax7DefaultOllama{mockEmbedder: newMockEmbedder(2)}) + results, err := QueryDocs(core.Background(), "question", "docs", -1) + + core.AssertNoError(t, err) + core.AssertEmpty(t, results) +} + +func TestAX7_QueryDocsContext_Good(t *core.T) { + store := &ax7DefaultQdrant{mockVectorStore: newMockVectorStore()} + store.searchFunc = func(string, []float32, uint64, map[string]string) ([]SearchResult, error) { + return []SearchResult{{Score: 1, Payload: map[string]any{"text": "context answer", "source": "a.md", "chunk_index": 0}}}, nil + } + ax7InstallDefaultClients(t, store, &ax7DefaultOllama{mockEmbedder: newMockEmbedder(2)}) + text, err := QueryDocsContext(core.Background(), "question", "docs", 3) + + core.AssertNoError(t, err) + core.AssertContains(t, text, "context answer") +} + +func TestAX7_QueryDocsContext_Bad(t *core.T) { + store := &ax7DefaultQdrant{mockVectorStore: newMockVectorStore()} + embedder := &ax7DefaultOllama{mockEmbedder: newMockEmbedder(2)} + embedder.embedErr = core.NewError("embed failed") + ax7InstallDefaultClients(t, store, embedder) + text, err := QueryDocsContext(core.Background(), "question", "docs", 3) + + core.AssertError(t, err) + core.AssertEqual(t, "", text) +} + +func TestAX7_QueryDocsContext_Ugly(t *core.T) { + store := &ax7DefaultQdrant{mockVectorStore: newMockVectorStore()} + store.searchFunc = func(string, []float32, uint64, map[string]string) ([]SearchResult, error) { return nil, nil } + ax7InstallDefaultClients(t, store, &ax7DefaultOllama{mockEmbedder: newMockEmbedder(2)}) + text, err := QueryDocsContext(core.Background(), "question", "docs", 3) + + core.AssertNoError(t, err) + core.AssertEqual(t, "", text) +} + +func TestAX7_IngestDirectory_Good(t *core.T) { + dir := t.TempDir() + ax7WriteFile(t, core.PathJoin(dir, "guide.md"), "# Guide\n\nHello world.") + store := &ax7DefaultQdrant{mockVectorStore: newMockVectorStore()} + ax7InstallDefaultClients(t, store, &ax7DefaultOllama{mockEmbedder: newMockEmbedder(2)}) + err := IngestDirectory(core.Background(), dir, "docs", false) + + core.AssertNoError(t, err) + core.AssertLen(t, store.points["docs"], 1) +} + +func TestAX7_IngestDirectory_Bad(t *core.T) { + store := &ax7DefaultQdrant{mockVectorStore: newMockVectorStore(), healthErr: core.NewError("health failed")} + ax7InstallDefaultClients(t, store, &ax7DefaultOllama{mockEmbedder: newMockEmbedder(2)}) + err := IngestDirectory(core.Background(), t.TempDir(), "docs", false) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "health check") +} + +func TestAX7_IngestDirectory_Ugly(t *core.T) { + store := &ax7DefaultQdrant{mockVectorStore: newMockVectorStore()} + embedder := &ax7DefaultOllama{mockEmbedder: newMockEmbedder(2), verifyErr: core.NewError("model missing")} + ax7InstallDefaultClients(t, store, embedder) + err := IngestDirectory(core.Background(), t.TempDir(), "docs", false) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "model missing") +} + +func TestAX7_IngestSingleFile_Good(t *core.T) { + path := core.PathJoin(t.TempDir(), "guide.md") + ax7WriteFile(t, path, "# Guide\n\nHello world.") + store := &ax7DefaultQdrant{mockVectorStore: newMockVectorStore()} + ax7InstallDefaultClients(t, store, &ax7DefaultOllama{mockEmbedder: newMockEmbedder(2)}) + count, err := IngestSingleFile(core.Background(), path, "docs") + + core.AssertNoError(t, err) + core.AssertEqual(t, 1, count) +} + +func TestAX7_IngestSingleFile_Bad(t *core.T) { + store := &ax7DefaultQdrant{mockVectorStore: newMockVectorStore(), healthErr: core.NewError("health failed")} + ax7InstallDefaultClients(t, store, &ax7DefaultOllama{mockEmbedder: newMockEmbedder(2)}) + count, err := IngestSingleFile(core.Background(), core.PathJoin(t.TempDir(), "guide.md"), "docs") + + core.AssertError(t, err) + core.AssertEqual(t, 0, count) +} + +func TestAX7_IngestSingleFile_Ugly(t *core.T) { + store := &ax7DefaultQdrant{mockVectorStore: newMockVectorStore()} + embedder := &ax7DefaultOllama{mockEmbedder: newMockEmbedder(2), verifyErr: core.NewError("model missing")} + ax7InstallDefaultClients(t, store, embedder) + count, err := IngestSingleFile(core.Background(), core.PathJoin(t.TempDir(), "guide.md"), "docs") + + core.AssertError(t, err) + core.AssertEqual(t, 0, count) +} diff --git a/ax7_keyword_query_test.go b/ax7_keyword_query_test.go new file mode 100644 index 0000000..874feac --- /dev/null +++ b/ax7_keyword_query_test.go @@ -0,0 +1,557 @@ +package rag + +import core "dappco.re/go" + +func TestAX7_KeywordResult_GetText_Good(t *core.T) { + result := KeywordResult{Text: "kubernetes deployment"} + + core.AssertEqual(t, "kubernetes deployment", result.GetText()) + core.AssertNotEmpty(t, result.GetText()) +} + +func TestAX7_KeywordResult_GetText_Bad(t *core.T) { + result := KeywordResult{} + + core.AssertEqual(t, "", result.GetText()) + core.AssertEmpty(t, result.GetText()) +} + +func TestAX7_KeywordResult_GetText_Ugly(t *core.T) { + result := KeywordResult{Text: "emoji 😀 deployment"} + + core.AssertContains(t, result.GetText(), "😀") + core.AssertEqual(t, "emoji 😀 deployment", result.GetText()) +} + +func TestAX7_KeywordResult_GetScore_Good(t *core.T) { + result := KeywordResult{Score: 0.75} + + core.AssertEqual(t, float32(0.75), result.GetScore()) + core.AssertGreater(t, result.GetScore(), float32(0)) +} + +func TestAX7_KeywordResult_GetScore_Bad(t *core.T) { + result := KeywordResult{} + + core.AssertEqual(t, float32(0), result.GetScore()) + core.AssertFalse(t, result.GetScore() > 0) +} + +func TestAX7_KeywordResult_GetScore_Ugly(t *core.T) { + result := KeywordResult{Score: -1} + + core.AssertEqual(t, float32(-1), result.GetScore()) + core.AssertLess(t, result.GetScore(), float32(0)) +} + +func TestAX7_KeywordResult_GetSource_Good(t *core.T) { + result := KeywordResult{Source: "docs/search.md"} + + core.AssertEqual(t, "docs/search.md", result.GetSource()) + core.AssertContains(t, result.GetSource(), "docs") +} + +func TestAX7_KeywordResult_GetSource_Bad(t *core.T) { + result := KeywordResult{} + + core.AssertEqual(t, "", result.GetSource()) + core.AssertEmpty(t, result.GetSource()) +} + +func TestAX7_KeywordResult_GetSource_Ugly(t *core.T) { + result := KeywordResult{Source: "docs/space name.md"} + + core.AssertContains(t, result.GetSource(), "space name") + core.AssertEqual(t, "docs/space name.md", result.GetSource()) +} + +func TestAX7_KeywordResult_HasChunkIndex_Good(t *core.T) { + result := KeywordResult{ChunkIndex: 3} + + core.AssertTrue(t, result.HasChunkIndex()) + core.AssertEqual(t, 3, result.GetChunkIndex()) +} + +func TestAX7_KeywordResult_HasChunkIndex_Bad(t *core.T) { + result := KeywordResult{} + + core.AssertTrue(t, result.HasChunkIndex()) + core.AssertEqual(t, 0, result.GetChunkIndex()) +} + +func TestAX7_KeywordResult_HasChunkIndex_Ugly(t *core.T) { + result := KeywordResult{ChunkIndex: -9} + + core.AssertTrue(t, result.HasChunkIndex()) + core.AssertEqual(t, -9, result.GetChunkIndex()) +} + +func TestAX7_KeywordResult_GetChunkIndex_Good(t *core.T) { + result := KeywordResult{ChunkIndex: 7} + + core.AssertEqual(t, 7, result.GetChunkIndex()) + core.AssertGreater(t, result.GetChunkIndex(), 0) +} + +func TestAX7_KeywordResult_GetChunkIndex_Bad(t *core.T) { + result := KeywordResult{} + + core.AssertEqual(t, 0, result.GetChunkIndex()) + core.AssertFalse(t, result.GetChunkIndex() > 0) +} + +func TestAX7_KeywordResult_GetChunkIndex_Ugly(t *core.T) { + result := KeywordResult{ChunkIndex: -1} + + core.AssertEqual(t, -1, result.GetChunkIndex()) + core.AssertLess(t, result.GetChunkIndex(), 0) +} + +func TestAX7_NewKeywordIndex_Good(t *core.T) { + idx := NewKeywordIndex([]Chunk{{Text: "Kubernetes deployment guide", Index: 0}}) + + core.AssertNotNil(t, idx) + core.AssertEqual(t, 1, idx.Len()) +} + +func TestAX7_NewKeywordIndex_Bad(t *core.T) { + idx := NewKeywordIndex(nil) + + core.AssertNotNil(t, idx) + core.AssertEqual(t, 0, idx.Len()) +} + +func TestAX7_NewKeywordIndex_Ugly(t *core.T) { + source := []Chunk{{Text: "Mutable text", Index: 0}} + idx := NewKeywordIndex(source) + source[0].Text = "Changed" + + core.AssertEqual(t, 1, idx.Len()) + core.AssertEqual(t, "Mutable text", idx.Search("mutable", 1)[0].Text) +} + +func TestAX7_KeywordIndex_Len_Good(t *core.T) { + idx := NewKeywordIndex([]Chunk{{Text: "alpha"}, {Text: "beta"}}) + + core.AssertEqual(t, 2, idx.Len()) + core.AssertGreater(t, idx.Len(), 1) +} + +func TestAX7_KeywordIndex_Len_Bad(t *core.T) { + var idx *KeywordIndex + + core.AssertEqual(t, 0, idx.Len()) + core.AssertFalse(t, idx.Len() > 0) +} + +func TestAX7_KeywordIndex_Len_Ugly(t *core.T) { + idx := NewKeywordIndex([]Chunk{{Text: ""}}) + + core.AssertEqual(t, 1, idx.Len()) + core.AssertEmpty(t, idx.Search("missing", 5)) +} + +func TestAX7_KeywordIndex_Search_Good(t *core.T) { + idx := NewKeywordIndex([]Chunk{{Text: "Kubernetes deployment", Section: "Ops", Index: 4}}) + results := idx.Search("kubernetes", 5) + + core.AssertLen(t, results, 1) + core.AssertEqual(t, 4, results[0].ChunkIndex) +} + +func TestAX7_KeywordIndex_Search_Bad(t *core.T) { + idx := NewKeywordIndex([]Chunk{{Text: "Kubernetes deployment"}}) + results := idx.Search("zzzz", 5) + + core.AssertEmpty(t, results) + core.AssertEqual(t, 0, len(results)) +} + +func TestAX7_KeywordIndex_Search_Ugly(t *core.T) { + idx := NewKeywordIndex([]Chunk{{Text: "golang golang golang"}, {Text: "golang deployment"}}) + results := idx.Search("golang golang", 1) + + core.AssertLen(t, results, 1) + core.AssertContains(t, results[0].Text, "golang") +} + +func TestAX7_SearchKeywords_Bad(t *core.T) { + results := SearchKeywords(nil, "kubernetes", 5) + + core.AssertEmpty(t, results) + core.AssertEqual(t, 0, len(results)) +} + +func TestAX7_SearchKeywords_Ugly(t *core.T) { + chunks := []Chunk{{Text: "alpha beta", Index: 0}, {Text: "alpha gamma", Index: 1}} + results := SearchKeywords(chunks, "alpha alpha", 10) + + core.AssertLen(t, results, 2) + core.AssertGreaterOrEqual(t, results[0].Score, results[1].Score) +} + +func TestAX7_SearchKeywordsSeq_Good(t *core.T) { + var results []KeywordResult + for result := range SearchKeywordsSeq([]Chunk{{Text: "searchable deployment", Index: 0}}, "deployment", 3) { + results = append(results, result) + } + + core.AssertLen(t, results, 1) + core.AssertEqual(t, 0, results[0].ChunkIndex) +} + +func TestAX7_SearchKeywordsSeq_Bad(t *core.T) { + var results []KeywordResult + for result := range SearchKeywordsSeq(nil, "deployment", 3) { + results = append(results, result) + } + + core.AssertEmpty(t, results) + core.AssertEqual(t, 0, len(results)) +} + +func TestAX7_SearchKeywordsSeq_Ugly(t *core.T) { + count := 0 + for range SearchKeywordsSeq([]Chunk{{Text: "alpha beta"}, {Text: "alpha gamma"}}, "alpha", 2) { + count++ + break + } + + core.AssertEqual(t, 1, count) + core.AssertTrue(t, count < 2) +} + +func TestAX7_KeywordFilter_Bad(t *core.T) { + results := []QueryResult{{Text: "alpha", Score: 0.5}} + filtered := KeywordFilter(results, nil) + + core.AssertEqual(t, results, filtered) + core.AssertEqual(t, float32(0.5), filtered[0].Score) +} + +func TestAX7_KeywordFilter_Ugly(t *core.T) { + results := []QueryResult{{Text: "Kubernetes deployment", Score: 1}, {Text: "Other", Score: 1}} + filtered := KeywordFilter(results, []string{"kubernetes", "kubernetes", ""}) + + core.AssertEqual(t, "Kubernetes deployment", filtered[0].Text) + core.AssertGreater(t, filtered[0].Score, filtered[1].Score) +} + +func TestAX7_KeywordFilterSeq_Bad(t *core.T) { + var results []QueryResult + for result := range KeywordFilterSeq(nil, []string{"alpha"}) { + results = append(results, result) + } + + core.AssertEmpty(t, results) + core.AssertEqual(t, 0, len(results)) +} + +func TestAX7_KeywordFilterSeq_Ugly(t *core.T) { + var results []QueryResult + input := []QueryResult{{Text: "alpha match", Score: 0.5}, {Text: "beta", Score: 0.6}} + for result := range KeywordFilterSeq(input, []string{"alpha"}) { + results = append(results, result) + } + + core.AssertLen(t, results, 2) + core.AssertGreater(t, results[0].Score, float32(0.5)) +} + +func TestAX7_QueryResult_GetText_Good(t *core.T) { + result := QueryResult{Text: "answer text"} + + core.AssertEqual(t, "answer text", result.GetText()) + core.AssertNotEmpty(t, result.GetText()) +} + +func TestAX7_QueryResult_GetText_Bad(t *core.T) { + result := QueryResult{} + + core.AssertEqual(t, "", result.GetText()) + core.AssertEmpty(t, result.GetText()) +} + +func TestAX7_QueryResult_GetText_Ugly(t *core.T) { + result := QueryResult{Text: "&text"} + + core.AssertContains(t, result.GetText(), "&") + core.AssertEqual(t, "&text", result.GetText()) +} + +func TestAX7_QueryResult_GetScore_Good(t *core.T) { + result := QueryResult{Score: 0.8} + + core.AssertEqual(t, float32(0.8), result.GetScore()) + core.AssertGreater(t, result.GetScore(), float32(0)) +} + +func TestAX7_QueryResult_GetScore_Bad(t *core.T) { + result := QueryResult{} + + core.AssertEqual(t, float32(0), result.GetScore()) + core.AssertFalse(t, result.GetScore() > 0) +} + +func TestAX7_QueryResult_GetScore_Ugly(t *core.T) { + result := QueryResult{Score: -0.2} + + core.AssertEqual(t, float32(-0.2), result.GetScore()) + core.AssertLess(t, result.GetScore(), float32(0)) +} + +func TestAX7_QueryResult_GetSource_Good(t *core.T) { + result := QueryResult{Source: "docs/source.md"} + + core.AssertEqual(t, "docs/source.md", result.GetSource()) + core.AssertContains(t, result.GetSource(), "source") +} + +func TestAX7_QueryResult_GetSource_Bad(t *core.T) { + result := QueryResult{} + + core.AssertEqual(t, "", result.GetSource()) + core.AssertEmpty(t, result.GetSource()) +} + +func TestAX7_QueryResult_GetSource_Ugly(t *core.T) { + result := QueryResult{Source: "docs/source with spaces.md"} + + core.AssertContains(t, result.GetSource(), "spaces") + core.AssertEqual(t, "docs/source with spaces.md", result.GetSource()) +} + +func TestAX7_QueryResult_HasChunkIndex_Good(t *core.T) { + result := QueryResult{ChunkIndex: 0, ChunkIndexPresent: true} + + core.AssertTrue(t, result.HasChunkIndex()) + core.AssertEqual(t, 0, result.GetChunkIndex()) +} + +func TestAX7_QueryResult_HasChunkIndex_Bad(t *core.T) { + result := QueryResult{} + + core.AssertFalse(t, result.HasChunkIndex()) + core.AssertEqual(t, missingChunkIndex, result.GetChunkIndex()) +} + +func TestAX7_QueryResult_HasChunkIndex_Ugly(t *core.T) { + result := QueryResult{Index: 9, IndexPresent: true} + + core.AssertTrue(t, result.HasChunkIndex()) + core.AssertEqual(t, 9, result.GetChunkIndex()) +} + +func TestAX7_QueryResult_GetChunkIndex_Good(t *core.T) { + result := QueryResult{ChunkIndex: 5, ChunkIndexPresent: true} + + core.AssertEqual(t, 5, result.GetChunkIndex()) + core.AssertTrue(t, result.HasChunkIndex()) +} + +func TestAX7_QueryResult_GetChunkIndex_Bad(t *core.T) { + result := QueryResult{} + + core.AssertEqual(t, missingChunkIndex, result.GetChunkIndex()) + core.AssertFalse(t, result.HasChunkIndex()) +} + +func TestAX7_QueryResult_GetChunkIndex_Ugly(t *core.T) { + result := QueryResult{Index: 0, IndexPresent: true} + + core.AssertEqual(t, 0, result.GetChunkIndex()) + core.AssertTrue(t, result.HasChunkIndex()) +} + +func TestAX7_DefaultQueryConfig_Bad(t *core.T) { + cfg := DefaultQueryConfig() + + core.AssertNotEqual(t, "", cfg.Collection) + core.AssertNotEqual(t, uint64(0), cfg.Limit) +} + +func TestAX7_DefaultQueryConfig_Ugly(t *core.T) { + cfg := DefaultQueryConfig() + cfg.Collection = "mutated" + + core.AssertEqual(t, "hostuk-docs", DefaultQueryConfig().Collection) + core.AssertEqual(t, "mutated", cfg.Collection) +} + +func TestAX7_Rank_Good(t *core.T) { + results := []QueryResult{{Text: "low", Score: 0.1}, {Text: "high", Score: 0.9}} + ranked := Rank(results, 1) + + core.AssertLen(t, ranked, 1) + core.AssertEqual(t, "high", ranked[0].Text) +} + +func TestAX7_Rank_Bad(t *core.T) { + ranked := Rank([]QueryResult{{Text: "ignored", Score: 1}}, 0) + + core.AssertEmpty(t, ranked) + core.AssertEqual(t, 0, len(ranked)) +} + +func TestAX7_Rank_Ugly(t *core.T) { + results := []QueryResult{{Text: "dup", Source: "a.md", ChunkIndex: 1, ChunkIndexPresent: true, Score: 0.9}, {Text: "dup", Source: "a.md", ChunkIndex: 1, ChunkIndexPresent: true, Score: 0.8}} + ranked := Rank(results, 5) + + core.AssertLen(t, ranked, 1) + core.AssertEqual(t, float32(0.9), ranked[0].Score) +} + +func TestAX7_Query_Bad(t *core.T) { + store := newMockVectorStore() + store.searchErr = core.NewError("search failed") + _, err := Query(core.Background(), store, newMockEmbedder(2), "query", DefaultQueryConfig()) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "error searching") +} + +func TestAX7_Query_Ugly(t *core.T) { + store := newMockVectorStore() + store.searchFunc = func(string, []float32, uint64, map[string]string) ([]SearchResult, error) { + return []SearchResult{{Score: 0.1, Payload: map[string]any{"text": "low"}}}, nil + } + results, err := Query(core.Background(), store, newMockEmbedder(2), "query", QueryConfig{Collection: "docs", Limit: 5, Threshold: 0.9}) + + core.AssertNoError(t, err) + core.AssertEmpty(t, results) +} + +func TestAX7_QuerySeq_Good(t *core.T) { + store := newMockVectorStore() + store.searchFunc = func(string, []float32, uint64, map[string]string) ([]SearchResult, error) { + return []SearchResult{{Score: 0.9, Payload: map[string]any{"text": "hit", "source": "a.md", "chunk_index": 0}}}, nil + } + seq, err := QuerySeq(core.Background(), store, newMockEmbedder(2), "query", QueryConfig{Collection: "docs", Limit: 5, Threshold: 0.1}) + + var results []QueryResult + for result := range seq { + results = append(results, result) + } + core.AssertNoError(t, err) + core.AssertLen(t, results, 1) +} + +func TestAX7_QuerySeq_Bad(t *core.T) { + embedder := newMockEmbedder(2) + embedder.embedErr = core.NewError("embed failed") + seq, err := QuerySeq(core.Background(), newMockVectorStore(), embedder, "query", DefaultQueryConfig()) + + core.AssertError(t, err) + core.AssertNil(t, seq) +} + +func TestAX7_QuerySeq_Ugly(t *core.T) { + store := newMockVectorStore() + store.searchFunc = func(string, []float32, uint64, map[string]string) ([]SearchResult, error) { + return []SearchResult{{Score: 1, Payload: map[string]any{"text": "Kubernetes"}}, {Score: 0.95, Payload: map[string]any{"text": "Other"}}}, nil + } + seq, err := QuerySeq(core.Background(), store, newMockEmbedder(2), "kubernetes", QueryConfig{Collection: "docs", Limit: 5, Threshold: 0.1, Keywords: true}) + + var results []QueryResult + for result := range seq { + results = append(results, result) + } + core.AssertNoError(t, err) + core.AssertEqual(t, "Kubernetes", results[0].Text) +} + +func TestAX7_QueryWith_Bad(t *core.T) { + embedder := newMockEmbedder(2) + embedder.embedErr = core.NewError("embed failed") + _, err := QueryWith(core.Background(), newMockVectorStore(), embedder, "query", "docs", 3) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "embedding") +} + +func TestAX7_QueryWith_Ugly(t *core.T) { + store := newMockVectorStore() + store.searchFunc = func(string, []float32, uint64, map[string]string) ([]SearchResult, error) { + return []SearchResult{{Score: 1, Payload: map[string]any{"text": "hit"}}}, nil + } + results, err := QueryWith(core.Background(), store, newMockEmbedder(2), "query", "docs", -1) + + core.AssertNoError(t, err) + core.AssertEmpty(t, results) +} + +func TestAX7_QueryContextWith_Bad(t *core.T) { + embedder := newMockEmbedder(2) + embedder.embedErr = core.NewError("embed failed") + text, err := QueryContextWith(core.Background(), newMockVectorStore(), embedder, "query", "docs", 3) + + core.AssertError(t, err) + core.AssertEqual(t, "", text) +} + +func TestAX7_QueryContextWith_Ugly(t *core.T) { + store := newMockVectorStore() + store.searchFunc = func(string, []float32, uint64, map[string]string) ([]SearchResult, error) { + return nil, nil + } + text, err := QueryContextWith(core.Background(), store, newMockEmbedder(2), "query", "docs", 3) + + core.AssertNoError(t, err) + core.AssertEqual(t, "", text) +} + +func TestAX7_FormatResultsText_Bad(t *core.T) { + text := FormatResultsText(nil) + + core.AssertEqual(t, "No results found.", text) + core.AssertNotContains(t, text, "Result 1") +} + +func TestAX7_FormatResultsText_Ugly(t *core.T) { + text := FormatResultsText([]QueryResult{{Text: "", Source: "", Category: "", Score: 0}}) + + core.AssertContains(t, text, "score: 0.00") + core.AssertContains(t, text, "Category:") +} + +func TestAX7_FormatResultsContext_Bad(t *core.T) { + context := FormatResultsContext(nil) + + core.AssertEqual(t, "", context) + core.AssertEmpty(t, context) +} + +func TestAX7_FormatResultsContext_Ugly(t *core.T) { + context := FormatResultsContext([]QueryResult{{Text: "&", Source: "a&b.md", Section: "\"sec\""}}) + + core.AssertContains(t, context, "<tag>&") + core.AssertContains(t, context, "a&b.md") +} + +func TestAX7_FormatResultsJSON_Bad(t *core.T) { + json := FormatResultsJSON(nil) + + core.AssertEqual(t, "[]", json) + core.AssertLen(t, json, 2) +} + +func TestAX7_FormatResultsJSON_Ugly(t *core.T) { + json := FormatResultsJSON([]QueryResult{{Text: "line\nquote\"", Source: "a.md", Score: 0.123456}}) + + core.AssertContains(t, json, "0.1235") + core.AssertContains(t, json, `quote\"`) +} + +func TestAX7_JoinResults_Bad(t *core.T) { + output := JoinResults[QueryResult](nil) + + core.AssertEqual(t, "", output) + core.AssertEmpty(t, output) +} + +func TestAX7_JoinResults_Ugly(t *core.T) { + output := JoinResults([]QueryResult{{Text: " Alpha "}, {Text: ""}, {Text: "\nBeta\n"}}) + + core.AssertEqual(t, "Alpha\n\nBeta", output) + core.AssertNotContains(t, output, "\n\n\n") +} diff --git a/ax7_ollama_test.go b/ax7_ollama_test.go new file mode 100644 index 0000000..9666641 --- /dev/null +++ b/ax7_ollama_test.go @@ -0,0 +1,221 @@ +package rag + +import ( + "net/http" + "net/http/httptest" + "net/url" + + core "dappco.re/go" + "github.com/ollama/ollama/api" +) + +func ax7OllamaClient(t *core.T, handler http.HandlerFunc) (*OllamaClient, func()) { + t.Helper() + server := httptest.NewServer(handler) + baseURL, err := url.Parse(server.URL) + core.RequireNoError(t, err) + + return &OllamaClient{ + client: api.NewClient(baseURL, server.Client()), + config: OllamaConfig{Model: "nomic-embed-text"}, + }, server.Close +} + +func TestAX7_DefaultOllamaConfig_Bad(t *core.T) { + cfg := DefaultOllamaConfig() + + core.AssertNotEqual(t, "", cfg.Host) + core.AssertNotEqual(t, 0, cfg.Port) +} + +func TestAX7_DefaultOllamaConfig_Ugly(t *core.T) { + cfg := DefaultOllamaConfig() + cfg.Model = "mutated" + + core.AssertEqual(t, "nomic-embed-text", DefaultOllamaConfig().Model) + core.AssertEqual(t, "mutated", cfg.Model) +} + +func TestAX7_NewOllamaClient_Bad(t *core.T) { + client, err := NewOllamaClient(OllamaConfig{Host: "localhost", Port: 0}) + + core.AssertError(t, err) + core.AssertNil(t, client) +} + +func TestAX7_NewOllamaClient_Ugly(t *core.T) { + client, err := NewOllamaClient(OllamaConfig{Host: "localhost", Port: 11434, Model: ""}) + + core.AssertNoError(t, err) + core.AssertEqual(t, "", client.Model()) +} + +func TestAX7_NewOllamaEmbedder_Good(t *core.T) { + client, err := NewOllamaEmbedder("http://localhost:11434", "custom-model") + + core.AssertNoError(t, err) + core.AssertEqual(t, "custom-model", client.Model()) +} + +func TestAX7_NewOllamaEmbedder_Bad(t *core.T) { + client, err := NewOllamaEmbedder("http://[::1", "custom-model") + + core.AssertError(t, err) + core.AssertNil(t, client) +} + +func TestAX7_NewOllamaEmbedder_Ugly(t *core.T) { + client, err := NewOllamaEmbedder("", "") + + core.AssertNoError(t, err) + core.AssertEqual(t, "nomic-embed-text", client.Model()) +} + +func TestAX7_OllamaClient_Embed_Good(t *core.T) { + client, closeServer := ax7OllamaClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"embeddings":[[0.1,0.2]]}`)) + }) + defer closeServer() + + vector, err := client.Embed(core.Background(), "hello") + core.AssertNoError(t, err) + core.AssertEqual(t, []float32{0.1, 0.2}, vector) +} + +func TestAX7_OllamaClient_Embed_Bad(t *core.T) { + client, closeServer := ax7OllamaClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"embeddings":[]}`)) + }) + defer closeServer() + + vector, err := client.Embed(core.Background(), "hello") + core.AssertError(t, err) + core.AssertNil(t, vector) +} + +func TestAX7_OllamaClient_Embed_Ugly(t *core.T) { + client, closeServer := ax7OllamaClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{not-json}`)) + }) + defer closeServer() + + vector, err := client.Embed(core.Background(), "hello") + core.AssertError(t, err) + core.AssertNil(t, vector) +} + +func TestAX7_OllamaClient_EmbedBatch_Good(t *core.T) { + calls := 0 + client, closeServer := ax7OllamaClient(t, func(w http.ResponseWriter, _ *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"embeddings":[[0.1,0.2]]}`)) + }) + defer closeServer() + + vectors, err := client.EmbedBatch(core.Background(), []string{"first", "second"}) + core.AssertNoError(t, err) + core.AssertLen(t, vectors, 2) + core.AssertEqual(t, 2, calls) +} + +func TestAX7_OllamaClient_EmbedBatch_Bad(t *core.T) { + client, closeServer := ax7OllamaClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"embeddings":[]}`)) + }) + defer closeServer() + + vectors, err := client.EmbedBatch(core.Background(), []string{"first"}) + core.AssertError(t, err) + core.AssertNil(t, vectors) +} + +func TestAX7_OllamaClient_EmbedBatch_Ugly(t *core.T) { + client := &OllamaClient{} + vectors, err := client.EmbedBatch(core.Background(), nil) + + core.AssertNoError(t, err) + core.AssertEmpty(t, vectors) +} + +func TestAX7_OllamaClient_EmbedDimension_Good(t *core.T) { + client := &OllamaClient{config: OllamaConfig{Model: "mxbai-embed-large"}} + + core.AssertEqual(t, uint64(1024), client.EmbedDimension()) + core.AssertGreater(t, client.EmbedDimension(), uint64(768)) +} + +func TestAX7_OllamaClient_EmbedDimension_Bad(t *core.T) { + client := &OllamaClient{config: OllamaConfig{Model: "unknown"}} + + core.AssertEqual(t, uint64(768), client.EmbedDimension()) + core.AssertNotEqual(t, uint64(1024), client.EmbedDimension()) +} + +func TestAX7_OllamaClient_EmbedDimension_Ugly(t *core.T) { + client := &OllamaClient{config: OllamaConfig{Model: ""}} + + core.AssertEqual(t, uint64(768), client.EmbedDimension()) + core.AssertGreater(t, client.EmbedDimension(), uint64(0)) +} + +func TestAX7_OllamaClient_Model_Good(t *core.T) { + client := &OllamaClient{config: OllamaConfig{Model: "nomic-embed-text"}} + + core.AssertEqual(t, "nomic-embed-text", client.Model()) + core.AssertNotEmpty(t, client.Model()) +} + +func TestAX7_OllamaClient_Model_Bad(t *core.T) { + client := &OllamaClient{} + + core.AssertEqual(t, "", client.Model()) + core.AssertEmpty(t, client.Model()) +} + +func TestAX7_OllamaClient_Model_Ugly(t *core.T) { + client := &OllamaClient{config: OllamaConfig{Model: "model/with:tag"}} + + core.AssertEqual(t, "model/with:tag", client.Model()) + core.AssertContains(t, client.Model(), ":tag") +} + +func TestAX7_OllamaClient_VerifyModel_Good(t *core.T) { + client, closeServer := ax7OllamaClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"embeddings":[[0.1]]}`)) + }) + defer closeServer() + + err := client.VerifyModel(core.Background()) + core.AssertNoError(t, err) + core.AssertNil(t, err) +} + +func TestAX7_OllamaClient_VerifyModel_Bad(t *core.T) { + client, closeServer := ax7OllamaClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"embeddings":[]}`)) + }) + defer closeServer() + + err := client.VerifyModel(core.Background()) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "not available") +} + +func TestAX7_OllamaClient_VerifyModel_Ugly(t *core.T) { + client, closeServer := ax7OllamaClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`server failed`)) + }) + defer closeServer() + + err := client.VerifyModel(core.Background()) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "nomic-embed-text") +} diff --git a/ax7_qdrant_test.go b/ax7_qdrant_test.go new file mode 100644 index 0000000..2a43b11 --- /dev/null +++ b/ax7_qdrant_test.go @@ -0,0 +1,535 @@ +package rag + +import ( + core "dappco.re/go" + "github.com/qdrant/go-client/qdrant" +) + +type ax7QdrantAPI struct { + closeCalled bool + healthErr error + closeErr error + + collections []string + listErr error + exists bool + existsErr error + createErr error + deleteErr error + info *qdrant.CollectionInfo + infoErr error + upsertErr error + queryErr error + + created *qdrant.CreateCollection + deleted string + upsert *qdrant.UpsertPoints + query *qdrant.QueryPoints + results []*qdrant.ScoredPoint +} + +func (f *ax7QdrantAPI) Close() error { + f.closeCalled = true + return f.closeErr +} + +func (f *ax7QdrantAPI) HealthCheck(core.Context) (*qdrant.HealthCheckReply, error) { + return &qdrant.HealthCheckReply{Title: "qdrant"}, f.healthErr +} + +func (f *ax7QdrantAPI) ListCollections(core.Context) ([]string, error) { + return f.collections, f.listErr +} + +func (f *ax7QdrantAPI) CollectionExists(core.Context, string) (bool, error) { + return f.exists, f.existsErr +} + +func (f *ax7QdrantAPI) CreateCollection(_ core.Context, request *qdrant.CreateCollection) error { + f.created = request + return f.createErr +} + +func (f *ax7QdrantAPI) DeleteCollection(_ core.Context, name string) error { + f.deleted = name + return f.deleteErr +} + +func (f *ax7QdrantAPI) GetCollectionInfo(core.Context, string) (*qdrant.CollectionInfo, error) { + return f.info, f.infoErr +} + +func (f *ax7QdrantAPI) Upsert(_ core.Context, request *qdrant.UpsertPoints) (*qdrant.UpdateResult, error) { + f.upsert = request + return &qdrant.UpdateResult{}, f.upsertErr +} + +func (f *ax7QdrantAPI) Query(_ core.Context, request *qdrant.QueryPoints) ([]*qdrant.ScoredPoint, error) { + f.query = request + return f.results, f.queryErr +} + +func ax7CollectionInfo(points uint64, size uint64, status qdrant.CollectionStatus) *qdrant.CollectionInfo { + return &qdrant.CollectionInfo{ + Status: status, + PointsCount: qdrant.PtrOf(points), + Config: &qdrant.CollectionConfig{ + Params: &qdrant.CollectionParams{ + VectorsConfig: qdrant.NewVectorsConfig(&qdrant.VectorParams{Size: size}), + }, + }, + } +} + +func TestAX7_DefaultQdrantConfig_Bad(t *core.T) { + cfg := DefaultQdrantConfig() + + core.AssertNotEqual(t, "", cfg.Host) + core.AssertNotEqual(t, 6333, cfg.Port) +} + +func TestAX7_DefaultQdrantConfig_Ugly(t *core.T) { + cfg := DefaultQdrantConfig() + cfg.Host = "mutated" + + core.AssertEqual(t, "localhost", DefaultQdrantConfig().Host) + core.AssertEqual(t, "mutated", cfg.Host) +} + +func TestAX7_NewQdrantClient_Good(t *core.T) { + client, err := NewQdrantClient(DefaultQdrantConfig()) + defer func() { + if client != nil { + _ = client.Close() + } + }() + + core.AssertNoError(t, err) + core.AssertNotNil(t, client) +} + +func TestAX7_NewQdrantClient_Bad(t *core.T) { + client, err := NewQdrantClient(QdrantConfig{Host: "bad host", Port: -1}) + + core.AssertError(t, err) + core.AssertNil(t, client) +} + +func TestAX7_NewQdrantClient_Ugly(t *core.T) { + client, err := NewQdrantClient(QdrantConfig{Host: "localhost", Port: 6334, APIKey: "token", UseTLS: true}) + defer func() { + if client != nil { + _ = client.Close() + } + }() + + core.AssertNoError(t, err) + core.AssertTrue(t, client.config.UseTLS) +} + +func TestAX7_NewQdrantStore_Good(t *core.T) { + client, err := NewQdrantStore("http://localhost:6333") + defer func() { + if client != nil { + _ = client.Close() + } + }() + + core.AssertNoError(t, err) + core.AssertEqual(t, 6334, client.config.Port) +} + +func TestAX7_NewQdrantStore_Bad(t *core.T) { + client, err := NewQdrantStore("http://[::1") + + core.AssertError(t, err) + core.AssertNil(t, client) +} + +func TestAX7_NewQdrantStore_Ugly(t *core.T) { + client, err := NewQdrantStore("") + defer func() { + if client != nil { + _ = client.Close() + } + }() + + core.AssertNoError(t, err) + core.AssertEqual(t, "localhost", client.config.Host) +} + +func TestAX7_QdrantClient_Close_Good(t *core.T) { + fake := &ax7QdrantAPI{} + err := (&QdrantClient{client: fake}).Close() + + core.AssertNoError(t, err) + core.AssertTrue(t, fake.closeCalled) +} + +func TestAX7_QdrantClient_Close_Bad(t *core.T) { + err := (&QdrantClient{}).Close() + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "not initialized") +} + +func TestAX7_QdrantClient_Close_Ugly(t *core.T) { + fake := &ax7QdrantAPI{closeErr: core.NewError("close failed")} + err := (&QdrantClient{client: fake}).Close() + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "close failed") +} + +func TestAX7_QdrantClient_HealthCheck_Good(t *core.T) { + err := (&QdrantClient{client: &ax7QdrantAPI{}}).HealthCheck(core.Background()) + + core.AssertNoError(t, err) + core.AssertNil(t, err) +} + +func TestAX7_QdrantClient_HealthCheck_Bad(t *core.T) { + err := (&QdrantClient{}).HealthCheck(core.Background()) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "not initialized") +} + +func TestAX7_QdrantClient_HealthCheck_Ugly(t *core.T) { + err := (&QdrantClient{client: &ax7QdrantAPI{healthErr: core.NewError("down")}}).HealthCheck(core.Background()) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "down") +} + +func TestAX7_QdrantClient_ListCollections_Good(t *core.T) { + fake := &ax7QdrantAPI{collections: []string{"alpha", "bravo"}} + names, err := (&QdrantClient{client: fake}).ListCollections(core.Background()) + + core.AssertNoError(t, err) + core.AssertEqual(t, []string{"alpha", "bravo"}, names) +} + +func TestAX7_QdrantClient_ListCollections_Bad(t *core.T) { + names, err := (&QdrantClient{}).ListCollections(core.Background()) + + core.AssertError(t, err) + core.AssertNil(t, names) +} + +func TestAX7_QdrantClient_ListCollections_Ugly(t *core.T) { + fake := &ax7QdrantAPI{listErr: core.NewError("list failed")} + names, err := (&QdrantClient{client: fake}).ListCollections(core.Background()) + + core.AssertError(t, err) + core.AssertNil(t, names) +} + +func TestAX7_QdrantClient_CollectionExists_Good(t *core.T) { + exists, err := (&QdrantClient{client: &ax7QdrantAPI{exists: true}}).CollectionExists(core.Background(), "docs") + + core.AssertNoError(t, err) + core.AssertTrue(t, exists) +} + +func TestAX7_QdrantClient_CollectionExists_Bad(t *core.T) { + exists, err := (&QdrantClient{}).CollectionExists(core.Background(), "docs") + + core.AssertError(t, err) + core.AssertFalse(t, exists) +} + +func TestAX7_QdrantClient_CollectionExists_Ugly(t *core.T) { + exists, err := (&QdrantClient{client: &ax7QdrantAPI{existsErr: core.NewError("exists failed")}}).CollectionExists(core.Background(), "docs") + + core.AssertError(t, err) + core.AssertFalse(t, exists) +} + +func TestAX7_QdrantClient_CreateCollection_Good(t *core.T) { + fake := &ax7QdrantAPI{} + err := (&QdrantClient{client: fake}).CreateCollection(core.Background(), "docs", 768) + + core.AssertNoError(t, err) + core.AssertEqual(t, uint64(768), fake.created.GetVectorsConfig().GetParams().GetSize()) +} + +func TestAX7_QdrantClient_CreateCollection_Bad(t *core.T) { + err := (&QdrantClient{}).CreateCollection(core.Background(), "docs", 768) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "not initialized") +} + +func TestAX7_QdrantClient_CreateCollection_Ugly(t *core.T) { + err := (&QdrantClient{client: &ax7QdrantAPI{createErr: core.NewError("create failed")}}).CreateCollection(core.Background(), "", 0) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "create failed") +} + +func TestAX7_QdrantClient_DeleteCollection_Good(t *core.T) { + fake := &ax7QdrantAPI{} + err := (&QdrantClient{client: fake}).DeleteCollection(core.Background(), "docs") + + core.AssertNoError(t, err) + core.AssertEqual(t, "docs", fake.deleted) +} + +func TestAX7_QdrantClient_DeleteCollection_Bad(t *core.T) { + err := (&QdrantClient{}).DeleteCollection(core.Background(), "docs") + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "not initialized") +} + +func TestAX7_QdrantClient_DeleteCollection_Ugly(t *core.T) { + err := (&QdrantClient{client: &ax7QdrantAPI{deleteErr: core.NewError("delete failed")}}).DeleteCollection(core.Background(), "docs") + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "delete failed") +} + +func TestAX7_QdrantClient_CollectionInfo_Good(t *core.T) { + info, err := (&QdrantClient{client: &ax7QdrantAPI{info: ax7CollectionInfo(3, 768, qdrant.CollectionStatus_Green)}}).CollectionInfo(core.Background(), "docs") + + core.AssertNoError(t, err) + core.AssertEqual(t, uint64(768), info.VectorSize) +} + +func TestAX7_QdrantClient_CollectionInfo_Bad(t *core.T) { + info, err := (&QdrantClient{}).CollectionInfo(core.Background(), "docs") + + core.AssertError(t, err) + core.AssertNil(t, info) +} + +func TestAX7_QdrantClient_CollectionInfo_Ugly(t *core.T) { + info, err := (&QdrantClient{client: &ax7QdrantAPI{infoErr: core.NewError("info failed")}}).CollectionInfo(core.Background(), "docs") + + core.AssertError(t, err) + core.AssertNil(t, info) +} + +func TestAX7_QdrantClient_UpsertPoints_Good(t *core.T) { + fake := &ax7QdrantAPI{} + err := (&QdrantClient{client: fake}).UpsertPoints(core.Background(), "docs", []Point{{ID: "id", Vector: []float32{0.1}, Payload: map[string]any{"text": "alpha"}}}) + + core.AssertNoError(t, err) + core.AssertEqual(t, "docs", fake.upsert.GetCollectionName()) +} + +func TestAX7_QdrantClient_UpsertPoints_Bad(t *core.T) { + err := (&QdrantClient{}).UpsertPoints(core.Background(), "docs", []Point{{ID: "id", Vector: []float32{0.1}}}) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "not initialized") +} + +func TestAX7_QdrantClient_UpsertPoints_Ugly(t *core.T) { + err := (&QdrantClient{}).UpsertPoints(core.Background(), "docs", nil) + + core.AssertNoError(t, err) + core.AssertNil(t, err) +} + +func TestAX7_QdrantClient_Search_Good(t *core.T) { + fake := &ax7QdrantAPI{results: []*qdrant.ScoredPoint{{ + Id: qdrant.NewID("point-1"), + Score: 0.9, + Payload: map[string]*qdrant.Value{ + "text": qdrant.NewValueString("alpha"), + "source": qdrant.NewValueString("a.md"), + "section": qdrant.NewValueString("Intro"), + "category": qdrant.NewValueString("docs"), + "chunk_index": qdrant.NewValueInt(2), + }, + }}} + results, err := (&QdrantClient{client: fake}).Search(core.Background(), "docs", []float32{0.1}, 5, map[string]string{"category": "docs"}) + + core.AssertNoError(t, err) + core.AssertEqual(t, "alpha", results[0].Text) +} + +func TestAX7_QdrantClient_Search_Bad(t *core.T) { + results, err := (&QdrantClient{}).Search(core.Background(), "docs", []float32{0.1}, 5, nil) + + core.AssertError(t, err) + core.AssertNil(t, results) +} + +func TestAX7_QdrantClient_Search_Ugly(t *core.T) { + results, err := (&QdrantClient{client: &ax7QdrantAPI{queryErr: core.NewError("query failed")}}).Search(core.Background(), "docs", nil, 0, nil) + + core.AssertError(t, err) + core.AssertNil(t, results) +} + +func TestAX7_QdrantClient_Add_Good(t *core.T) { + fake := &ax7QdrantAPI{} + err := (&QdrantClient{client: fake}).Add(core.Background(), "docs", []Vector{{ID: "id", Values: []float32{0.2}, Payload: map[string]any{"text": "alpha"}}}) + + core.AssertNoError(t, err) + core.AssertEqual(t, "docs", fake.upsert.GetCollectionName()) +} + +func TestAX7_QdrantClient_Add_Bad(t *core.T) { + err := (&QdrantClient{}).Add(core.Background(), "docs", []Vector{{ID: "id", Values: []float32{0.2}}}) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "not initialized") +} + +func TestAX7_QdrantClient_Add_Ugly(t *core.T) { + err := (&QdrantClient{}).Add(core.Background(), "docs", nil) + + core.AssertNoError(t, err) + core.AssertNil(t, err) +} + +func TestAX7_SearchResult_GetText_Good(t *core.T) { + result := SearchResult{Text: "direct text", Payload: map[string]any{"text": "payload text"}} + + core.AssertEqual(t, "direct text", result.GetText()) + core.AssertNotEmpty(t, result.GetText()) +} + +func TestAX7_SearchResult_GetText_Bad(t *core.T) { + result := SearchResult{} + + core.AssertEqual(t, "", result.GetText()) + core.AssertEmpty(t, result.GetText()) +} + +func TestAX7_SearchResult_GetText_Ugly(t *core.T) { + result := SearchResult{Payload: map[string]any{"text": "payload text"}} + + core.AssertEqual(t, "payload text", result.GetText()) + core.AssertContains(t, result.GetText(), "payload") +} + +func TestAX7_SearchResult_GetScore_Good(t *core.T) { + result := SearchResult{Score: 0.6} + + core.AssertEqual(t, float32(0.6), result.GetScore()) + core.AssertGreater(t, result.GetScore(), float32(0)) +} + +func TestAX7_SearchResult_GetScore_Bad(t *core.T) { + result := SearchResult{} + + core.AssertEqual(t, float32(0), result.GetScore()) + core.AssertFalse(t, result.GetScore() > 0) +} + +func TestAX7_SearchResult_GetScore_Ugly(t *core.T) { + result := SearchResult{Score: -0.5} + + core.AssertEqual(t, float32(-0.5), result.GetScore()) + core.AssertLess(t, result.GetScore(), float32(0)) +} + +func TestAX7_SearchResult_GetSource_Good(t *core.T) { + result := SearchResult{Source: "direct.md", Payload: map[string]any{"source": "payload.md"}} + + core.AssertEqual(t, "direct.md", result.GetSource()) + core.AssertNotEmpty(t, result.GetSource()) +} + +func TestAX7_SearchResult_GetSource_Bad(t *core.T) { + result := SearchResult{} + + core.AssertEqual(t, "", result.GetSource()) + core.AssertEmpty(t, result.GetSource()) +} + +func TestAX7_SearchResult_GetSource_Ugly(t *core.T) { + result := SearchResult{Payload: map[string]any{"source": "payload.md"}} + + core.AssertEqual(t, "payload.md", result.GetSource()) + core.AssertContains(t, result.GetSource(), "payload") +} + +func TestAX7_SearchResult_GetSection_Good(t *core.T) { + result := SearchResult{Section: "Intro", Payload: map[string]any{"section": "Payload"}} + + core.AssertEqual(t, "Intro", result.GetSection()) + core.AssertNotEmpty(t, result.GetSection()) +} + +func TestAX7_SearchResult_GetSection_Bad(t *core.T) { + result := SearchResult{} + + core.AssertEqual(t, "", result.GetSection()) + core.AssertEmpty(t, result.GetSection()) +} + +func TestAX7_SearchResult_GetSection_Ugly(t *core.T) { + result := SearchResult{Payload: map[string]any{"section": "Payload"}} + + core.AssertEqual(t, "Payload", result.GetSection()) + core.AssertContains(t, result.GetSection(), "Payload") +} + +func TestAX7_SearchResult_GetCategory_Good(t *core.T) { + result := SearchResult{Category: "docs", Payload: map[string]any{"category": "payload"}} + + core.AssertEqual(t, "docs", result.GetCategory()) + core.AssertNotEmpty(t, result.GetCategory()) +} + +func TestAX7_SearchResult_GetCategory_Bad(t *core.T) { + result := SearchResult{} + + core.AssertEqual(t, "", result.GetCategory()) + core.AssertEmpty(t, result.GetCategory()) +} + +func TestAX7_SearchResult_GetCategory_Ugly(t *core.T) { + result := SearchResult{Payload: map[string]any{"category": "payload"}} + + core.AssertEqual(t, "payload", result.GetCategory()) + core.AssertContains(t, result.GetCategory(), "payload") +} + +func TestAX7_SearchResult_HasChunkIndex_Good(t *core.T) { + result := SearchResult{ChunkIndex: 0, ChunkIndexPresent: true} + + core.AssertTrue(t, result.HasChunkIndex()) + core.AssertEqual(t, 0, result.GetChunkIndex()) +} + +func TestAX7_SearchResult_HasChunkIndex_Bad(t *core.T) { + result := SearchResult{} + + core.AssertFalse(t, result.HasChunkIndex()) + core.AssertEqual(t, missingChunkIndex, result.GetChunkIndex()) +} + +func TestAX7_SearchResult_HasChunkIndex_Ugly(t *core.T) { + result := SearchResult{Payload: map[string]any{"chunk_index": float64(7)}} + + core.AssertTrue(t, result.HasChunkIndex()) + core.AssertEqual(t, 7, result.GetChunkIndex()) +} + +func TestAX7_SearchResult_GetChunkIndex_Good(t *core.T) { + result := SearchResult{ChunkIndex: 5, ChunkIndexPresent: true} + + core.AssertEqual(t, 5, result.GetChunkIndex()) + core.AssertTrue(t, result.HasChunkIndex()) +} + +func TestAX7_SearchResult_GetChunkIndex_Bad(t *core.T) { + result := SearchResult{} + + core.AssertEqual(t, missingChunkIndex, result.GetChunkIndex()) + core.AssertFalse(t, result.HasChunkIndex()) +} + +func TestAX7_SearchResult_GetChunkIndex_Ugly(t *core.T) { + result := SearchResult{Index: 3, IndexPresent: true, Payload: map[string]any{"chunk_index": 8}} + + core.AssertEqual(t, 3, result.GetChunkIndex()) + core.AssertTrue(t, result.HasChunkIndex()) +} diff --git a/benchmark_gpu_test.go b/benchmark_gpu_test.go index a050043..17fb8a9 100644 --- a/benchmark_gpu_test.go +++ b/benchmark_gpu_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "dappco.re/go/core" + "dappco.re/go" ) // --- Embedding benchmarks (Ollama on ROCm GPU) --- diff --git a/benchmark_test.go b/benchmark_test.go index 7af03d2..d5da7ff 100644 --- a/benchmark_test.go +++ b/benchmark_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "dappco.re/go/core" + "dappco.re/go" ) // generateMarkdownDoc creates a ~10KB markdown document for benchmarking. diff --git a/chunk.go b/chunk.go index 081dd3d..0d40115 100644 --- a/chunk.go +++ b/chunk.go @@ -6,14 +6,16 @@ import ( "slices" "unicode" - "dappco.re/go/core" + "dappco.re/go" ) // ChunkConfig holds chunking configuration. // cfg := ChunkConfig{Size: 500, Overlap: 50} type ChunkConfig struct { - Size int // Characters per chunk - Overlap int // Overlap between chunks + // Size is the target maximum number of characters per chunk. + Size int + // Overlap is the number of characters carried from one chunk into the next. + Overlap int } // DefaultChunkConfig returns default chunking configuration. @@ -28,9 +30,12 @@ func DefaultChunkConfig() ChunkConfig { // Chunk represents a text chunk with metadata. // chunk := Chunk{Text: "Go uses goroutines.", Section: "Concurrency", Index: 0} type Chunk struct { - Text string + // Text is the chunk body sent to embedding and search. + Text string + // Section is the Markdown heading path associated with the chunk. Section string - Index int + // Index is the chunk's zero-based position within the source document. + Index int } // ChunkMarkdown splits markdown text into chunks by sections and paragraphs. @@ -225,7 +230,9 @@ func splitLongTextByWords(text string, size int) iter.Seq[string] { } } - _ = flush() + if !flush() { + return + } } } diff --git a/chunk_test.go b/chunk_test.go index 3abdfb8..a654555 100644 --- a/chunk_test.go +++ b/chunk_test.go @@ -3,7 +3,7 @@ package rag import ( "testing" - "dappco.re/go/core" + "dappco.re/go" ) func TestChunk_ChunkMarkdown_Good_SmallSection(t *testing.T) { @@ -352,7 +352,10 @@ func TestChunk_DefaultChunkConfig_Good(t *testing.T) { } func TestChunk_FileExtensions_Good(t *testing.T) { - assertEqual(t, []string{".md", ".markdown", ".pdf", ".txt"}, FileExtensions()) + extensions := FileExtensions() + + assertEqual(t, []string{".md", ".markdown", ".pdf", ".txt"}, extensions) + assertLen(t, extensions, 4) } func TestChunk_DefaultIngestConfig_Good(t *testing.T) { diff --git a/cmd/rag/ax7_commands_test.go b/cmd/rag/ax7_commands_test.go new file mode 100644 index 0000000..4fa8438 --- /dev/null +++ b/cmd/rag/ax7_commands_test.go @@ -0,0 +1,34 @@ +package rag + +import ( + core "dappco.re/go" + "dappco.re/go/cli/pkg/cli" +) + +func TestAX7_AddRAGSubcommands_Good(t *core.T) { + parent := cli.NewGroup("root", "", "") + AddRAGSubcommands(parent) + + core.AssertLen(t, parent.Commands(), 1) + core.AssertEqual(t, "rag", parent.Commands()[0].Name()) +} + +func TestAX7_AddRAGSubcommands_Bad(t *core.T) { + called := false + core.AssertPanics(t, func() { + called = true + AddRAGSubcommands(nil) + }) + + core.AssertTrue(t, called) + core.AssertNotNil(t, ragCmd) +} + +func TestAX7_AddRAGSubcommands_Ugly(t *core.T) { + parent := cli.NewGroup("root", "", "") + AddRAGSubcommands(parent) + AddRAGSubcommands(parent) + + core.AssertLen(t, parent.Commands(), 1) + core.AssertLen(t, parent.Commands()[0].Commands(), 3) +} diff --git a/cmd/rag/cmd_collections.go b/cmd/rag/cmd_collections.go index a2136d9..6ae4c90 100644 --- a/cmd/rag/cmd_collections.go +++ b/cmd/rag/cmd_collections.go @@ -4,7 +4,7 @@ import ( "context" "io" - "dappco.re/go/core" + "dappco.re/go" "dappco.re/go/cli/pkg/cli" "dappco.re/go/i18n" "dappco.re/go/rag" @@ -23,6 +23,7 @@ var collectionsCmd = &cli.Command{ RunE: runCollections, } +// runCollections handles list, stats, and delete collection operations. func runCollections(cmd *cli.Command, args []string) error { ctx := context.Background() out := cmd.OutOrStdout() diff --git a/cmd/rag/cmd_ingest.go b/cmd/rag/cmd_ingest.go index 9b02f3f..fa81fe2 100644 --- a/cmd/rag/cmd_ingest.go +++ b/cmd/rag/cmd_ingest.go @@ -4,8 +4,8 @@ import ( "context" "io" + "dappco.re/go" "dappco.re/go/cli/pkg/cli" - "dappco.re/go/core" "dappco.re/go/i18n" "dappco.re/go/rag" ) @@ -25,6 +25,7 @@ var ingestCmd = &cli.Command{ RunE: runIngest, } +// runIngest validates local flags, connects clients, and ingests documents. func runIngest(cmd *cli.Command, args []string) error { directory := "." if len(args) > 0 { diff --git a/cmd/rag/cmd_query.go b/cmd/rag/cmd_query.go index 8777fae..ae5b679 100644 --- a/cmd/rag/cmd_query.go +++ b/cmd/rag/cmd_query.go @@ -4,8 +4,8 @@ import ( "context" "io" + "dappco.re/go" "dappco.re/go/cli/pkg/cli" - "dappco.re/go/core" "dappco.re/go/i18n" "dappco.re/go/rag" ) @@ -27,6 +27,7 @@ var queryCmd = &cli.Command{ RunE: runQuery, } +// runQuery embeds a question, searches Qdrant, and writes formatted results. func runQuery(cmd *cli.Command, args []string) error { question := args[0] ctx := context.Background() diff --git a/cmd/rag/cmd_rag.go b/cmd/rag/cmd_rag.go index 9abc652..b536743 100644 --- a/cmd/rag/cmd_rag.go +++ b/cmd/rag/cmd_rag.go @@ -4,8 +4,8 @@ import ( "strconv" "sync" + "dappco.re/go" "dappco.re/go/cli/pkg/cli" - "dappco.re/go/core" "dappco.re/go/i18n" ) @@ -27,6 +27,7 @@ var ragCmd = &cli.Command{ Long: i18n.T("cmd.rag.long"), } +// initFlags initialises persistent and subcommand flags once. func initFlags() { initFlagsOnce.Do(func() { // Qdrant connection flags (persistent) - defaults to localhost for local development @@ -79,6 +80,7 @@ func initFlags() { }) } +// envPortOrDefault returns an environment port override or the fallback port. func envPortOrDefault(name string, fallback int) int { value := core.Env(name) if value == "" { diff --git a/collections_test.go b/collections_test.go index 434edb0..15a440b 100644 --- a/collections_test.go +++ b/collections_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "dappco.re/go/core" + "dappco.re/go" ) // --- ListCollections tests --- diff --git a/endpoint.go b/endpoint.go index 4f99457..26bc3c4 100644 --- a/endpoint.go +++ b/endpoint.go @@ -4,7 +4,7 @@ import ( "net/url" "strconv" - "dappco.re/go/core" + "dappco.re/go" ) // parseEndpointURL normalises host-style endpoints into a parsed URL. @@ -20,6 +20,7 @@ func parseEndpointURL(endpoint string) (*url.URL, error) { return url.Parse(endpoint) } +// parseEndpointPort converts and validates a TCP port parsed from an endpoint. func parseEndpointPort(scope string, portText string) (int, error) { port, err := strconv.Atoi(portText) if err != nil { diff --git a/go.mod b/go.mod index 758906b..0bf245a 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module dappco.re/go/rag go 1.26.0 require ( + dappco.re/go v0.9.0 // Note: structured errors, formatting helpers, and filesystem wrappers used across the RAG package. dappco.re/go/cli v0.8.0-alpha.1 // Note: CLI command framework for rag ingest, query, and collection commands. - dappco.re/go/core v0.8.0-alpha.1 // Note: structured errors, formatting helpers, and filesystem wrappers used across the RAG package. dappco.re/go/i18n v0.8.0-alpha.1 // Note: localized CLI labels and messages for the rag command surface. github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 // Note: PDF text extraction lets .pdf documents enter the chunking pipeline. github.com/ollama/ollama v0.18.1 // Note: Ollama embeddings client backing the repository's Embedder implementation. @@ -12,45 +12,35 @@ require ( ) require ( + dappco.re/go/core v0.8.0-alpha.1 // indirect dappco.re/go/inference v0.8.0-alpha.1 // indirect dappco.re/go/log v0.8.0-alpha.1 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect - github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mailru/easyjson v0.9.2 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.21 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/otel v1.42.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.52.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.41.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect gonum.org/v1/gonum v0.17.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect google.golang.org/grpc v1.79.2 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace dappco.re/go/cli => ./internal/compat/cli + +replace dappco.re/go/i18n => github.com/dappcore/go-i18n v0.8.0-alpha.1 + +replace dappco.re/go/inference => github.com/dappcore/go-inference v0.8.0-alpha.1 + +replace dappco.re/go/log => github.com/dappcore/go-log v0.8.0-alpha.1 diff --git a/go.sum b/go.sum index bfd831e..61e7c0f 100644 --- a/go.sum +++ b/go.sum @@ -1,42 +1,22 @@ +dappco.re/go v0.9.0 h1:4ruZRNqKDDva8o6g65tYggjGVe42E6/lMZfVKXtr3p0= +dappco.re/go v0.9.0/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= -forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg= -forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs= -forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0= -forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ= -forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA= -forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8= -forge.lthn.ai/core/go-inference v0.1.6 h1:ce42zC0zO8PuISUyAukAN1NACEdWp5wF1mRgnh5+58E= -forge.lthn.ai/core/go-inference v0.1.6/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw= -forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= -forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= -github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= -github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= -github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= -github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/dappcore/go-i18n v0.8.0-alpha.1 h1:fHB8yWWp7M8UNRndo8owTgnVq+XrtNTU+n0R3HX0uPA= +github.com/dappcore/go-i18n v0.8.0-alpha.1/go.mod h1:aSfWSAW2EVh/aMbMplc27URnjl6DvRVvWfvRC2my7AY= +github.com/dappcore/go-inference v0.8.0-alpha.1 h1:pzJoaJI0FhzUakq7tZqD6VdgMoMiuFTflVXXHCQuI0I= +github.com/dappcore/go-inference v0.8.0-alpha.1/go.mod h1:rfNXLcfMilEI3nKpcdrC0PQKyUyaf6bDYseowgRwDP8= +github.com/dappcore/go-log v0.8.0-alpha.1 h1:OqZ9Njhz4fr+2BCHOgWxZZcPj/T46jN2UlOCytOCr2Y= +github.com/dappcore/go-log v0.8.0-alpha.1/go.mod h1:IC04Em9SfVTcXiWc1BqZDQfa1MtOuMDEermZkQcTz9c= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -55,28 +35,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8= github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= -github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ollama/ollama v0.18.1 h1:7K6anW64C2keASpToYfuOa00LuP8aCmofLKcT2c1mlY= github.com/ollama/ollama v0.18.1/go.mod h1:tCX4IMV8DHjl3zY0THxuEkpWDZSOchJpzTuLACpMwFw= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/qdrant/go-client v1.17.1 h1:7QmPwDddrHL3hC4NfycwtQlraVKRLcRi++BX6TTm+3g= github.com/qdrant/go-client v1.17.1/go.mod h1:n1h6GhkdAzcohoXt/5Z19I2yxbCkMA6Jejob3S6NZT8= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -85,10 +51,10 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= @@ -104,18 +70,14 @@ go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhn go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts= diff --git a/helpers.go b/helpers.go index f7cfa3a..a6bb238 100644 --- a/helpers.go +++ b/helpers.go @@ -4,11 +4,30 @@ import ( "context" "sync" - "dappco.re/go/core" + "dappco.re/go" ) const maxConcurrentEmbeddings = 8 +type defaultQdrantClient interface { + VectorStore + Close() error + HealthCheck(context.Context) error +} + +type defaultOllamaClient interface { + Embedder + VerifyModel(context.Context) error +} + +var newDefaultQdrantClient = func() (defaultQdrantClient, error) { + return NewQdrantClient(DefaultQdrantConfig()) +} + +var newDefaultOllamaClient = func() (defaultOllamaClient, error) { + return NewOllamaClient(DefaultOllamaConfig()) +} + // QueryWith queries the vector store using the provided embedder and store. // QueryWith(ctx, store, embedder, "how do goroutines work?", "project-docs", 5) func QueryWith(ctx context.Context, store VectorStore, embedder Embedder, question, collectionName string, topK int) ([]QueryResult, error) { @@ -55,13 +74,13 @@ func IngestFileWith(ctx context.Context, store VectorStore, embedder Embedder, f // QueryDocs queries the RAG database with default clients. // QueryDocs(ctx, "how do goroutines work?", "project-docs", 5) func QueryDocs(ctx context.Context, question, collectionName string, topK int) ([]QueryResult, error) { - qdrantClient, err := NewQdrantClient(DefaultQdrantConfig()) + qdrantClient, err := newDefaultQdrantClient() if err != nil { return nil, err } defer func() { _ = qdrantClient.Close() }() - ollamaClient, err := NewOllamaClient(DefaultOllamaConfig()) + ollamaClient, err := newDefaultOllamaClient() if err != nil { return nil, err } @@ -82,7 +101,7 @@ func QueryDocsContext(ctx context.Context, question, collectionName string, topK // IngestDirectory ingests all documents in a directory with default clients. // IngestDirectory(ctx, "./docs", "project-docs", true) func IngestDirectory(ctx context.Context, directory, collectionName string, recreateCollection bool) error { - qdrantClient, err := NewQdrantClient(DefaultQdrantConfig()) + qdrantClient, err := newDefaultQdrantClient() if err != nil { return err } @@ -92,7 +111,7 @@ func IngestDirectory(ctx context.Context, directory, collectionName string, recr return core.E("rag.IngestDirectory", "qdrant health check failed", err) } - ollamaClient, err := NewOllamaClient(DefaultOllamaConfig()) + ollamaClient, err := newDefaultOllamaClient() if err != nil { return err } @@ -107,7 +126,7 @@ func IngestDirectory(ctx context.Context, directory, collectionName string, recr // IngestSingleFile ingests a single file with default clients. // IngestSingleFile(ctx, "./docs/guide.md", "project-docs") func IngestSingleFile(ctx context.Context, filePath, collectionName string) (int, error) { - qdrantClient, err := NewQdrantClient(DefaultQdrantConfig()) + qdrantClient, err := newDefaultQdrantClient() if err != nil { return 0, err } @@ -117,7 +136,7 @@ func IngestSingleFile(ctx context.Context, filePath, collectionName string) (int return 0, core.E("rag.IngestSingleFile", "qdrant health check failed", err) } - ollamaClient, err := NewOllamaClient(DefaultOllamaConfig()) + ollamaClient, err := newDefaultOllamaClient() if err != nil { return 0, err } diff --git a/helpers_test.go b/helpers_test.go index ef38f05..c44d840 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "dappco.re/go/core" + "dappco.re/go" ) // --- QueryWith tests --- diff --git a/ingest.go b/ingest.go index e461a6a..11162da 100644 --- a/ingest.go +++ b/ingest.go @@ -6,19 +6,25 @@ import ( "io/fs" "slices" - "dappco.re/go/core" + "dappco.re/go" "github.com/ledongthuc/pdf" ) // IngestConfig holds ingestion configuration. // cfg := IngestConfig{Directory: "./docs", Collection: "project-docs", BatchSize: 100} type IngestConfig struct { - Directory string + // Directory is the root directory scanned for ingestible files. + Directory string + // Collection is the vector-store collection that receives ingested points. Collection string - Recreate bool - Verbose bool - BatchSize int - Chunk ChunkConfig + // Recreate deletes and recreates Collection before ingestion when true. + Recreate bool + // Verbose enables per-file progress output. + Verbose bool + // BatchSize controls embedding and upsert batch sizes. + BatchSize int + // Chunk configures Markdown chunking before embedding. + Chunk ChunkConfig } // DefaultIngestConfig returns default ingestion configuration. @@ -34,8 +40,11 @@ func DefaultIngestConfig() IngestConfig { // IngestStats holds statistics from ingestion. // stats := IngestStats{Files: 12, Chunks: 84, Errors: 0} type IngestStats struct { - Files int + // Files counts non-empty files processed by ingestion. + Files int + // Chunks counts chunks successfully embedded and queued for upsert. Chunks int + // Errors counts read and embedding failures that ingestion skipped. Errors int } @@ -229,6 +238,7 @@ func embedChunkBatch(ctx context.Context, embedder Embedder, texts []string) ([] return embeddings, errs } +// buildPoint converts a chunk and embedding into vector-store payload form. func buildPoint(source, category string, chunk Chunk, embedding []float32) Point { return Point{ ID: ChunkID(source, chunk.Index, chunk.Text), @@ -243,6 +253,7 @@ func buildPoint(source, category string, chunk Chunk, embedding []float32) Point } } +// collectMarkdownFiles appends ingestible file paths below currentPath. func collectMarkdownFiles(localFS *core.Fs, currentPath string, currentRel string, files *[]string) error { listResult := localFS.List(currentPath) if !listResult.OK { @@ -288,6 +299,7 @@ func collectMarkdownFiles(localFS *core.Fs, currentPath string, currentRel strin return nil } +// resultError extracts an error from a core.Result or creates a generic one. func resultError(result core.Result) error { if err, ok := result.Value.(error); ok { return err @@ -318,6 +330,7 @@ func readDocument(fs *core.Fs, filePath string) (string, error) { return readAsText(fs, filePath) } +// readAsText reads a file through core.Fs and validates the string payload. func readAsText(fs *core.Fs, filePath string) (string, error) { result := fs.Read(filePath) if !result.OK { diff --git a/ingest_test.go b/ingest_test.go index 274d50f..7da7aa0 100644 --- a/ingest_test.go +++ b/ingest_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "dappco.re/go/core" + "dappco.re/go" ) // --- Ingest (directory) tests with mocks --- diff --git a/integration_test.go b/integration_test.go index 2c2ef3b..eef86fa 100644 --- a/integration_test.go +++ b/integration_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "dappco.re/go/core" + "dappco.re/go" ) // skipIfServicesUnavailable skips the test if either Qdrant or Ollama is not diff --git a/internal/compat/cli/go.mod b/internal/compat/cli/go.mod new file mode 100644 index 0000000..9560dab --- /dev/null +++ b/internal/compat/cli/go.mod @@ -0,0 +1,10 @@ +module dappco.re/go/cli + +go 1.26.0 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/internal/compat/cli/go.sum b/internal/compat/cli/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/internal/compat/cli/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/compat/cli/pkg/cli/ax7_cli_test.go b/internal/compat/cli/pkg/cli/ax7_cli_test.go new file mode 100644 index 0000000..8ef3f92 --- /dev/null +++ b/internal/compat/cli/pkg/cli/ax7_cli_test.go @@ -0,0 +1,117 @@ +package cli + +import "testing" + +func TestAX7_ExactArgs_Good(t *testing.T) { + validator := ExactArgs(2) + err := validator(&Command{}, []string{"one", "two"}) + + if err != nil { + t.Fatalf("expected exact args to pass, got %v", err) + } +} + +func TestAX7_ExactArgs_Bad(t *testing.T) { + validator := ExactArgs(2) + err := validator(&Command{}, []string{"one"}) + + if err == nil { + t.Fatalf("expected exact args to reject wrong count") + } +} + +func TestAX7_ExactArgs_Ugly(t *testing.T) { + validator := ExactArgs(0) + err := validator(&Command{}, nil) + + if err != nil { + t.Fatalf("expected zero exact args to pass, got %v", err) + } +} + +func TestAX7_MaximumNArgs_Good(t *testing.T) { + validator := MaximumNArgs(2) + err := validator(&Command{}, []string{"one"}) + + if err != nil { + t.Fatalf("expected args below maximum to pass, got %v", err) + } +} + +func TestAX7_MaximumNArgs_Bad(t *testing.T) { + validator := MaximumNArgs(1) + err := validator(&Command{}, []string{"one", "two"}) + + if err == nil { + t.Fatalf("expected args above maximum to fail") + } +} + +func TestAX7_MaximumNArgs_Ugly(t *testing.T) { + validator := MaximumNArgs(0) + err := validator(&Command{}, nil) + + if err != nil { + t.Fatalf("expected nil args to satisfy zero maximum, got %v", err) + } +} + +func TestAX7_NewGroup_Good(t *testing.T) { + cmd := NewGroup("rag", "short", "long") + + if cmd.Use != "rag" { + t.Fatalf("want use rag, got %s", cmd.Use) + } + if cmd.Long != "long" { + t.Fatalf("want long text, got %s", cmd.Long) + } +} + +func TestAX7_NewGroup_Bad(t *testing.T) { + cmd := NewGroup("", "", "") + + if cmd.Use != "" { + t.Fatalf("want empty use, got %s", cmd.Use) + } + if cmd.Long != "" { + t.Fatalf("want empty long, got %s", cmd.Long) + } +} + +func TestAX7_NewGroup_Ugly(t *testing.T) { + cmd := NewGroup("rag", "", "") + + if cmd.Short != "" { + t.Fatalf("want empty short, got %s", cmd.Short) + } + if len(cmd.Commands()) != 0 { + t.Fatalf("want no child commands, got %d", len(cmd.Commands())) + } +} + +func TestAX7_Style_Render_Good(t *testing.T) { + style := Style{} + got := style.Render("Collections") + + if got != "Collections" { + t.Fatalf("want rendered text unchanged, got %s", got) + } +} + +func TestAX7_Style_Render_Bad(t *testing.T) { + style := Style{} + got := style.Render("") + + if got != "" { + t.Fatalf("want empty text unchanged, got %s", got) + } +} + +func TestAX7_Style_Render_Ugly(t *testing.T) { + style := Style{} + got := style.Render("emoji 😀") + + if got != "emoji 😀" { + t.Fatalf("want unicode text unchanged, got %s", got) + } +} diff --git a/internal/compat/cli/pkg/cli/cli.go b/internal/compat/cli/pkg/cli/cli.go new file mode 100644 index 0000000..9a0f1f4 --- /dev/null +++ b/internal/compat/cli/pkg/cli/cli.go @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package cli + +import "github.com/spf13/cobra" + +// Command is the command type used by the RAG CLI. +type Command = cobra.Command + +// PositionalArgs validates command positional arguments. +type PositionalArgs = cobra.PositionalArgs + +// ExactArgs returns a positional validator requiring exactly n arguments. +func ExactArgs(n int) PositionalArgs { + return cobra.ExactArgs(n) +} + +// MaximumNArgs returns a positional validator allowing at most n arguments. +func MaximumNArgs(n int) PositionalArgs { + return cobra.MaximumNArgs(n) +} + +// NewGroup creates a parent command with no run handler. +func NewGroup(use string, short string, long string) *Command { + cmd := &Command{ + Use: use, + Short: short, + } + if long != "" { + cmd.Long = long + } + return cmd +} + +// Style renders terminal text for command output. +type Style struct{} + +// Render returns text unchanged for the compatibility CLI surface. +func (Style) Render(text string) string { return text } + +var ( + // TitleStyle renders section titles. + TitleStyle Style + // ValueStyle renders named values. + ValueStyle Style + // ErrorStyle renders error values. + ErrorStyle Style + // DimStyle renders low-emphasis progress text. + DimStyle Style +) diff --git a/keyword.go b/keyword.go index 370047b..9f58807 100644 --- a/keyword.go +++ b/keyword.go @@ -5,7 +5,7 @@ import ( "math" "slices" - "dappco.re/go/core" + "dappco.re/go" ) // KeywordResult represents a keyword-search hit with TF-IDF score and @@ -13,11 +13,16 @@ import ( // // result := KeywordResult{Source: "docs/guide.md", Score: 0.42, Text: "..."} type KeywordResult struct { - Text string - Source string - Section string + // Text is the chunk text that matched the query terms. + Text string + // Source is the source document path when available. + Source string + // Section is the Markdown section attached to the chunk. + Section string + // ChunkIndex is the chunk's zero-based source position. ChunkIndex int - Score float32 + // Score is the TF-IDF relevance score. + Score float32 } // GetText returns the result text (satisfies textResult / rankedResult). diff --git a/mock_test.go b/mock_test.go index 69fa333..cf765a0 100644 --- a/mock_test.go +++ b/mock_test.go @@ -5,7 +5,7 @@ import ( "slices" "sync" - "dappco.re/go/core" + "dappco.re/go" ) // mockEmbedder is a test-only Embedder that returns deterministic vectors. @@ -23,10 +23,12 @@ type mockEmbedder struct { embedFunc func(text string) ([]float32, error) } +// newMockEmbedder creates a mock embedder with deterministic vector length. func newMockEmbedder(dimension uint64) *mockEmbedder { return &mockEmbedder{dimension: dimension} } +// Embed records text and returns either injected errors or deterministic vectors. func (m *mockEmbedder) Embed(ctx context.Context, text string) ([]float32, error) { m.mu.Lock() m.embedCalls = append(m.embedCalls, text) @@ -47,6 +49,7 @@ func (m *mockEmbedder) Embed(ctx context.Context, text string) ([]float32, error return vec, nil } +// EmbedBatch records a batch call and embeds each text in input order. func (m *mockEmbedder) EmbedBatch(ctx context.Context, texts []string) ([][]float32, error) { m.mu.Lock() m.batchCalls = append(m.batchCalls, texts) @@ -67,6 +70,7 @@ func (m *mockEmbedder) EmbedBatch(ctx context.Context, texts []string) ([][]floa return results, nil } +// EmbedDimension returns the configured mock vector dimension. func (m *mockEmbedder) EmbedDimension() uint64 { return m.dimension } @@ -124,6 +128,7 @@ type searchCall struct { Filter map[string]string } +// newMockVectorStore creates an empty in-memory vector store for tests. func newMockVectorStore() *mockVectorStore { return &mockVectorStore{ collections: make(map[string]uint64), @@ -131,6 +136,7 @@ func newMockVectorStore() *mockVectorStore { } } +// CreateCollection records collection creation and stores the requested vector size. func (m *mockVectorStore) CreateCollection(ctx context.Context, name string, vectorSize uint64) error { m.mu.Lock() defer m.mu.Unlock() @@ -145,6 +151,7 @@ func (m *mockVectorStore) CreateCollection(ctx context.Context, name string, vec return nil } +// CollectionExists records existence checks and reports whether the collection exists. func (m *mockVectorStore) CollectionExists(ctx context.Context, name string) (bool, error) { m.mu.Lock() defer m.mu.Unlock() @@ -159,6 +166,7 @@ func (m *mockVectorStore) CollectionExists(ctx context.Context, name string) (bo return exists, nil } +// DeleteCollection records deletion and removes the collection from memory. func (m *mockVectorStore) DeleteCollection(ctx context.Context, name string) error { m.mu.Lock() defer m.mu.Unlock() @@ -174,6 +182,7 @@ func (m *mockVectorStore) DeleteCollection(ctx context.Context, name string) err return nil } +// ListCollections records listing and returns collection names in stable order. func (m *mockVectorStore) ListCollections(ctx context.Context) ([]string, error) { m.mu.Lock() defer m.mu.Unlock() @@ -192,6 +201,7 @@ func (m *mockVectorStore) ListCollections(ctx context.Context) ([]string, error) return names, nil } +// CollectionInfo records metadata lookup and returns in-memory collection statistics. func (m *mockVectorStore) CollectionInfo(ctx context.Context, name string) (*CollectionInfo, error) { m.mu.Lock() defer m.mu.Unlock() @@ -220,6 +230,7 @@ func (m *mockVectorStore) CollectionInfo(ctx context.Context, name string) (*Col }, nil } +// UpsertPoints records upserts and appends points to the named collection. func (m *mockVectorStore) UpsertPoints(ctx context.Context, collection string, points []Point) error { m.mu.Lock() defer m.mu.Unlock() @@ -234,6 +245,7 @@ func (m *mockVectorStore) UpsertPoints(ctx context.Context, collection string, p return nil } +// Search records vector searches and returns either custom or stored mock results. func (m *mockVectorStore) Search(ctx context.Context, collection string, vector []float32, limit uint64, filter map[string]string) ([]SearchResult, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/ollama.go b/ollama.go index 1beffbb..08f2d58 100644 --- a/ollama.go +++ b/ollama.go @@ -10,17 +10,21 @@ import ( // Note: AX-6 - http.Client timeout is expressed as time.Duration; core has no duration primitive. "time" - "dappco.re/go/core" + "dappco.re/go" "github.com/ollama/ollama/api" ) // OllamaConfig holds Ollama connection configuration. // cfg := OllamaConfig{Host: "localhost", Port: 11434, Model: "nomic-embed-text"} type OllamaConfig struct { + // Scheme is the HTTP scheme used for the Ollama endpoint. Scheme string - Host string - Port int - Model string + // Host is the Ollama server hostname. + Host string + // Port is the Ollama server HTTP port. + Port int + // Model is the embedding model name used for requests. + Model string } // DefaultOllamaConfig returns default Ollama configuration. @@ -58,6 +62,9 @@ type OllamaClient struct { // NewOllamaClient creates a new Ollama client. // client, err := NewOllamaClient(DefaultOllamaConfig()) func NewOllamaClient(cfg OllamaConfig) (*OllamaClient, error) { + if cfg.Port < 1 || cfg.Port > 65535 { + return nil, core.E("rag.Ollama", core.Sprintf("port out of range: %d", cfg.Port), nil) + } scheme := cfg.Scheme if scheme == "" { scheme = "http" diff --git a/qdrant.go b/qdrant.go index 23ca001..a6092f1 100644 --- a/qdrant.go +++ b/qdrant.go @@ -5,16 +5,20 @@ package rag import ( "context" - "dappco.re/go/core" + "dappco.re/go" "github.com/qdrant/go-client/qdrant" ) // QdrantConfig holds Qdrant connection configuration. // cfg := QdrantConfig{Host: "localhost", Port: 6334, UseTLS: false} type QdrantConfig struct { - Host string - Port int + // Host is the Qdrant server hostname. + Host string + // Port is the Qdrant gRPC port. + Port int + // APIKey is the optional Qdrant API key. APIKey string + // UseTLS enables TLS when connecting to Qdrant. UseTLS bool } @@ -42,6 +46,7 @@ func NewQdrantStore(endpoint string) (*QdrantClient, error) { return NewQdrantClient(cfg) } +// normalizeQdrantGRPCPort maps Qdrant's common REST port to its gRPC port. func normalizeQdrantGRPCPort(port int) int { if port == 6333 { return 6334 @@ -49,6 +54,7 @@ func normalizeQdrantGRPCPort(port int) int { return port } +// qdrantConfigFromEndpoint parses a host or URL into Qdrant connection settings. func qdrantConfigFromEndpoint(endpoint string) (QdrantConfig, error) { cfg := DefaultQdrantConfig() parsed, err := parseEndpointURL(endpoint) @@ -83,20 +89,43 @@ func qdrantConfigFromEndpoint(endpoint string) (QdrantConfig, error) { // QdrantClient wraps the Qdrant Go client with convenience methods. // client, _ := NewQdrantClient(DefaultQdrantConfig()) type QdrantClient struct { - client *qdrant.Client + client qdrantClientAPI config QdrantConfig } +type qdrantClientAPI interface { + Close() error + HealthCheck(ctx context.Context) (*qdrant.HealthCheckReply, error) + ListCollections(ctx context.Context) ([]string, error) + CollectionExists(ctx context.Context, name string) (bool, error) + CreateCollection(ctx context.Context, request *qdrant.CreateCollection) error + DeleteCollection(ctx context.Context, name string) error + GetCollectionInfo(ctx context.Context, name string) (*qdrant.CollectionInfo, error) + Upsert(ctx context.Context, request *qdrant.UpsertPoints) (*qdrant.UpdateResult, error) + Query(ctx context.Context, request *qdrant.QueryPoints) ([]*qdrant.ScoredPoint, error) +} + +func (q *QdrantClient) api() (qdrantClientAPI, error) { + if q == nil || q.client == nil { + return nil, core.E("rag.Qdrant", "client is not initialized", nil) + } + return q.client, nil +} + // NewQdrantClient creates a new Qdrant client. // client, err := NewQdrantClient(DefaultQdrantConfig()) func NewQdrantClient(cfg QdrantConfig) (*QdrantClient, error) { + if cfg.Port < 1 || cfg.Port > 65535 { + return nil, core.E("rag.Qdrant", core.Sprintf("port out of range: %d", cfg.Port), nil) + } addr := core.Sprintf("%s:%d", cfg.Host, cfg.Port) client, err := qdrant.NewClient(&qdrant.Config{ - Host: cfg.Host, - Port: cfg.Port, - APIKey: cfg.APIKey, - UseTLS: cfg.UseTLS, + Host: cfg.Host, + Port: cfg.Port, + APIKey: cfg.APIKey, + UseTLS: cfg.UseTLS, + SkipCompatibilityCheck: true, }) if err != nil { return nil, core.E("rag.Qdrant", core.Sprintf("failed to connect to Qdrant at %s", addr), err) @@ -111,20 +140,32 @@ func NewQdrantClient(cfg QdrantConfig) (*QdrantClient, error) { // Close closes the Qdrant client connection. // defer client.Close() func (q *QdrantClient) Close() error { - return q.client.Close() + client, err := q.api() + if err != nil { + return err + } + return client.Close() } // HealthCheck verifies the connection to Qdrant. // client.HealthCheck(ctx) func (q *QdrantClient) HealthCheck(ctx context.Context) error { - _, err := q.client.HealthCheck(ctx) + client, err := q.api() + if err != nil { + return err + } + _, err = client.HealthCheck(ctx) return err } // ListCollections returns all collection names. // names, _ := client.ListCollections(ctx) func (q *QdrantClient) ListCollections(ctx context.Context) ([]string, error) { - resp, err := q.client.ListCollections(ctx) + client, err := q.api() + if err != nil { + return nil, err + } + resp, err := client.ListCollections(ctx) if err != nil { return nil, err } @@ -136,13 +177,21 @@ func (q *QdrantClient) ListCollections(ctx context.Context) ([]string, error) { // CollectionExists checks if a collection exists. // exists, _ := client.CollectionExists(ctx, "project-docs") func (q *QdrantClient) CollectionExists(ctx context.Context, name string) (bool, error) { - return q.client.CollectionExists(ctx, name) + client, err := q.api() + if err != nil { + return false, err + } + return client.CollectionExists(ctx, name) } // CreateCollection creates a new collection with cosine distance. // client.CreateCollection(ctx, "project-docs", 768) func (q *QdrantClient) CreateCollection(ctx context.Context, name string, vectorSize uint64) error { - return q.client.CreateCollection(ctx, &qdrant.CreateCollection{ + client, err := q.api() + if err != nil { + return err + } + return client.CreateCollection(ctx, &qdrant.CreateCollection{ CollectionName: name, VectorsConfig: qdrant.NewVectorsConfig(&qdrant.VectorParams{ Size: vectorSize, @@ -154,13 +203,21 @@ func (q *QdrantClient) CreateCollection(ctx context.Context, name string, vector // DeleteCollection deletes a collection. // client.DeleteCollection(ctx, "project-docs") func (q *QdrantClient) DeleteCollection(ctx context.Context, name string) error { - return q.client.DeleteCollection(ctx, name) + client, err := q.api() + if err != nil { + return err + } + return client.DeleteCollection(ctx, name) } // CollectionInfo returns backend-agnostic metadata about a collection. // info, _ := client.CollectionInfo(ctx, "project-docs") func (q *QdrantClient) CollectionInfo(ctx context.Context, name string) (*CollectionInfo, error) { - info, err := q.client.GetCollectionInfo(ctx, name) + client, err := q.api() + if err != nil { + return nil, err + } + info, err := client.GetCollectionInfo(ctx, name) if err != nil { return nil, err } @@ -209,8 +266,11 @@ func (q *QdrantClient) CollectionInfo(ctx context.Context, name string) (*Collec // Point represents a vector point with payload. // point := Point{ID: "chunk-1", Vector: []float32{0.1, 0.2}, Payload: map[string]any{"source": "docs/go.md"}} type Point struct { - ID string - Vector []float32 + // ID is the stable vector-store identifier for the point. + ID string + // Vector is the embedding stored in the collection. + Vector []float32 + // Payload stores source text and metadata alongside the vector. Payload map[string]any } @@ -220,6 +280,10 @@ func (q *QdrantClient) UpsertPoints(ctx context.Context, collection string, poin if len(points) == 0 { return nil } + client, err := q.api() + if err != nil { + return err + } qdrantPoints := make([]*qdrant.PointStruct, len(points)) for i, p := range points { @@ -230,7 +294,7 @@ func (q *QdrantClient) UpsertPoints(ctx context.Context, collection string, poin } } - _, err := q.client.Upsert(ctx, &qdrant.UpsertPoints{ + _, err = client.Upsert(ctx, &qdrant.UpsertPoints{ CollectionName: collection, Points: qdrantPoints, }) @@ -240,17 +304,28 @@ func (q *QdrantClient) UpsertPoints(ctx context.Context, collection string, poin // SearchResult represents a low-level vector search hit. // result := SearchResult{ID: "chunk-1", Score: 0.92, Text: "...", Source: "docs.md"} type SearchResult struct { - ID string - Score float32 - Text string - Source string - Section string - Category string - ChunkIndex int - Index int + // ID is the vector-store point identifier. + ID string + // Score is the similarity score returned by the vector store. + Score float32 + // Text is the denormalised chunk text, when present. + Text string + // Source is the denormalised source path, when present. + Source string + // Section is the denormalised Markdown section, when present. + Section string + // Category is the denormalised document category, when present. + Category string + // ChunkIndex is the denormalised source chunk index. + ChunkIndex int + // Index is a compatibility alias for ChunkIndex. + Index int + // ChunkIndexPresent distinguishes an explicit zero chunk index from missing metadata. ChunkIndexPresent bool - IndexPresent bool - Payload map[string]any + // IndexPresent distinguishes an explicit zero index from missing metadata. + IndexPresent bool + // Payload is the decoded raw vector-store payload. + Payload map[string]any } // GetText returns the text field from Payload (satisfies textResult / rankedResult). @@ -342,6 +417,10 @@ func (r SearchResult) GetCategory() string { // results, _ := client.Search(ctx, "project-docs", vector, 5, nil) // results, _ := client.Search(ctx, "project-docs", vector, 5, map[string]string{"source": "docs"}) func (q *QdrantClient) Search(ctx context.Context, collection string, vector []float32, limit uint64, filter map[string]string) ([]SearchResult, error) { + client, err := q.api() + if err != nil { + return nil, err + } query := &qdrant.QueryPoints{ CollectionName: collection, Query: qdrant.NewQuery(vector...), @@ -359,7 +438,7 @@ func (q *QdrantClient) Search(ctx context.Context, collection string, vector []f } } - resp, err := q.client.Query(ctx, query) + resp, err := client.Query(ctx, query) if err != nil { return nil, err } @@ -392,6 +471,7 @@ func (q *QdrantClient) Search(ctx context.Context, collection string, vector []f return results, nil } +// payloadChunkIndex extracts chunk_index from decoded Qdrant payload values. func payloadChunkIndex(payload map[string]any) (int, bool) { if payload == nil { return 0, false diff --git a/qdrant_integration_test.go b/qdrant_integration_test.go index eb8c1d5..f55f4dc 100644 --- a/qdrant_integration_test.go +++ b/qdrant_integration_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "dappco.re/go/core" + "dappco.re/go" ) // testCollectionName returns a unique collection name for the current test run diff --git a/query.go b/query.go index 7f412d1..882ae5f 100644 --- a/query.go +++ b/query.go @@ -7,7 +7,7 @@ import ( "math" "slices" - "dappco.re/go/core" + "dappco.re/go" ) const missingChunkIndex = -1 @@ -15,11 +15,16 @@ const missingChunkIndex = -1 // QueryConfig holds query configuration. // cfg := QueryConfig{Collection: "project-docs", Limit: 5, Threshold: 0.6} type QueryConfig struct { + // Collection is the vector-store collection to search. Collection string - Limit uint64 - Threshold float32 // Minimum similarity score (0-1) - Category string // Filter by category - Keywords bool // When true, extract keywords from query and boost matching results + // Limit is the maximum number of ranked results returned. + Limit uint64 + // Threshold is the minimum similarity score from 0 to 1. + Threshold float32 + // Category filters search results by document category when set. + Category string + // Keywords enables keyword extraction and boosting when true. + Keywords bool } // DefaultQueryConfig returns default query configuration. @@ -35,15 +40,24 @@ func DefaultQueryConfig() QueryConfig { // QueryResult represents a query result with metadata. // result := QueryResult{Source: "docs/go.md", Section: "Concurrency", Score: 0.92} type QueryResult struct { - Text string - Source string - Section string - Category string - ChunkIndex int - Index int + // Text is the matched chunk text. + Text string + // Source is the source document path. + Source string + // Section is the source Markdown section. + Section string + // Category is the inferred document category. + Category string + // ChunkIndex is the chunk's zero-based source position. + ChunkIndex int + // Index is a compatibility alias for ChunkIndex. + Index int + // ChunkIndexPresent distinguishes explicit zero from missing chunk metadata. ChunkIndexPresent bool - IndexPresent bool - Score float32 + // IndexPresent distinguishes explicit zero from missing index metadata. + IndexPresent bool + // Score is the vector or boosted relevance score. + Score float32 } // GetText returns the result text (satisfies the rankedResult interface). @@ -159,6 +173,7 @@ func rankKey[T rankedResult](result T) string { return core.Sprintf("score:%f", result.GetScore()) } +// resultHasChunkIndex reports whether a ranked result carries explicit chunk metadata. func resultHasChunkIndex[T rankedResult](result T) bool { if indexed, ok := any(result).(chunkIndexedResult); ok { return indexed.HasChunkIndex() diff --git a/query_test.go b/query_test.go index 460e81d..9421ac4 100644 --- a/query_test.go +++ b/query_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "dappco.re/go/core" + "dappco.re/go" ) // --- DefaultQueryConfig tests --- diff --git a/rfc_features_test.go b/rfc_features_test.go index ae86457..04fef96 100644 --- a/rfc_features_test.go +++ b/rfc_features_test.go @@ -4,6 +4,7 @@ import ( "testing" ) +// TestChunkBySentences covers sentence-aligned chunking required by the RFC surface. func TestChunkBySentences(t *testing.T) { chunks := ChunkBySentences("One. Two. Three.", ChunkConfig{Size: 8, Overlap: 0}) @@ -13,6 +14,7 @@ func TestChunkBySentences(t *testing.T) { assertEqual(t, "Three.", chunks[2].Text) } +// TestChunkByParagraphs covers paragraph-aligned chunking required by the RFC surface. func TestChunkByParagraphs(t *testing.T) { text := "First paragraph.\n\nSecond paragraph." chunks := ChunkByParagraphs(text, ChunkConfig{Size: 100, Overlap: 0}) @@ -22,6 +24,7 @@ func TestChunkByParagraphs(t *testing.T) { assertContains(t, chunks[0].Text, "Second paragraph.") } +// TestRank verifies score ordering and duplicate removal for query results. func TestRank(t *testing.T) { results := []QueryResult{ {Text: "duplicate low", Source: "a.md", ChunkIndex: 1, Score: 0.4}, @@ -36,6 +39,7 @@ func TestRank(t *testing.T) { assertEqual(t, "other", ranked[1].Text) } +// TestJoinResults verifies prompt text joining for query results. func TestJoinResults(t *testing.T) { results := []QueryResult{ {Text: "alpha"}, @@ -45,6 +49,7 @@ func TestJoinResults(t *testing.T) { assertEqual(t, "alpha\n\nbeta", JoinResults(results)) } +// TestJoinResultsSearchResult verifies prompt text joining for search results. func TestJoinResultsSearchResult(t *testing.T) { results := []SearchResult{ {Text: "alpha"}, @@ -54,6 +59,7 @@ func TestJoinResultsSearchResult(t *testing.T) { assertEqual(t, "alpha\n\nbeta", JoinResults(results)) } +// TestRankSearchResult verifies generic ranking against Qdrant search results. func TestRankSearchResult(t *testing.T) { results := []SearchResult{ {Text: "duplicate low", Source: "a.md", Index: 1, Score: 0.4}, @@ -68,6 +74,7 @@ func TestRankSearchResult(t *testing.T) { assertEqual(t, "other", ranked[1].Text) } +// TestKeywordIndex verifies TF-IDF keyword lookup over chunk text. func TestKeywordIndex(t *testing.T) { index := NewKeywordIndex([]Chunk{ {Text: "Kubernetes deployment guide", Section: "Ops", Index: 0}, @@ -81,6 +88,7 @@ func TestKeywordIndex(t *testing.T) { assertEqual(t, "Ops", hits[0].Section) } +// TestEndpointConfigParsing verifies endpoint URL parsing for Qdrant and Ollama clients. func TestEndpointConfigParsing(t *testing.T) { qcfg, err := qdrantConfigFromEndpoint("https://example.com:6333") assertNoError(t, err) diff --git a/specs/forge.lthn.ai/core/go-rag.md b/specs/forge.lthn.ai/core/go-rag.md index 1252687..e10496e 100644 --- a/specs/forge.lthn.ai/core/go-rag.md +++ b/specs/forge.lthn.ai/core/go-rag.md @@ -62,7 +62,7 @@ Connection settings for Qdrant. `Host` and `Port` identify the server, `APIKey` ### `QdrantClient` `type QdrantClient struct { /* unexported fields */ }` -Concrete `VectorStore` implementation backed by `github.com/qdrant/go-client/qdrant`. The value wraps an initialized Qdrant client plus the configuration used to create it. +Concrete `VectorStore` implementation backed by `github.com/qdrant/go-client/qdrant`. The value wraps an initialised Qdrant client plus the configuration used to create it. ### `QueryConfig` `type QueryConfig struct { Collection string; Limit uint64; Threshold float32; Category string; Keywords bool }` diff --git a/test_helpers_test.go b/test_helpers_test.go index baeb4a0..2809ce6 100644 --- a/test_helpers_test.go +++ b/test_helpers_test.go @@ -12,111 +12,133 @@ type assertionHelper struct{} var testAssert assertionHelper +// assertNoError fails the test when err is non-nil. func assertNoError(t testing.TB, err error, msgAndArgs ...any) bool { t.Helper() return testAssert.NoError(t, err, msgAndArgs...) } +// assertError fails the test when err is nil. func assertError(t testing.TB, err error, msgAndArgs ...any) bool { t.Helper() return testAssert.Error(t, err, msgAndArgs...) } +// assertEqual fails the test when want and got differ. func assertEqual(t testing.TB, want any, got any, msgAndArgs ...any) bool { t.Helper() return testAssert.Equal(t, want, got, msgAndArgs...) } +// assertNotEqual fails the test when want and got match. func assertNotEqual(t testing.TB, want any, got any, msgAndArgs ...any) bool { t.Helper() return testAssert.NotEqual(t, want, got, msgAndArgs...) } +// assertTrue fails the test when value is false. func assertTrue(t testing.TB, value bool, msgAndArgs ...any) bool { t.Helper() return testAssert.True(t, value, msgAndArgs...) } +// assertTruef fails the test when value is false and formats the supplied message. func assertTruef(t testing.TB, value bool, msg string, args ...any) bool { t.Helper() return testAssert.Truef(t, value, msg, args...) } +// assertFalse fails the test when value is true. func assertFalse(t testing.TB, value bool, msgAndArgs ...any) bool { t.Helper() return testAssert.False(t, value, msgAndArgs...) } +// assertNil fails the test when value is not nil. func assertNil(t testing.TB, value any, msgAndArgs ...any) bool { t.Helper() return testAssert.Nil(t, value, msgAndArgs...) } +// assertNotNil fails the test when value is nil. func assertNotNil(t testing.TB, value any, msgAndArgs ...any) bool { t.Helper() return testAssert.NotNil(t, value, msgAndArgs...) } +// assertLen fails the test when value does not expose the expected length. func assertLen(t testing.TB, value any, want int, msgAndArgs ...any) bool { t.Helper() return testAssert.Len(t, value, want, msgAndArgs...) } +// assertContains fails the test when value does not contain element. func assertContains(t testing.TB, value any, element any, msgAndArgs ...any) bool { t.Helper() return testAssert.Contains(t, value, element, msgAndArgs...) } +// assertNotContains fails the test when value contains element. func assertNotContains(t testing.TB, value any, element any, msgAndArgs ...any) bool { t.Helper() return testAssert.NotContains(t, value, element, msgAndArgs...) } +// assertEmpty fails the test when value is not empty. func assertEmpty(t testing.TB, value any, msgAndArgs ...any) bool { t.Helper() return testAssert.Empty(t, value, msgAndArgs...) } +// assertNotEmpty fails the test when value is empty. func assertNotEmpty(t testing.TB, value any, msgAndArgs ...any) bool { t.Helper() return testAssert.NotEmpty(t, value, msgAndArgs...) } +// assertNotEmptyf fails the test when value is empty and formats the supplied message. func assertNotEmptyf(t testing.TB, value any, msg string, args ...any) bool { t.Helper() return testAssert.NotEmptyf(t, value, msg, args...) } +// assertGreater fails the test when got is not greater than want. func assertGreater(t testing.TB, got any, want any, msgAndArgs ...any) bool { t.Helper() return testAssert.Greater(t, got, want, msgAndArgs...) } +// assertGreaterf fails the test when got is not greater than want and formats the supplied message. func assertGreaterf(t testing.TB, got any, want any, msg string, args ...any) bool { t.Helper() return testAssert.Greaterf(t, got, want, msg, args...) } +// assertGreaterOrEqual fails the test when got is less than want. func assertGreaterOrEqual(t testing.TB, got any, want any, msgAndArgs ...any) bool { t.Helper() return testAssert.GreaterOrEqual(t, got, want, msgAndArgs...) } +// assertLess fails the test when got is not less than want. func assertLess(t testing.TB, got any, want any, msgAndArgs ...any) bool { t.Helper() return testAssert.Less(t, got, want, msgAndArgs...) } +// assertLessOrEqual fails the test when got is greater than want. func assertLessOrEqual(t testing.TB, got any, want any, msgAndArgs ...any) bool { t.Helper() return testAssert.LessOrEqual(t, got, want, msgAndArgs...) } +// assertInDelta fails the test when got is outside delta from want. func assertInDelta(t testing.TB, want any, got any, delta any, msgAndArgs ...any) bool { t.Helper() return testAssert.InDelta(t, want, got, delta, msgAndArgs...) } +// NoError reports whether err is nil. func (assertionHelper) NoError(t testing.TB, err error, msgAndArgs ...any) bool { t.Helper() if err != nil { @@ -125,6 +147,7 @@ func (assertionHelper) NoError(t testing.TB, err error, msgAndArgs ...any) bool return true } +// Error reports whether err is non-nil. func (assertionHelper) Error(t testing.TB, err error, msgAndArgs ...any) bool { t.Helper() if err == nil { @@ -133,6 +156,7 @@ func (assertionHelper) Error(t testing.TB, err error, msgAndArgs ...any) bool { return true } +// Equal reports whether want and got are deeply equal. func (assertionHelper) Equal(t testing.TB, want any, got any, msgAndArgs ...any) bool { t.Helper() if !reflect.DeepEqual(want, got) { @@ -141,6 +165,7 @@ func (assertionHelper) Equal(t testing.TB, want any, got any, msgAndArgs ...any) return true } +// NotEqual reports whether want and got differ. func (assertionHelper) NotEqual(t testing.TB, want any, got any, msgAndArgs ...any) bool { t.Helper() if reflect.DeepEqual(want, got) { @@ -149,6 +174,7 @@ func (assertionHelper) NotEqual(t testing.TB, want any, got any, msgAndArgs ...a return true } +// True reports whether value is true. func (assertionHelper) True(t testing.TB, value bool, msgAndArgs ...any) bool { t.Helper() if !value { @@ -157,11 +183,13 @@ func (assertionHelper) True(t testing.TB, value bool, msgAndArgs ...any) bool { return true } +// Truef reports whether value is true and formats the supplied message on failure. func (h assertionHelper) Truef(t testing.TB, value bool, msg string, args ...any) bool { t.Helper() return h.True(t, value, append([]any{msg}, args...)...) } +// False reports whether value is false. func (assertionHelper) False(t testing.TB, value bool, msgAndArgs ...any) bool { t.Helper() if value { @@ -170,6 +198,7 @@ func (assertionHelper) False(t testing.TB, value bool, msgAndArgs ...any) bool { return true } +// Nil reports whether value is nil, including typed nils. func (assertionHelper) Nil(t testing.TB, value any, msgAndArgs ...any) bool { t.Helper() if !isNil(value) { @@ -178,6 +207,7 @@ func (assertionHelper) Nil(t testing.TB, value any, msgAndArgs ...any) bool { return true } +// NotNil reports whether value is not nil, including typed nils. func (assertionHelper) NotNil(t testing.TB, value any, msgAndArgs ...any) bool { t.Helper() if isNil(value) { @@ -186,6 +216,7 @@ func (assertionHelper) NotNil(t testing.TB, value any, msgAndArgs ...any) bool { return true } +// Len reports whether value has the expected length. func (assertionHelper) Len(t testing.TB, value any, want int, msgAndArgs ...any) bool { t.Helper() got, ok := lengthOf(value) @@ -198,6 +229,7 @@ func (assertionHelper) Len(t testing.TB, value any, want int, msgAndArgs ...any) return true } +// Contains reports whether value contains element. func (assertionHelper) Contains(t testing.TB, value any, element any, msgAndArgs ...any) bool { t.Helper() found, ok := contains(value, element) @@ -210,6 +242,7 @@ func (assertionHelper) Contains(t testing.TB, value any, element any, msgAndArgs return true } +// NotContains reports whether value does not contain element. func (assertionHelper) NotContains(t testing.TB, value any, element any, msgAndArgs ...any) bool { t.Helper() found, ok := contains(value, element) @@ -222,6 +255,7 @@ func (assertionHelper) NotContains(t testing.TB, value any, element any, msgAndA return true } +// Empty reports whether value is empty. func (assertionHelper) Empty(t testing.TB, value any, msgAndArgs ...any) bool { t.Helper() if !isEmpty(value) { @@ -230,6 +264,7 @@ func (assertionHelper) Empty(t testing.TB, value any, msgAndArgs ...any) bool { return true } +// NotEmpty reports whether value is not empty. func (assertionHelper) NotEmpty(t testing.TB, value any, msgAndArgs ...any) bool { t.Helper() if isEmpty(value) { @@ -238,11 +273,13 @@ func (assertionHelper) NotEmpty(t testing.TB, value any, msgAndArgs ...any) bool return true } +// NotEmptyf reports whether value is not empty and formats the supplied message on failure. func (h assertionHelper) NotEmptyf(t testing.TB, value any, msg string, args ...any) bool { t.Helper() return h.NotEmpty(t, value, append([]any{msg}, args...)...) } +// Greater reports whether got is greater than want. func (assertionHelper) Greater(t testing.TB, got any, want any, msgAndArgs ...any) bool { t.Helper() cmp, ok := compareOrdered(got, want) @@ -255,11 +292,13 @@ func (assertionHelper) Greater(t testing.TB, got any, want any, msgAndArgs ...an return true } +// Greaterf reports whether got is greater than want and formats the supplied message on failure. func (h assertionHelper) Greaterf(t testing.TB, got any, want any, msg string, args ...any) bool { t.Helper() return h.Greater(t, got, want, append([]any{msg}, args...)...) } +// GreaterOrEqual reports whether got is greater than or equal to want. func (assertionHelper) GreaterOrEqual(t testing.TB, got any, want any, msgAndArgs ...any) bool { t.Helper() cmp, ok := compareOrdered(got, want) @@ -272,6 +311,7 @@ func (assertionHelper) GreaterOrEqual(t testing.TB, got any, want any, msgAndArg return true } +// Less reports whether got is less than want. func (assertionHelper) Less(t testing.TB, got any, want any, msgAndArgs ...any) bool { t.Helper() cmp, ok := compareOrdered(got, want) @@ -284,6 +324,7 @@ func (assertionHelper) Less(t testing.TB, got any, want any, msgAndArgs ...any) return true } +// LessOrEqual reports whether got is less than or equal to want. func (assertionHelper) LessOrEqual(t testing.TB, got any, want any, msgAndArgs ...any) bool { t.Helper() cmp, ok := compareOrdered(got, want) @@ -296,6 +337,7 @@ func (assertionHelper) LessOrEqual(t testing.TB, got any, want any, msgAndArgs . return true } +// InDelta reports whether got is within delta of want. func (assertionHelper) InDelta(t testing.TB, want any, got any, delta any, msgAndArgs ...any) bool { t.Helper() wantNumber, wantOK := number(want) @@ -310,6 +352,7 @@ func (assertionHelper) InDelta(t testing.TB, want any, got any, delta any, msgAn return true } +// failf records a fatal assertion failure with optional caller context. func failf(t testing.TB, format string, args []any, msgAndArgs ...any) bool { t.Helper() detail := format @@ -323,6 +366,7 @@ func failf(t testing.TB, format string, args []any, msgAndArgs ...any) bool { return false } +// assertionMessage formats optional assertion context. func assertionMessage(msgAndArgs ...any) string { if len(msgAndArgs) == 0 { return "" @@ -337,6 +381,7 @@ func assertionMessage(msgAndArgs ...any) string { return fmt.Sprintf(format, msgAndArgs[1:]...) } +// isNil reports whether value is nil, including typed nil values. func isNil(value any) bool { if value == nil { return true @@ -350,6 +395,7 @@ func isNil(value any) bool { } } +// isEmpty reports whether value is the zero or empty form of its type. func isEmpty(value any) bool { if isNil(value) { return true @@ -363,6 +409,7 @@ func isEmpty(value any) bool { } } +// lengthOf returns the length of supported collection-like values. func lengthOf(value any) (int, bool) { if value == nil { return 0, false @@ -386,6 +433,7 @@ func lengthOf(value any) (int, bool) { } } +// contains reports whether a string, slice, array, or map contains element. func contains(value any, element any) (bool, bool) { if value == nil { return false, true @@ -425,6 +473,7 @@ func contains(value any, element any) (bool, bool) { } } +// compareOrdered compares strings and numeric values. func compareOrdered(left any, right any) (int, bool) { if leftString, ok := left.(string); ok { rightString, ok := right.(string) @@ -449,6 +498,7 @@ func compareOrdered(left any, right any) (int, bool) { } } +// number converts supported numeric values to float64 for comparisons. func number(value any) (float64, bool) { if value == nil { return 0, false diff --git a/vectorstore.go b/vectorstore.go index 417d1b1..dfedc12 100644 --- a/vectorstore.go +++ b/vectorstore.go @@ -34,19 +34,29 @@ type VectorStore interface { // Vector represents an RFC-compatible vector payload for storage. // It is equivalent to Point, but uses the Values field name from the spec. type Vector struct { - ID string - Values []float32 + // ID is the stable vector identifier. + ID string + // Values is the embedding vector payload. + Values []float32 + // Payload stores arbitrary metadata alongside the vector. Payload map[string]any } // CollectionInfo holds backend-agnostic metadata about a collection. // info := CollectionInfo{Name: "project-docs", Count: 42, Vectors: 42, PointCount: 42, VectorSize: 768, Status: "green"} type CollectionInfo struct { - Name string - Count uint64 - Vectors uint64 - Index string + // Name is the collection name. + Name string + // Count is the backend-reported point count. + Count uint64 + // Vectors is the backend-reported vector count. + Vectors uint64 + // Index names the index implementation when known. + Index string + // PointCount is the number of stored points. PointCount uint64 + // VectorSize is the configured embedding dimension. VectorSize uint64 - Status string // e.g. "green", "yellow", "red", "unknown" + // Status is the backend health state, e.g. "green", "yellow", "red", or "unknown". + Status string }