| 1 | using HybridCodebaseIndex.Core.Indexing; |
| 2 | |
| 3 | namespace HybridCodebaseIndex.Core; |
| 4 | |
| 5 | /// <summary>Сервис гибридного индекса (слой B по ADR 0105 CascadeIDE; v0: FTS5 по тексту файлов).</summary> |
| 6 | public sealed class CodebaseIndexService |
| 7 | { |
| 8 | private readonly string _indexDirectoryRelative; |
| 9 | |
| 10 | public CodebaseIndexService(string indexDirectoryRelative = ".hybrid-codebase-index") |
| 11 | { |
| 12 | _indexDirectoryRelative = string.IsNullOrWhiteSpace(indexDirectoryRelative) |
| 13 | ? ".hybrid-codebase-index" |
| 14 | : indexDirectoryRelative; |
| 15 | } |
| 16 | |
| 17 | public string GetDatabasePath(string workspaceRoot) |
| 18 | => GetDatabasePath(workspaceRoot, solutionPath: null); |
| 19 | |
| 20 | public string GetDatabasePath(string workspaceRoot, string? solutionPath) |
| 21 | { |
| 22 | var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar)); |
| 23 | var indexDir = ResolveIndexDirectoryRelative(root, solutionPath); |
| 24 | return SqliteFtsIndex.ResolveDatabasePathForRead(root, indexDir); |
| 25 | } |
| 26 | |
| 27 | public Task<ReindexSummary> FullReindexAsync(string workspaceRoot, CancellationToken cancellationToken = default) |
| 28 | => FullReindexAsync(workspaceRoot, solutionPath: null, reindexObservers: null, cancellationToken); |
| 29 | |
| 30 | public Task<ReindexSummary> FullReindexAsync( |
| 31 | string workspaceRoot, |
| 32 | string? solutionPath, |
| 33 | CancellationToken cancellationToken = default) |
| 34 | => FullReindexAsync(workspaceRoot, solutionPath, reindexObservers: null, cancellationToken); |
| 35 | |
| 36 | public Task<ReindexSummary> FullReindexAsync( |
| 37 | string workspaceRoot, |
| 38 | string? solutionPath, |
| 39 | IReadOnlyList<ICodebaseIndexReindexObserver>? reindexObservers, |
| 40 | CancellationToken cancellationToken = default) |
| 41 | { |
| 42 | var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar)); |
| 43 | var indexDir = ResolveIndexDirectoryRelative(root, solutionPath); |
| 44 | var db = SqliteFtsIndex.ResolveDatabasePathForWrite(root, indexDir); |
| 45 | return SqliteFtsIndex.ReindexIncrementalAsync(root, db, reindexObservers, cancellationToken); |
| 46 | } |
| 47 | |
| 48 | public Task<ReindexSummary> FullRebuildAsync(string workspaceRoot, CancellationToken cancellationToken = default) |
| 49 | => FullRebuildAsync(workspaceRoot, solutionPath: null, reindexObservers: null, cancellationToken); |
| 50 | |
| 51 | public Task<ReindexSummary> FullRebuildAsync( |
| 52 | string workspaceRoot, |
| 53 | string? solutionPath, |
| 54 | CancellationToken cancellationToken = default) |
| 55 | => FullRebuildAsync(workspaceRoot, solutionPath, reindexObservers: null, cancellationToken); |
| 56 | |
| 57 | public Task<ReindexSummary> FullRebuildAsync( |
| 58 | string workspaceRoot, |
| 59 | string? solutionPath, |
| 60 | IReadOnlyList<ICodebaseIndexReindexObserver>? reindexObservers, |
| 61 | CancellationToken cancellationToken = default) |
| 62 | { |
| 63 | var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar)); |
| 64 | var indexDir = ResolveIndexDirectoryRelative(root, solutionPath); |
| 65 | var db = SqliteFtsIndex.ResolveDatabasePathForWrite(root, indexDir); |
| 66 | return SqliteFtsIndex.FullRebuildAsync(root, db, reindexObservers, cancellationToken); |
| 67 | } |
| 68 | |
| 69 | public Task<ExplainHitResponse> ExplainHitAsync( |
| 70 | string workspaceRoot, |
| 71 | long hitId, |
| 72 | CancellationToken cancellationToken = default) |
| 73 | => ExplainHitAsync(workspaceRoot, solutionPath: null, hitId, cancellationToken); |
| 74 | |
| 75 | public Task<ExplainHitResponse> ExplainHitAsync( |
| 76 | string workspaceRoot, |
| 77 | string? solutionPath, |
| 78 | long hitId, |
| 79 | CancellationToken cancellationToken = default) |
| 80 | { |
| 81 | var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar)); |
| 82 | var indexDir = ResolveIndexDirectoryRelative(root, solutionPath); |
| 83 | var db = SqliteFtsIndex.ResolveDatabasePathForRead(root, indexDir); |
| 84 | return SqliteFtsIndex.ExplainHitAsync(root, db, hitId, cancellationToken); |
| 85 | } |
| 86 | |
| 87 | public Task<(SearchResponse response, string? error)> SearchAsync( |
| 88 | string workspaceRoot, |
| 89 | string query, |
| 90 | int topN = 15, |
| 91 | string? pathPrefix = null, |
| 92 | IReadOnlyList<string>? excludePathPrefixes = null, |
| 93 | IReadOnlyList<string>? extensions = null, |
| 94 | CancellationToken cancellationToken = default) |
| 95 | => SearchAsync(workspaceRoot, solutionPath: null, query, topN, pathPrefix, excludePathPrefixes, extensions, cancellationToken); |
| 96 | |
| 97 | public Task<(SearchResponse response, string? error)> SearchAsync( |
| 98 | string workspaceRoot, |
| 99 | string? solutionPath, |
| 100 | string query, |
| 101 | int topN = 15, |
| 102 | string? pathPrefix = null, |
| 103 | IReadOnlyList<string>? excludePathPrefixes = null, |
| 104 | IReadOnlyList<string>? extensions = null, |
| 105 | CancellationToken cancellationToken = default) |
| 106 | { |
| 107 | var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar)); |
| 108 | var indexDir = ResolveIndexDirectoryRelative(root, solutionPath); |
| 109 | var db = SqliteFtsIndex.ResolveDatabasePathForRead(root, indexDir); |
| 110 | return SqliteFtsIndex.SearchAsync(root, db, query, topN, pathPrefix, excludePathPrefixes, extensions, cancellationToken); |
| 111 | } |
| 112 | |
| 113 | public Task<(SearchResponse response, string? error)> SearchHybridAsync( |
| 114 | string workspaceRoot, |
| 115 | string? solutionPath, |
| 116 | string query, |
| 117 | int topN, |
| 118 | string? pathPrefix, |
| 119 | IReadOnlyList<string>? excludePathPrefixes, |
| 120 | IReadOnlyList<string>? extensions, |
| 121 | bool semantic, |
| 122 | double alpha, |
| 123 | double beta, |
| 124 | int vecTopK, |
| 125 | CancellationToken cancellationToken = default) |
| 126 | { |
| 127 | var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar)); |
| 128 | var indexDir = ResolveIndexDirectoryRelative(root, solutionPath); |
| 129 | var db = SqliteFtsIndex.ResolveDatabasePathForRead(root, indexDir); |
| 130 | return SqliteFtsIndex.SearchHybridAsync(root, db, query, topN, pathPrefix, excludePathPrefixes, extensions, semantic, alpha, beta, vecTopK, cancellationToken); |
| 131 | } |
| 132 | |
| 133 | public Task<IndexStatus> GetStatusAsync(string workspaceRoot, CancellationToken cancellationToken = default) |
| 134 | => GetStatusAsync(workspaceRoot, solutionPath: null, cancellationToken); |
| 135 | |
| 136 | public Task<IndexStatus> GetStatusAsync(string workspaceRoot, string? solutionPath, CancellationToken cancellationToken = default) |
| 137 | { |
| 138 | var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar)); |
| 139 | var indexDir = ResolveIndexDirectoryRelative(root, solutionPath); |
| 140 | var db = SqliteFtsIndex.ResolveDatabasePathForRead(root, indexDir); |
| 141 | return SqliteFtsIndex.GetStatusAsync(root, db, cancellationToken); |
| 142 | } |
| 143 | |
| 144 | public Task<DocDraftResponse> DraftDocAsync( |
| 145 | string workspaceRoot, |
| 146 | string? solutionPath, |
| 147 | string title, |
| 148 | IReadOnlyList<string> changedPaths, |
| 149 | CancellationToken cancellationToken = default) |
| 150 | { |
| 151 | var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar)); |
| 152 | var indexDir = ResolveIndexDirectoryRelative(root, solutionPath); |
| 153 | var db = SqliteFtsIndex.ResolveDatabasePathForRead(root, indexDir); |
| 154 | return SqliteFtsIndex.DraftDocAsync(root, db, title, changedPaths, cancellationToken); |
| 155 | } |
| 156 | |
| 157 | public Task<(int vectorsUpserted, string? err)> ReindexVectorsAsync( |
| 158 | string workspaceRoot, |
| 159 | string? solutionPath, |
| 160 | CancellationToken cancellationToken = default) |
| 161 | { |
| 162 | var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar)); |
| 163 | var indexDir = ResolveIndexDirectoryRelative(root, solutionPath); |
| 164 | var db = SqliteFtsIndex.ResolveDatabasePathForRead(root, indexDir); |
| 165 | return SqliteFtsIndex.ReindexVectorsAsync(root, db, cancellationToken); |
| 166 | } |
| 167 | |
| 168 | private string ResolveIndexDirectoryRelative(string workspaceRootNormalized, string? solutionPath) |
| 169 | { |
| 170 | if (string.IsNullOrWhiteSpace(solutionPath)) |
| 171 | return _indexDirectoryRelative; |
| 172 | |
| 173 | var resolved = solutionPath.Trim(); |
| 174 | if (!Path.IsPathRooted(resolved)) |
| 175 | resolved = Path.Combine(workspaceRootNormalized, resolved); |
| 176 | |
| 177 | try |
| 178 | { |
| 179 | resolved = Path.GetFullPath(resolved); |
| 180 | } |
| 181 | catch |
| 182 | { |
| 183 | // best-effort: fall back to raw string (still scopes DB away from root) |
| 184 | } |
| 185 | |
| 186 | if (OperatingSystem.IsWindows()) |
| 187 | resolved = resolved.ToLowerInvariant(); |
| 188 | |
| 189 | var safeName = SanitizeSegment(Path.GetFileNameWithoutExtension(resolved)); |
| 190 | if (string.IsNullOrWhiteSpace(safeName)) |
| 191 | safeName = "solution"; |
| 192 | |
| 193 | var hash12 = ComputeHash12(resolved); |
| 194 | var dir = $"{safeName}--{hash12}"; |
| 195 | return Path.Combine(_indexDirectoryRelative, dir); |
| 196 | } |
| 197 | |
| 198 | private static string SanitizeSegment(string? s) |
| 199 | { |
| 200 | if (string.IsNullOrWhiteSpace(s)) |
| 201 | return ""; |
| 202 | |
| 203 | Span<char> buffer = stackalloc char[s.Length]; |
| 204 | var n = 0; |
| 205 | foreach (var ch in s) |
| 206 | { |
| 207 | if (char.IsLetterOrDigit(ch) || ch is '-' or '_' or '.') |
| 208 | buffer[n++] = ch; |
| 209 | else |
| 210 | buffer[n++] = '-'; |
| 211 | } |
| 212 | |
| 213 | return new string(buffer[..n]).Trim('-'); |
| 214 | } |
| 215 | |
| 216 | private static string ComputeHash12(string input) |
| 217 | { |
| 218 | var bytes = System.Text.Encoding.UTF8.GetBytes(input); |
| 219 | var hash = System.Security.Cryptography.SHA256.HashData(bytes); |
| 220 | return Convert.ToHexString(hash)[..12].ToLowerInvariant(); |
| 221 | } |
| 222 | } |
| 223 | |