Merged
Reuse Git-backed local project lexical and RAG retrieval (#205) #197
urandonAdd on 23 days ago
Reuse Git-backed local project lexical and RAG retrieval (#205) #197
Merged
Total: 33 files changed+1876-53
| @@ -0,0 +1,131 @@ | |||
| 1 | +package main | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "bytes" | ||
| 5 | + "encoding/json" | ||
| 6 | + "net/http" | ||
| 7 | + "net/http/httptest" | ||
| 8 | + "os" | ||
| 9 | + "path/filepath" | ||
| 10 | + "strconv" | ||
| 11 | + "sync/atomic" | ||
| 12 | + "testing" | ||
| 13 | + | ||
| 14 | + "gitcode-mcp/internal/config" | ||
| 15 | + "gitcode-mcp/internal/localgit" | ||
| 16 | + "gitcode-mcp/internal/service" | ||
| 17 | +) | ||
| 18 | + | ||
| 19 | +func TestLocalProjectSearchCLIAndMCPRealGitAndExplicitProvider(t *testing.T) { | ||
| 20 | + base := newTestSource(t) | ||
| 21 | + short, err := os.MkdirTemp(".", "g5-") | ||
| 22 | + if err != nil { | ||
| 23 | + t.Fatal("short runtime fixture unavailable") | ||
| 24 | + } | ||
| 25 | + t.Cleanup(func() { os.RemoveAll(short) }) | ||
| 26 | + base.cacheDir = filepath.Join(short, "cache") | ||
| 27 | + base.env[config.EnvMCPConfigPath] = filepath.Join(base.configDir, "local.yaml") | ||
| 28 | + var calls, documentTransfers atomic.Int64 | ||
| 29 | + var providerFailure, queryFailure atomic.Bool | ||
| 30 | + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 31 | + calls.Add(1) | ||
| 32 | + if providerFailure.Load() { | ||
| 33 | + w.WriteHeader(503) | ||
| 34 | + return | ||
| 35 | + } | ||
| 36 | + w.Header().Set("Content-Type", "application/json") | ||
| 37 | + switch r.URL.Path { | ||
| 38 | + case "/api/tags": | ||
| 39 | + json.NewEncoder(w).Encode(map[string]any{"models": []any{map[string]any{"name": "fixture-embedding", "digest": "fixture-revision"}}}) | ||
| 40 | + case "/api/embed": | ||
| 41 | + if queryFailure.Load() { | ||
| 42 | + w.WriteHeader(503) | ||
| 43 | + return | ||
| 44 | + } | ||
| 45 | + var req struct { | ||
| 46 | + Input []string `json:"input"` | ||
| 47 | + } | ||
| 48 | + if json.NewDecoder(r.Body).Decode(&req) != nil { | ||
| 49 | + t.Error("invalid bounded fixture embed request") | ||
| 50 | + w.WriteHeader(400) | ||
| 51 | + return | ||
| 52 | + } | ||
| 53 | + vectors := make([][]float64, len(req.Input)) | ||
| 54 | + for i, input := range req.Input { | ||
| 55 | + if bytes.Contains([]byte(input), []byte("GitOnlyTransportFixture")) { | ||
| 56 | + documentTransfers.Add(1) | ||
| 57 | + } | ||
| 58 | + vectors[i] = []float64{1, 0, 0, 0, 0, 0, 0, 0} | ||
| 59 | + } | ||
| 60 | + json.NewEncoder(w).Encode(map[string]any{"model": "fixture-embedding", "embeddings": vectors}) | ||
| 61 | + default: | ||
| 62 | + t.Error("local search attempted unexpected provider or GitCode operation") | ||
| 63 | + w.WriteHeader(500) | ||
| 64 | + } | ||
| 65 | + })) | ||
| 66 | + defer provider.Close() | ||
| 67 | + base.env[config.EnvAPIURL] = provider.URL + "/api/v5" | ||
| 68 | + src := localProjectSource{base} | ||
| 69 | + localProjectCLI(t, src, "", "init", "--project", "lab", "--project-id", "lab", "--storage-ref", "data", "--rag-profile", "fixture", "--confirm") | ||
| 70 | + data, err := os.ReadFile(base.env[config.EnvMCPConfigPath]) | ||
| 71 | + if err != nil || bytes.Count(data, []byte("rag_profile: fixture")) != 1 { | ||
| 72 | + t.Fatal("local setup fixture invalid") | ||
| 73 | + } | ||
| 74 | + data = append(data, []byte("\nrag:\n providers:\n fixture:\n type: ollama\n data_boundary: local_network\n endpoint: "+strconv.Quote(provider.URL)+"\n profiles:\n fixture:\n provider: fixture\n model: fixture-embedding\n dimensions: 8\n batch_size: 2\n max_input_tokens: 512\n")...) | ||
| 75 | + if os.WriteFile(base.env[config.EnvMCPConfigPath], data, 0600) != nil { | ||
| 76 | + t.Fatal("explicit profile fixture unavailable") | ||
| 77 | + } | ||
| 78 | + _, stop := startLocalProjectDaemon(t, src) | ||
| 79 | + defer stop() | ||
| 80 | + var status service.LocalProjectsStatus | ||
| 81 | + json.Unmarshal(localProjectCLI(t, src, "", "status"), &status) | ||
| 82 | + var issue localgit.MutationResult | ||
| 83 | + json.Unmarshal(localProjectCLI(t, src, "", "mutate", "--project", "lab", "--operation", "create_issue", "--title", "Question", "--body", "oldneedle GitOnlyTransportFixture", "--expected-head", status.Projects[0].Commit, "--write-mode", "local", "--idempotency-key", "question"), &issue) | ||
| 84 | + var cliSearch, mcpSearch service.LocalProjectSearchResult | ||
| 85 | + json.Unmarshal(localProjectCLI(t, src, "", "search", "--project", "lab", "--query", "oldneedle"), &cliSearch) | ||
| 86 | + json.Unmarshal(localProjectMCP(t, src, "tools/call", projectTool("local_project_search", service.LocalProjectSearchRequest{Project: "lab", Query: "oldneedle"}), ""), &mcpSearch) | ||
| 87 | + if calls.Load() != 0 || cliSearch.EffectiveMode != "fulltext" || len(cliSearch.Hits) != 1 || len(mcpSearch.Hits) != 1 || cliSearch.Hits[0].Citation != issue.Entity.Citation || cliSearch.Hits[0].Citation != mcpSearch.Hits[0].Citation { | ||
| 88 | + t.Fatal("default transports contacted provider or lost exact citation") | ||
| 89 | + } | ||
| 90 | + localProjectMCP(t, localReadOnlySource{src}, "tools/call", projectTool("local_project_index", service.LocalProjectIndexRequest{Project: "lab", WriteMode: "local"}), "tool_disabled_by_policy") | ||
| 91 | + if calls.Load() != 0 { | ||
| 92 | + t.Fatal("read policy performed provider work") | ||
| 93 | + } | ||
| 94 | + var indexed service.LocalProjectIndexResult | ||
| 95 | + json.Unmarshal(localProjectCLI(t, src, "", "index", "--project", "lab", "--write-mode", "local", "--max-chunks", "8"), &indexed) | ||
| 96 | + if indexed.State != "ready" || documentTransfers.Load() != 1 { | ||
| 97 | + t.Fatal("explicit bounded index did not embed document once") | ||
| 98 | + } | ||
| 99 | + before := documentTransfers.Load() | ||
| 100 | + json.Unmarshal(localProjectMCP(t, src, "tools/call", projectTool("local_project_search", service.LocalProjectSearchRequest{Project: "lab", Query: "oldneedle", Mode: "hybrid"}), ""), &mcpSearch) | ||
| 101 | + if mcpSearch.EffectiveMode != "hybrid" || mcpSearch.IndexState != "ready" || len(mcpSearch.Hits) != 1 || documentTransfers.Load() != before { | ||
| 102 | + t.Fatal("hybrid query repaired index or sent cached text") | ||
| 103 | + } | ||
| 104 | + queryFailure.Store(true) | ||
| 105 | + json.Unmarshal(localProjectCLI(t, src, "", "search", "--project", "lab", "--query", "oldneedle", "--mode", "hybrid"), &cliSearch) | ||
| 106 | + json.Unmarshal(localProjectMCP(t, src, "tools/call", projectTool("local_project_search", service.LocalProjectSearchRequest{Project: "lab", Query: "oldneedle", Mode: "hybrid"}), ""), &mcpSearch) | ||
| 107 | + for _, result := range []service.LocalProjectSearchResult{cliSearch, mcpSearch} { | ||
| 108 | + if result.EffectiveMode != "fulltext" || result.Coverage.State != "lexical-only" || result.Fallback != "semantic_unavailable" || len(result.Hits) != 1 || result.Hits[0].Citation != issue.Entity.Citation || documentTransfers.Load() != before { | ||
| 109 | + t.Fatal("query embedding failure lost verified lexical fallback") | ||
| 110 | + } | ||
| 111 | + } | ||
| 112 | + queryFailure.Store(false) | ||
| 113 | + update := service.LocalProjectMutation{Project: "lab", Mutation: localgit.Mutation{Operation: "update_issue", ID: "ISSUE-1", Title: "Question", State: "open", Body: "newneedle GitOnlyTransportFixture", ExpectedRevision: issue.Entity.Citation.Blob, WriteMode: "local", IdempotencyKey: "edit"}} | ||
| 114 | + localProjectMCP(t, src, "tools/call", projectTool("local_project_mutate", update), "") | ||
| 115 | + json.Unmarshal(localProjectCLI(t, src, "", "search", "--project", "lab", "--query", "oldneedle", "--mode", "hybrid"), &cliSearch) | ||
| 116 | + if cliSearch.EffectiveMode != "fulltext" || cliSearch.IndexState != "stale" || len(cliSearch.Hits) != 0 || documentTransfers.Load() != before { | ||
| 117 | + t.Fatal("transport used or repaired stale semantic set") | ||
| 118 | + } | ||
| 119 | + json.Unmarshal(localProjectMCP(t, src, "tools/call", projectTool("local_project_index", service.LocalProjectIndexRequest{Project: "lab", WriteMode: "local", MaxChunks: 8}), ""), &indexed) | ||
| 120 | + if indexed.State != "ready" || indexed.EmbeddedChunks != 1 { | ||
| 121 | + t.Fatal("MCP index failed to use shared engine") | ||
| 122 | + } | ||
| 123 | + before = documentTransfers.Load() | ||
| 124 | + providerFailure.Store(true) | ||
| 125 | + json.Unmarshal(localProjectMCP(t, src, "tools/call", projectTool("local_project_search", service.LocalProjectSearchRequest{Project: "lab", Query: "newneedle", Mode: "hybrid"}), ""), &mcpSearch) | ||
| 126 | + if mcpSearch.EffectiveMode != "fulltext" || mcpSearch.Fallback != "semantic_unavailable" || len(mcpSearch.Hits) != 1 || documentTransfers.Load() != before { | ||
| 127 | + t.Fatal("provider failure hid lexical results or repaired documents") | ||
| 128 | + } | ||
| 129 | + localProjectCLI(t, src, "local_git_invalid_argument", "search", "--project", "lab", "--query", "x", "--write-mode", "local") | ||
| 130 | + localProjectMCP(t, src, "tools/call", projectTool("local_project_search", map[string]any{"project": "lab", "query": "x", "cache_path": "private-coordinate"}), "local_git_invalid_argument") | ||
| 131 | +} | ||
| @@ -71,11 +71,7 @@ func ChunkDocumentWithPolicy(objectIdentity string, data []byte, policy Processi | |||
| 71 | if normalized != "" { | 71 | if normalized != "" { |
| 72 | rawDigest := digestBytes(raw) | 72 | rawDigest := digestBytes(raw) |
| 73 | inputDigest := digestBytes([]byte(normalized)) | 73 | inputDigest := digestBytes([]byte(normalized)) |
| 74 | - lineStart := 1 + bytes.Count(data[:start], []byte{'\n'}) | 74 | + lineStart, lineEnd := chunkLineRange(data, start, end) |
| 75 | - lineEnd := lineStart + bytes.Count(raw, []byte{'\n'}) | ||
| 76 | - if len(raw) > 0 && raw[len(raw)-1] == '\n' && lineEnd > lineStart { | ||
| 77 | - lineEnd-- | ||
| 78 | - } | ||
| 79 | idSum := sha256.Sum256([]byte(strings.Join([]string{objectIdentity, fmt.Sprint(start), fmt.Sprint(end), rawDigest, inputDigest, policyID}, "\x00"))) | 75 | idSum := sha256.Sum256([]byte(strings.Join([]string{objectIdentity, fmt.Sprint(start), fmt.Sprint(end), rawDigest, inputDigest, policyID}, "\x00"))) |
| 80 | chunks = append(chunks, Chunk{ID: "repo-doc-chunk-" + hex.EncodeToString(idSum[:]), ByteStart: start, ByteEnd: end, LineStart: lineStart, LineEnd: lineEnd, RawSliceDigest: rawDigest, EmbeddingInputDigest: inputDigest, Text: normalized}) | 76 | chunks = append(chunks, Chunk{ID: "repo-doc-chunk-" + hex.EncodeToString(idSum[:]), ByteStart: start, ByteEnd: end, LineStart: lineStart, LineEnd: lineEnd, RawSliceDigest: rawDigest, EmbeddingInputDigest: inputDigest, Text: normalized}) |
| 81 | } | 77 | } |
| @@ -84,6 +80,17 @@ func ChunkDocumentWithPolicy(objectIdentity string, data []byte, policy Processi | |||
| 84 | return chunks, nil | 80 | return chunks, nil |
| 85 | } | 81 | } |
| 86 | 82 | ||
| 83 | +// Call only after validating the byte range. Line locators are always derived | ||
| 84 | +// from authoritative bytes, never trusted from disposable index metadata. | ||
| 85 | +func chunkLineRange(data []byte, start, end int) (int, int) { | ||
| 86 | + lineStart := 1 + bytes.Count(data[:start], []byte{'\n'}) | ||
| 87 | + lineEnd := lineStart + bytes.Count(data[start:end], []byte{'\n'}) | ||
| 88 | + if end > start && data[end-1] == '\n' && lineEnd > lineStart { | ||
| 89 | + lineEnd-- | ||
| 90 | + } | ||
| 91 | + return lineStart, lineEnd | ||
| 92 | +} | ||
| 93 | + | ||
| 87 | func normalizeEmbeddingInput(value string) string { | 94 | func normalizeEmbeddingInput(value string) string { |
| 88 | lines := strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n") | 95 | lines := strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n") |
| 89 | for idx := range lines { | 96 | for idx := range lines { |
| @@ -136,6 +136,7 @@ func (e *IndexSnapshotStaleError) Unwrap() error { return ErrIndexSnapshotStale | |||
| 136 | func (e *IndexSnapshotStaleError) DiagnosticCode() string { return "repository_docs_snapshot_stale" } | 136 | func (e *IndexSnapshotStaleError) DiagnosticCode() string { return "repository_docs_snapshot_stale" } |
| 137 | 137 | ||
| 138 | type Repository struct { | 138 | type Repository struct { |
| 139 | + pinned *pinnedCorpus | ||
| 139 | root string | 140 | root string |
| 140 | commonDir string | 141 | commonDir string |
| 141 | GitStoreRef string `json:"git_store_ref"` | 142 | GitStoreRef string `json:"git_store_ref"` |
| @@ -162,7 +163,7 @@ func (r *Repository) unavailableGitObjectError(ctx context.Context, object, reas | |||
| 162 | } | 163 | } |
| 163 | 164 | ||
| 164 | func (r *Repository) gitObjectAvailability(ctx context.Context, revisionObject bool) GitObjectAvailability { | 165 | func (r *Repository) gitObjectAvailability(ctx context.Context, revisionObject bool) GitObjectAvailability { |
| 165 | - if r == nil || strings.TrimSpace(r.root) == "" { | 166 | + if r == nil || r.pinned != nil || strings.TrimSpace(r.root) == "" { |
| 166 | return GitObjectUnavailable | 167 | return GitObjectUnavailable |
| 167 | } | 168 | } |
| 168 | isShallow := func() bool { | 169 | isShallow := func() bool { |
| @@ -394,6 +395,9 @@ func (r *Repository) ResolveRevision(ctx context.Context, revision string) (stri | |||
| 394 | if r == nil { | 395 | if r == nil { |
| 395 | return "", fmt.Errorf("repository docs: Git repository is required") | 396 | return "", fmt.Errorf("repository docs: Git repository is required") |
| 396 | } | 397 | } |
| 398 | + if r.pinned != nil { | ||
| 399 | + return r.pinned.resolve(ctx, revision) | ||
| 400 | + } | ||
| 397 | if strings.TrimSpace(revision) == "" { | 401 | if strings.TrimSpace(revision) == "" { |
| 398 | revision = "HEAD" | 402 | revision = "HEAD" |
| 399 | } | 403 | } |
| @@ -423,6 +427,9 @@ func (r *Repository) ListTree(ctx context.Context, commitOID string) ([]TreeEntr | |||
| 423 | // WalkTree visits the committed tree in Git's deterministic output order | 427 | // WalkTree visits the committed tree in Git's deterministic output order |
| 424 | // without buffering the complete ls-tree response in memory. | 428 | // without buffering the complete ls-tree response in memory. |
| 425 | func (r *Repository) WalkTree(ctx context.Context, commitOID string, visit func(TreeEntry) error) error { | 429 | func (r *Repository) WalkTree(ctx context.Context, commitOID string, visit func(TreeEntry) error) error { |
| 430 | + if r.pinned != nil { | ||
| 431 | + return r.pinned.walk(ctx, commitOID, visit) | ||
| 432 | + } | ||
| 426 | if !validOID(commitOID, r.ObjectFormat) { | 433 | if !validOID(commitOID, r.ObjectFormat) { |
| 427 | return invalidGitObjectError(commitOID, "invalid commit object id") | 434 | return invalidGitObjectError(commitOID, "invalid commit object id") |
| 428 | } | 435 | } |
| @@ -514,6 +521,16 @@ func parseTreeRecord(record []byte) (TreeEntry, error) { | |||
| 514 | } | 521 | } |
| 515 | 522 | ||
| 516 | func (r *Repository) OpenBlob(ctx context.Context, oid string) (io.ReadCloser, error) { | 523 | func (r *Repository) OpenBlob(ctx context.Context, oid string) (io.ReadCloser, error) { |
| 524 | + if r.pinned != nil { | ||
| 525 | + if err := ctx.Err(); err != nil { | ||
| 526 | + return nil, err | ||
| 527 | + } | ||
| 528 | + data, ok := r.pinned.blobs[oid] | ||
| 529 | + if !ok { | ||
| 530 | + return nil, ErrGitObjectUnavailable | ||
| 531 | + } | ||
| 532 | + return io.NopCloser(bytes.NewReader(data)), nil | ||
| 533 | + } | ||
| 517 | if !validOID(oid, r.ObjectFormat) { | 534 | if !validOID(oid, r.ObjectFormat) { |
| 518 | return nil, invalidGitObjectError(oid, "invalid blob object id") | 535 | return nil, invalidGitObjectError(oid, "invalid blob object id") |
| 519 | } | 536 | } |
| @@ -574,6 +591,9 @@ func readBoundedFile(path string, maxBytes int64) ([]byte, error) { | |||
| 574 | } | 591 | } |
| 575 | 592 | ||
| 576 | func (r *Repository) ReadFileAtCommit(ctx context.Context, commitOID, repoPath string, maxBytes int64) ([]byte, bool, error) { | 593 | func (r *Repository) ReadFileAtCommit(ctx context.Context, commitOID, repoPath string, maxBytes int64) ([]byte, bool, error) { |
| 594 | + if r.pinned != nil { | ||
| 595 | + return r.pinned.file(ctx, commitOID, repoPath, maxBytes) | ||
| 596 | + } | ||
| 577 | if !validOID(commitOID, r.ObjectFormat) { | 597 | if !validOID(commitOID, r.ObjectFormat) { |
| 578 | return nil, false, invalidGitObjectError(commitOID, "invalid commit object id") | 598 | return nil, false, invalidGitObjectError(commitOID, "invalid commit object id") |
| 579 | } | 599 | } |
| @@ -627,6 +647,9 @@ func (r *Repository) TrackedChangesAt(ctx context.Context, commitOID string, max | |||
| 627 | // relevant to the caller's corpus. Git still computes one local name-status | 647 | // relevant to the caller's corpus. Git still computes one local name-status |
| 628 | // diff, but an unrelated large tracked file cannot fail documentation work. | 648 | // diff, but an unrelated large tracked file cannot fail documentation work. |
| 629 | func (r *Repository) TrackedChangesAtFiltered(ctx context.Context, commitOID string, maxBytes int64, include func(string) bool) ([]WorktreeChange, error) { | 649 | func (r *Repository) TrackedChangesAtFiltered(ctx context.Context, commitOID string, maxBytes int64, include func(string) bool) ([]WorktreeChange, error) { |
| 650 | + if r.pinned != nil { | ||
| 651 | + return nil, ErrWorktreeUnavailable | ||
| 652 | + } | ||
| 630 | if !validOID(commitOID, r.ObjectFormat) { | 653 | if !validOID(commitOID, r.ObjectFormat) { |
| 631 | return nil, invalidGitObjectError(commitOID, "invalid overlay base commit") | 654 | return nil, invalidGitObjectError(commitOID, "invalid overlay base commit") |
| 632 | } | 655 | } |
| @@ -733,6 +756,9 @@ func stateForWorktreeChange(states map[string]byte, change WorktreeChange) byte | |||
| 733 | } | 756 | } |
| 734 | 757 | ||
| 735 | func (r *Repository) ReadTrackedWorktreeFile(ctx context.Context, repoPath string, maxBytes int64) ([]byte, error) { | 758 | func (r *Repository) ReadTrackedWorktreeFile(ctx context.Context, repoPath string, maxBytes int64) ([]byte, error) { |
| 759 | + if r.pinned != nil { | ||
| 760 | + return nil, ErrWorktreeUnavailable | ||
| 761 | + } | ||
| 736 | if filepath.IsAbs(repoPath) || filepath.VolumeName(repoPath) != "" { | 762 | if filepath.IsAbs(repoPath) || filepath.VolumeName(repoPath) != "" { |
| 737 | return nil, &WorktreeError{Reason: "unsafe repository-relative path"} | 763 | return nil, &WorktreeError{Reason: "unsafe repository-relative path"} |
| 738 | } | 764 | } |
| @@ -761,6 +787,12 @@ func ResolvePolicy(ctx context.Context, repo *Repository, revision string, inclu | |||
| 761 | if err != nil { | 787 | if err != nil { |
| 762 | return "", PolicyResolution{}, err | 788 | return "", PolicyResolution{}, err |
| 763 | } | 789 | } |
| 790 | + if repo.pinned != nil { | ||
| 791 | + if includeWorktree { | ||
| 792 | + return "", PolicyResolution{}, ErrWorktreeUnavailable | ||
| 793 | + } | ||
| 794 | + return commitOID, pinnedProjectPolicy(), nil | ||
| 795 | + } | ||
| 764 | data, found, err := repo.ReadFileAtCommit(ctx, commitOID, PolicyConfigPath, DefaultMaxDocumentBytes) | 796 | data, found, err := repo.ReadFileAtCommit(ctx, commitOID, PolicyConfigPath, DefaultMaxDocumentBytes) |
| 765 | if err != nil { | 797 | if err != nil { |
| 766 | return "", PolicyResolution{}, err | 798 | return "", PolicyResolution{}, err |
| @@ -222,13 +222,19 @@ func (i *Indexer) Run(ctx context.Context, req IndexRequest) (IndexResult, error | |||
| 222 | inputs[idx] = pending[idx].text | 222 | inputs[idx] = pending[idx].text |
| 223 | } | 223 | } |
| 224 | response, embedErr := i.provider.Embed(ctx, rag.EmbedRequest{Inputs: inputs}) | 224 | response, embedErr := i.provider.Embed(ctx, rag.EmbedRequest{Inputs: inputs}) |
| 225 | + if embedErr == nil && !embeddingResponseMatchesNamespace(response, namespaceIdentity) { | ||
| 226 | + return SemanticProviderContractError{code: "semantic_provider_namespace_changed"} | ||
| 227 | + } | ||
| 225 | if embedErr != nil || len(response.Embeddings) != len(pending) { | 228 | if embedErr != nil || len(response.Embeddings) != len(pending) { |
| 226 | for _, item := range pending { | 229 | for _, item := range pending { |
| 227 | if err := ctx.Err(); err != nil { | 230 | if err := ctx.Err(); err != nil { |
| 228 | return err | 231 | return err |
| 229 | } | 232 | } |
| 230 | one, oneErr := i.provider.Embed(ctx, rag.EmbedRequest{Inputs: []string{item.text}}) | 233 | one, oneErr := i.provider.Embed(ctx, rag.EmbedRequest{Inputs: []string{item.text}}) |
| 231 | - if oneErr != nil || len(one.Embeddings) != 1 { | 234 | + if oneErr == nil && !embeddingResponseMatchesNamespace(one, namespaceIdentity) { |
| 235 | + return SemanticProviderContractError{code: "semantic_provider_namespace_changed"} | ||
| 236 | + } | ||
| 237 | + if oneErr != nil || len(one.Embeddings) != 1 || one.Dimensions != namespace.Dimensions { | ||
| 232 | result.FailedChunks++ | 238 | result.FailedChunks++ |
| 233 | continue | 239 | continue |
| 234 | } | 240 | } |
| @@ -243,6 +249,10 @@ func (i *Indexer) Run(ctx context.Context, req IndexRequest) (IndexResult, error | |||
| 243 | return nil | 249 | return nil |
| 244 | } | 250 | } |
| 245 | for idx, vector := range response.Embeddings { | 251 | for idx, vector := range response.Embeddings { |
| 252 | + if response.Dimensions != namespace.Dimensions { | ||
| 253 | + result.FailedChunks++ | ||
| 254 | + continue | ||
| 255 | + } | ||
| 246 | if err := i.persistProviderVector(ctx, req.RepoID, namespace.ID, pending[idx].meta.ID, namespace.Dimensions, vector); err != nil { | 256 | if err := i.persistProviderVector(ctx, req.RepoID, namespace.ID, pending[idx].meta.ID, namespace.Dimensions, vector); err != nil { |
| 247 | result.FailedChunks++ | 257 | result.FailedChunks++ |
| 248 | continue | 258 | continue |
Ainternal/repositorydocs/pinned_corpus_test.go+59-0File content is under review, please try again later
| @@ -91,25 +91,28 @@ func (e SemanticProviderContractError) DiagnosticCode() string { | |||
| 91 | } | 91 | } |
| 92 | 92 | ||
| 93 | type SearchResult struct { | 93 | type SearchResult struct { |
| 94 | - RepoID string `json:"repo_id"` | 94 | + // RequestedNamespaceID supports internal coverage classification. It is not |
| 95 | - CorpusKind string `json:"corpus_kind"` | 95 | + // public evidence of a selected/ready semantic namespace. |
| 96 | - Query string `json:"query"` | 96 | + RequestedNamespaceID string `json:"-"` |
| 97 | - RequestedRevision string `json:"requested_revision"` | 97 | + RepoID string `json:"repo_id"` |
| 98 | - EffectiveRevision string `json:"effective_revision"` | 98 | + CorpusKind string `json:"corpus_kind"` |
| 99 | - Mode string `json:"mode"` | 99 | + Query string `json:"query"` |
| 100 | - RequestedMode string `json:"requested_mode"` | 100 | + RequestedRevision string `json:"requested_revision"` |
| 101 | - EffectiveMode string `json:"effective_mode"` | 101 | + EffectiveRevision string `json:"effective_revision"` |
| 102 | - Authority string `json:"authority"` | 102 | + Mode string `json:"mode"` |
| 103 | - OverlayDigest string `json:"overlay_digest,omitempty"` | 103 | + RequestedMode string `json:"requested_mode"` |
| 104 | - RevisionSetID string `json:"revision_set_id,omitempty"` | 104 | + EffectiveMode string `json:"effective_mode"` |
| 105 | - PolicyHash string `json:"policy_hash"` | 105 | + Authority string `json:"authority"` |
| 106 | - PolicySource string `json:"policy_source"` | 106 | + OverlayDigest string `json:"overlay_digest,omitempty"` |
| 107 | - NamespaceID string `json:"namespace_id,omitempty"` | 107 | + RevisionSetID string `json:"revision_set_id,omitempty"` |
| 108 | - Coverage SearchCoverage `json:"coverage"` | 108 | + PolicyHash string `json:"policy_hash"` |
| 109 | - Hits []SearchHit `json:"hits"` | 109 | + PolicySource string `json:"policy_source"` |
| 110 | - Warnings []string `json:"warnings,omitempty"` | 110 | + NamespaceID string `json:"namespace_id,omitempty"` |
| 111 | - WarningDetails []SearchWarning `json:"warning_details,omitempty"` | 111 | + Coverage SearchCoverage `json:"coverage"` |
| 112 | - Fallback string `json:"fallback,omitempty"` | 112 | + Hits []SearchHit `json:"hits"` |
| 113 | + Warnings []string `json:"warnings,omitempty"` | ||
| 114 | + WarningDetails []SearchWarning `json:"warning_details,omitempty"` | ||
| 115 | + Fallback string `json:"fallback,omitempty"` | ||
| 113 | } | 116 | } |
| 114 | 117 | ||
| 115 | func (r *SearchResult) addWarning(code, message string) { | 118 | func (r *SearchResult) addWarning(code, message string) { |
| @@ -132,7 +135,7 @@ func NewRetriever(store SearchStore, provider rag.EmbeddingProvider) *Retriever | |||
| 132 | } | 135 | } |
| 133 | 136 | ||
| 134 | func (r *Retriever) Search(ctx context.Context, req SearchRequest) (SearchResult, error) { | 137 | func (r *Retriever) Search(ctx context.Context, req SearchRequest) (SearchResult, error) { |
| 135 | - if r == nil || r.store == nil || req.Repository == nil || strings.TrimSpace(req.RepoID) == "" || strings.TrimSpace(req.Query) == "" { | 138 | + if r == nil || req.Repository == nil || strings.TrimSpace(req.RepoID) == "" || strings.TrimSpace(req.Query) == "" { |
| 136 | return SearchResult{}, fmt.Errorf("repository docs: search requires repo id, Git repository, query, and cache") | 139 | return SearchResult{}, fmt.Errorf("repository docs: search requires repo id, Git repository, query, and cache") |
| 137 | } | 140 | } |
| 138 | if req.Mode == "" { | 141 | if req.Mode == "" { |
| @@ -168,8 +171,9 @@ func (r *Retriever) Search(ctx context.Context, req SearchRequest) (SearchResult | |||
| 168 | result.Authority = "worktree_overlay" | 171 | result.Authority = "worktree_overlay" |
| 169 | result.OverlayDigest = overlayDigest | 172 | result.OverlayDigest = overlayDigest |
| 170 | } | 173 | } |
| 171 | - semanticAvailable := req.Mode == SearchModeHybrid && r.provider != nil | 174 | + semanticAvailable := req.Mode == SearchModeHybrid && r.provider != nil && r.store != nil |
| 172 | expectedNamespaceID := "" | 175 | expectedNamespaceID := "" |
| 176 | + var expectedNamespace cache.EmbeddingNamespaceIdentity | ||
| 173 | if semanticAvailable { | 177 | if semanticAvailable { |
| 174 | identity, identityErr := r.provider.NamespaceIdentity(ctx, rag.NamespaceRequest{RepoID: req.RepoID, ChunkPolicyID: chunkPolicyID, LanguagePolicyID: rag.DefaultLanguagePolicyID, DocumentInstructionID: "repo-doc-v1", QueryInstructionID: "repo-doc-query-v1"}) | 178 | identity, identityErr := r.provider.NamespaceIdentity(ctx, rag.NamespaceRequest{RepoID: req.RepoID, ChunkPolicyID: chunkPolicyID, LanguagePolicyID: rag.DefaultLanguagePolicyID, DocumentInstructionID: "repo-doc-v1", QueryInstructionID: "repo-doc-query-v1"}) |
| 175 | if identityErr != nil { | 179 | if identityErr != nil { |
| @@ -179,6 +183,8 @@ func (r *Retriever) Search(ctx context.Context, req SearchRequest) (SearchResult | |||
| 179 | result.addWarning("embedding_provider_identity_unavailable", "embedding provider identity unavailable; returning verified lexical results") | 183 | result.addWarning("embedding_provider_identity_unavailable", "embedding provider identity unavailable; returning verified lexical results") |
| 180 | } else { | 184 | } else { |
| 181 | expectedNamespaceID = cache.EmbeddingNamespaceID(identity) | 185 | expectedNamespaceID = cache.EmbeddingNamespaceID(identity) |
| 186 | + expectedNamespace = identity | ||
| 187 | + result.RequestedNamespaceID = expectedNamespaceID | ||
| 182 | } | 188 | } |
| 183 | } | 189 | } |
| 184 | var selected cache.RepositoryDocRevisionSet | 190 | var selected cache.RepositoryDocRevisionSet |
| @@ -229,7 +235,7 @@ func (r *Retriever) Search(ctx context.Context, req SearchRequest) (SearchResult | |||
| 229 | } | 235 | } |
| 230 | semantic := map[string]rankedHit{} | 236 | semantic := map[string]rankedHit{} |
| 231 | if req.Mode == SearchModeHybrid && selected.ID != "" && semanticAvailable { | 237 | if req.Mode == SearchModeHybrid && selected.ID != "" && semanticAvailable { |
| 232 | - semantic, err = r.semantic(ctx, req, selected) | 238 | + semantic, err = r.semantic(ctx, req, selected, expectedNamespace) |
| 233 | if err != nil { | 239 | if err != nil { |
| 234 | code := "semantic_retrieval_unavailable" | 240 | code := "semantic_retrieval_unavailable" |
| 235 | message := "semantic retrieval unavailable; returning verified lexical results" | 241 | message := "semantic retrieval unavailable; returning verified lexical results" |
| @@ -242,6 +248,7 @@ func (r *Retriever) Search(ctx context.Context, req SearchRequest) (SearchResult | |||
| 242 | result.EffectiveMode = SearchModeFullText | 248 | result.EffectiveMode = SearchModeFullText |
| 243 | result.addWarning(code, message) | 249 | result.addWarning(code, message) |
| 244 | semantic = map[string]rankedHit{} | 250 | semantic = map[string]rankedHit{} |
| 251 | + result.Coverage = lexicalCoverage | ||
| 245 | } | 252 | } |
| 246 | } else if req.Mode == SearchModeHybrid && !semanticAvailable && result.Fallback == "" { | 253 | } else if req.Mode == SearchModeHybrid && !semanticAvailable && result.Fallback == "" { |
| 247 | result.Fallback = "provider_unavailable" | 254 | result.Fallback = "provider_unavailable" |
| @@ -422,7 +429,7 @@ func trimRankedHits(values map[string]rankedHit, limit int) map[string]rankedHit | |||
| 422 | return trimmed | 429 | return trimmed |
| 423 | } | 430 | } |
| 424 | 431 | ||
| 425 | -func (r *Retriever) semantic(ctx context.Context, req SearchRequest, set cache.RepositoryDocRevisionSet) (map[string]rankedHit, error) { | 432 | +func (r *Retriever) semantic(ctx context.Context, req SearchRequest, set cache.RepositoryDocRevisionSet, namespace cache.EmbeddingNamespaceIdentity) (map[string]rankedHit, error) { |
| 426 | response, err := r.provider.Embed(ctx, rag.EmbedRequest{Inputs: []string{req.Query}}) | 433 | response, err := r.provider.Embed(ctx, rag.EmbedRequest{Inputs: []string{req.Query}}) |
| 427 | if err != nil || len(response.Embeddings) != 1 { | 434 | if err != nil || len(response.Embeddings) != 1 { |
| 428 | if err == nil { | 435 | if err == nil { |
| @@ -431,31 +438,52 @@ func (r *Retriever) semantic(ctx context.Context, req SearchRequest, set cache.R | |||
| 431 | return nil, err | 438 | return nil, err |
| 432 | } | 439 | } |
| 433 | query := response.Embeddings[0] | 440 | query := response.Embeddings[0] |
| 434 | - if response.Dimensions <= 0 || len(query) != response.Dimensions { | 441 | + if response.Dimensions <= 0 || len(query) != response.Dimensions || response.Dimensions != namespace.Dimensions { |
| 435 | return nil, SemanticProviderContractError{code: "query_embedding_dimension_mismatch"} | 442 | return nil, SemanticProviderContractError{code: "query_embedding_dimension_mismatch"} |
| 436 | } | 443 | } |
| 444 | + if !embeddingResponseMatchesNamespace(response, namespace) { | ||
| 445 | + return nil, SemanticProviderContractError{code: "semantic_provider_namespace_changed"} | ||
| 446 | + } | ||
| 447 | + query, err = rag.NormalizeFloat32Vector(query) | ||
| 448 | + if err != nil { | ||
| 449 | + return nil, SemanticProviderContractError{code: "query_embedding_vector_invalid"} | ||
| 450 | + } | ||
| 437 | snapshot, err := r.store.LoadRepositoryDocSearchSnapshot(ctx, req.RepoID, set.ID, set.NamespaceID) | 451 | snapshot, err := r.store.LoadRepositoryDocSearchSnapshot(ctx, req.RepoID, set.ID, set.NamespaceID) |
| 438 | if err != nil { | 452 | if err != nil { |
| 439 | return nil, err | 453 | return nil, err |
| 440 | } | 454 | } |
| 455 | + if len(snapshot.Candidates) != set.EligibleChunks { | ||
| 456 | + return nil, SemanticProviderContractError{code: "semantic_vector_contract_invalid"} | ||
| 457 | + } | ||
| 441 | results := map[string]rankedHit{} | 458 | results := map[string]rankedHit{} |
| 442 | for _, candidate := range snapshot.Candidates { | 459 | for _, candidate := range snapshot.Candidates { |
| 443 | if len(candidate.Vector) == 0 { | 460 | if len(candidate.Vector) == 0 { |
| 444 | - continue | 461 | + return nil, SemanticProviderContractError{code: "semantic_vector_contract_invalid"} |
| 445 | } | 462 | } |
| 446 | if candidate.Dimensions != len(query) { | 463 | if candidate.Dimensions != len(query) { |
| 447 | return nil, SemanticProviderContractError{code: "query_embedding_dimension_mismatch"} | 464 | return nil, SemanticProviderContractError{code: "query_embedding_dimension_mismatch"} |
| 448 | } | 465 | } |
| 466 | + if candidate.DType != rag.DefaultEmbeddingDType || candidate.DType != namespace.DType || candidate.VectorHash != digestBytes(candidate.Vector) { | ||
| 467 | + return nil, SemanticProviderContractError{code: "semantic_vector_contract_invalid"} | ||
| 468 | + } | ||
| 449 | vector, err := rag.DecodeFloat32Vector(candidate.Vector, candidate.Dimensions) | 469 | vector, err := rag.DecodeFloat32Vector(candidate.Vector, candidate.Dimensions) |
| 450 | if err != nil || len(vector) != len(query) { | 470 | if err != nil || len(vector) != len(query) { |
| 451 | return nil, SemanticProviderContractError{code: "semantic_vector_contract_invalid"} | 471 | return nil, SemanticProviderContractError{code: "semantic_vector_contract_invalid"} |
| 452 | } | 472 | } |
| 473 | + vector, err = rag.NormalizeFloat32Vector(vector) | ||
| 474 | + if err != nil { | ||
| 475 | + return nil, SemanticProviderContractError{code: "semantic_vector_contract_invalid"} | ||
| 476 | + } | ||
| 453 | score := cosine(query, vector) | 477 | score := cosine(query, vector) |
| 454 | results[candidateRankKey(candidate)] = rankedHit{candidate: candidate, score: score, semantic: score} | 478 | results[candidateRankKey(candidate)] = rankedHit{candidate: candidate, score: score, semantic: score} |
| 455 | } | 479 | } |
| 456 | return results, nil | 480 | return results, nil |
| 457 | } | 481 | } |
| 458 | 482 | ||
| 483 | +func embeddingResponseMatchesNamespace(response rag.EmbedResponse, namespace cache.EmbeddingNamespaceIdentity) bool { | ||
| 484 | + return response.Model == namespace.ModelID && response.Revision == namespace.ModelRevision | ||
| 485 | +} | ||
| 486 | + | ||
| 459 | func fuseRanks(lexical, semantic map[string]rankedHit) []rankedHit { | 487 | func fuseRanks(lexical, semantic map[string]rankedHit) []rankedHit { |
| 460 | lex := sortedRanks(lexical) | 488 | lex := sortedRanks(lexical) |
| 461 | sem := sortedRanks(semantic) | 489 | sem := sortedRanks(semantic) |
| @@ -518,6 +546,7 @@ func (r *Retriever) hydrate(ctx context.Context, repo *Repository, commitOID str | |||
| 518 | return SearchHit{}, false, SearchWarning{Code: "worktree_overlay_stale", Message: "worktree_overlay_stale: tracked content changed; reindex the overlay"} | 546 | return SearchHit{}, false, SearchWarning{Code: "worktree_overlay_stale", Message: "worktree_overlay_stale: tracked content changed; reindex the overlay"} |
| 519 | } | 547 | } |
| 520 | raw := data[candidate.ByteStart:candidate.ByteEnd] | 548 | raw := data[candidate.ByteStart:candidate.ByteEnd] |
| 549 | + candidate.LineStart, candidate.LineEnd = chunkLineRange(data, candidate.ByteStart, candidate.ByteEnd) | ||
| 521 | snippet := truncateUTF8Bytes(strings.TrimSpace(string(raw)), 800) | 550 | snippet := truncateUTF8Bytes(strings.TrimSpace(string(raw)), 800) |
| 522 | return SearchHit{ChunkID: candidate.ChunkID, Snippet: snippet, Score: item.score, Lexical: item.lexical, Semantic: item.semantic, Citation: Citation{Authority: "worktree", CommitOID: commitOID, Path: candidate.Path, LineStart: candidate.LineStart, LineEnd: candidate.LineEnd, RawSliceDigest: candidate.RawSliceDigest}}, true, SearchWarning{} | 551 | return SearchHit{ChunkID: candidate.ChunkID, Snippet: snippet, Score: item.score, Lexical: item.lexical, Semantic: item.semantic, Citation: Citation{Authority: "worktree", CommitOID: commitOID, Path: candidate.Path, LineStart: candidate.LineStart, LineEnd: candidate.LineEnd, RawSliceDigest: candidate.RawSliceDigest}}, true, SearchWarning{} |
| 523 | } | 552 | } |
| @@ -529,6 +558,7 @@ func (r *Retriever) hydrate(ctx context.Context, repo *Repository, commitOID str | |||
| 529 | return SearchHit{}, false, SearchWarning{Code: "citation_locator_invalid", Message: "citation omitted: stored chunk locator is invalid"} | 558 | return SearchHit{}, false, SearchWarning{Code: "citation_locator_invalid", Message: "citation omitted: stored chunk locator is invalid"} |
| 530 | } | 559 | } |
| 531 | raw := data[candidate.ByteStart:candidate.ByteEnd] | 560 | raw := data[candidate.ByteStart:candidate.ByteEnd] |
| 561 | + candidate.LineStart, candidate.LineEnd = chunkLineRange(data, candidate.ByteStart, candidate.ByteEnd) | ||
| 532 | if digestBytes(raw) != candidate.RawSliceDigest { | 562 | if digestBytes(raw) != candidate.RawSliceDigest { |
| 533 | return SearchHit{}, false, SearchWarning{Code: "citation_digest_mismatch", Message: "citation omitted: Git content failed digest verification"} | 563 | return SearchHit{}, false, SearchWarning{Code: "citation_digest_mismatch", Message: "citation omitted: Git content failed digest verification"} |
| 534 | } | 564 | } |
Ainternal/service/local_project_model_review_test.go+81-0File content is under review, please try again later
Ainternal/service/local_project_provider_test.go+52-0File content is under review, please try again later
Ainternal/service/local_project_query_vector_review_test.go+63-0File content is under review, please try again later
Ainternal/service/local_project_search_test.go+301-0File content is under review, please try again later
Ainternal/servicectl/local_project_checkpoint_review_test.go+55-0File content is under review, please try again later
Ainternal/servicectl/local_project_search_ipc.go+102-0File content is under review, please try again later
Ainternal/servicectl/local_project_search_ipc_test.go+73-0File content is under review, please try again later
Ainternal/servicectl/local_project_search_review_test.go+104-0File content is under review, please try again later
Ainternal/servicectl/local_project_vector_review_test.go+48-0File content is under review, please try again later