@memberjunction/search-engine 5.32.0 → 5.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/generic/BaseReRanker.d.ts +164 -0
  2. package/dist/generic/BaseReRanker.d.ts.map +1 -0
  3. package/dist/generic/BaseReRanker.js +209 -0
  4. package/dist/generic/BaseReRanker.js.map +1 -0
  5. package/dist/generic/EntitySearchProvider.d.ts +51 -3
  6. package/dist/generic/EntitySearchProvider.d.ts.map +1 -1
  7. package/dist/generic/EntitySearchProvider.js +130 -17
  8. package/dist/generic/EntitySearchProvider.js.map +1 -1
  9. package/dist/generic/FullTextSearchProvider.d.ts +7 -2
  10. package/dist/generic/FullTextSearchProvider.d.ts.map +1 -1
  11. package/dist/generic/FullTextSearchProvider.js +25 -4
  12. package/dist/generic/FullTextSearchProvider.js.map +1 -1
  13. package/dist/generic/ISearchProvider.d.ts +44 -2
  14. package/dist/generic/ISearchProvider.d.ts.map +1 -1
  15. package/dist/generic/ISearchProvider.js +35 -1
  16. package/dist/generic/ISearchProvider.js.map +1 -1
  17. package/dist/generic/NoopReRanker.d.ts +28 -0
  18. package/dist/generic/NoopReRanker.d.ts.map +1 -0
  19. package/dist/generic/NoopReRanker.js +49 -0
  20. package/dist/generic/NoopReRanker.js.map +1 -0
  21. package/dist/generic/ScopeTemplateRenderer.d.ts +36 -0
  22. package/dist/generic/ScopeTemplateRenderer.d.ts.map +1 -0
  23. package/dist/generic/ScopeTemplateRenderer.js +110 -0
  24. package/dist/generic/ScopeTemplateRenderer.js.map +1 -0
  25. package/dist/generic/SearchEngine.d.ts +180 -12
  26. package/dist/generic/SearchEngine.d.ts.map +1 -1
  27. package/dist/generic/SearchEngine.js +737 -35
  28. package/dist/generic/SearchEngine.js.map +1 -1
  29. package/dist/generic/SearchFusion.d.ts +40 -6
  30. package/dist/generic/SearchFusion.d.ts.map +1 -1
  31. package/dist/generic/SearchFusion.js +139 -18
  32. package/dist/generic/SearchFusion.js.map +1 -1
  33. package/dist/generic/StorageSearchProvider.d.ts +9 -2
  34. package/dist/generic/StorageSearchProvider.d.ts.map +1 -1
  35. package/dist/generic/StorageSearchProvider.js +44 -12
  36. package/dist/generic/StorageSearchProvider.js.map +1 -1
  37. package/dist/generic/VectorSearchProvider.d.ts +9 -2
  38. package/dist/generic/VectorSearchProvider.d.ts.map +1 -1
  39. package/dist/generic/VectorSearchProvider.js +83 -13
  40. package/dist/generic/VectorSearchProvider.js.map +1 -1
  41. package/dist/generic/search.types.d.ts +206 -0
  42. package/dist/generic/search.types.d.ts.map +1 -1
  43. package/dist/index.d.ts +13 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +18 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/permissions/SearchScopePermissionResolver.d.ts +109 -0
  48. package/dist/permissions/SearchScopePermissionResolver.d.ts.map +1 -0
  49. package/dist/permissions/SearchScopePermissionResolver.js +159 -0
  50. package/dist/permissions/SearchScopePermissionResolver.js.map +1 -0
  51. package/dist/providers/AzureAISearchProvider.d.ts +37 -0
  52. package/dist/providers/AzureAISearchProvider.d.ts.map +1 -0
  53. package/dist/providers/AzureAISearchProvider.js +180 -0
  54. package/dist/providers/AzureAISearchProvider.js.map +1 -0
  55. package/dist/providers/ElasticsearchSearchProvider.d.ts +43 -0
  56. package/dist/providers/ElasticsearchSearchProvider.d.ts.map +1 -0
  57. package/dist/providers/ElasticsearchSearchProvider.js +200 -0
  58. package/dist/providers/ElasticsearchSearchProvider.js.map +1 -0
  59. package/dist/providers/OpenSearchSearchProvider.d.ts +36 -0
  60. package/dist/providers/OpenSearchSearchProvider.d.ts.map +1 -0
  61. package/dist/providers/OpenSearchSearchProvider.js +167 -0
  62. package/dist/providers/OpenSearchSearchProvider.js.map +1 -0
  63. package/dist/providers/TypesenseSearchProvider.d.ts +36 -0
  64. package/dist/providers/TypesenseSearchProvider.d.ts.map +1 -0
  65. package/dist/providers/TypesenseSearchProvider.js +161 -0
  66. package/dist/providers/TypesenseSearchProvider.js.map +1 -0
  67. package/dist/rerankers/BGEReRanker.d.ts +57 -0
  68. package/dist/rerankers/BGEReRanker.d.ts.map +1 -0
  69. package/dist/rerankers/BGEReRanker.js +193 -0
  70. package/dist/rerankers/BGEReRanker.js.map +1 -0
  71. package/dist/rerankers/CohereReRanker.d.ts +65 -0
  72. package/dist/rerankers/CohereReRanker.d.ts.map +1 -0
  73. package/dist/rerankers/CohereReRanker.js +155 -0
  74. package/dist/rerankers/CohereReRanker.js.map +1 -0
  75. package/dist/rerankers/OpenAIReRanker.d.ts +62 -0
  76. package/dist/rerankers/OpenAIReRanker.d.ts.map +1 -0
  77. package/dist/rerankers/OpenAIReRanker.js +197 -0
  78. package/dist/rerankers/OpenAIReRanker.js.map +1 -0
  79. package/dist/rerankers/RerankerBudgetGuard.d.ts +54 -0
  80. package/dist/rerankers/RerankerBudgetGuard.d.ts.map +1 -0
  81. package/dist/rerankers/RerankerBudgetGuard.js +67 -0
  82. package/dist/rerankers/RerankerBudgetGuard.js.map +1 -0
  83. package/dist/rerankers/VoyageReRanker.d.ts +59 -0
  84. package/dist/rerankers/VoyageReRanker.d.ts.map +1 -0
  85. package/dist/rerankers/VoyageReRanker.js +184 -0
  86. package/dist/rerankers/VoyageReRanker.js.map +1 -0
  87. package/package.json +13 -8
@@ -5,7 +5,14 @@
5
5
  * fuses results with Reciprocal Rank Fusion (RRF), applies enrichment
6
6
  * (entity icons, record names, tags), and filters by minimum score.
7
7
  *
8
- * Providers are loaded via @RegisterClass(BaseSearchProvider, DriverClass) and
8
+ * When `SearchParams.ScopeIDs` is provided, the engine resolves each scope against
9
+ * `SearchEngineBase` (which caches all `MJ: Search Scope*` metadata), builds a
10
+ * `ScopeConstraints` object per scope (including Nunjucks-rendered MetadataFilter,
11
+ * ExtraFilter, UserSearchString, and FolderPath values), runs each scope's providers
12
+ * in parallel, then fuses the per-scope results via cross-scope RRF. An optional
13
+ * re-ranker stage (`BaseReRanker`) runs after fusion when configured.
14
+ *
15
+ * Providers are loaded via `@RegisterClass(BaseSearchProvider, DriverClass)` and
9
16
  * instantiated using the MJ ClassFactory based on active SearchProvider records.
10
17
  *
11
18
  * Uses BaseSingleton from @memberjunction/global for a truly global instance.
@@ -14,29 +21,43 @@
14
21
  */
15
22
  import { EntityPermissionType, LogError, LogStatus, RunView } from '@memberjunction/core';
16
23
  import { SearchEngineBase } from '@memberjunction/core-entities';
17
- import { BaseSingleton, MJGlobal, NormalizeUUID } from '@memberjunction/global';
24
+ import { BaseSingleton, MJGlobal, NormalizeUUID, UUIDsEqual } from '@memberjunction/global';
18
25
  import { BaseSearchProvider } from './ISearchProvider.js';
19
26
  import { SearchFusion } from './SearchFusion.js';
20
27
  import { SearchEnricher } from './SearchEnricher.js';
21
28
  import { FullTextSearchProvider } from './FullTextSearchProvider.js';
29
+ import { BaseReRanker } from './BaseReRanker.js';
30
+ import { NoopReRanker, LoadNoopReRanker } from './NoopReRanker.js';
31
+ import { RerankerBudgetGuard } from '../rerankers/RerankerBudgetGuard.js';
32
+ import { RenderScopeTemplate, RenderScopeJsonTemplate } from './ScopeTemplateRenderer.js';
33
+ // Keep the default re-ranker registration alive under tree-shaking
34
+ LoadNoopReRanker();
22
35
  /**
23
36
  * Singleton search engine that orchestrates multi-source search with RRF fusion.
24
37
  *
25
38
  * Providers are discovered from the MJ: Search Providers entity. Each active
26
39
  * provider's DriverClass is resolved via ClassFactory to create an instance,
27
- * which is then initialized with the provider's config from the DB.
40
+ * which is then initialized with the provider's config from the DB record.
28
41
  *
29
42
  * Usage:
30
43
  * ```typescript
31
44
  * // Initialize once at server startup
32
45
  * await SearchEngine.Instance.Config({}, contextUser);
33
46
  *
34
- * // Execute searches
47
+ * // Execute searches (unscoped — original behavior)
35
48
  * const result = await SearchEngine.Instance.Search({
36
49
  * Query: 'quarterly revenue',
37
50
  * MaxResults: 20,
38
51
  * MinScore: 0.1
39
52
  * }, contextUser);
53
+ *
54
+ * // Scoped search against two scopes with multi-tenant context
55
+ * const scopedResult = await SearchEngine.Instance.Search({
56
+ * Query: 'refund policy',
57
+ * MaxResults: 20,
58
+ * ScopeIDs: ['hr-scope-id', 'legal-scope-id'],
59
+ * SearchContext: { PrimaryScopeRecordID: 'tenant-a' }
60
+ * }, contextUser);
40
61
  * ```
41
62
  */
42
63
  export class SearchEngine extends BaseSingleton {
@@ -48,11 +69,28 @@ export class SearchEngine extends BaseSingleton {
48
69
  this._fusion = new SearchFusion();
49
70
  this._enricher = new SearchEnricher();
50
71
  this._defaultMaxResults = 20;
72
+ this._defaultOverfetchFactor = 2;
73
+ this._cache = new Map();
51
74
  }
52
75
  /** Static accessor for the global singleton instance */
53
76
  static get Instance() {
54
77
  return super.getInstance();
55
78
  }
79
+ /**
80
+ * Minimum trimmed query length we accept. One- and two-character queries against
81
+ * a `LIKE '%term%'` fan-out are essentially full-database scans with negligible
82
+ * relevance — the providers also enforce this, but we short-circuit here to
83
+ * avoid the cache lookup and provider dispatch overhead too.
84
+ */
85
+ static { this.MIN_TERM_LENGTH = 3; }
86
+ /**
87
+ * Result cache TTL. 30s balances "user resubmits the same prefix" wins against
88
+ * "results stay reasonably fresh after a write". Cache key includes the user's
89
+ * ID so two users with different RLS scopes never share an entry.
90
+ */
91
+ static { this.CACHE_TTL_MS = 30_000; }
92
+ /** Maximum cached entries across all users. LRU-evicted on overflow. */
93
+ static { this.CACHE_MAX_ENTRIES = 500; }
56
94
  /** Access the cached provider metadata from SearchEngineBase */
57
95
  get Base() {
58
96
  return SearchEngineBase.Instance;
@@ -76,8 +114,9 @@ export class SearchEngine extends BaseSingleton {
76
114
  if (this._configured && !forceRefresh)
77
115
  return;
78
116
  this._defaultMaxResults = config.DefaultMaxResults ?? 20;
117
+ this._defaultOverfetchFactor = Math.max(1, config.DefaultPermissionOverfetchFactor ?? 2);
79
118
  this._providerEntries = [];
80
- // Ensure SearchEngineBase has loaded provider metadata
119
+ // Ensure SearchEngineBase has loaded provider + scope metadata
81
120
  await this.Base.Config(forceRefresh, contextUser);
82
121
  const providerRecords = this.Base.ActiveProviders;
83
122
  if (providerRecords.length === 0) {
@@ -95,29 +134,67 @@ export class SearchEngine extends BaseSingleton {
95
134
  this._providerEntries.sort((a, b) => a.Priority - b.Priority);
96
135
  this._configured = true;
97
136
  const names = this._providerEntries.map(e => e.Provider.SourceType);
98
- LogStatus(`SearchEngine: Configured with ${this._providerEntries.length} provider(s): ${names.join(', ')}`);
137
+ LogStatus(`SearchEngine: Configured with ${this._providerEntries.length} provider(s): ${names.join(', ')} and ${this.Base.Scopes.length} scope(s)`);
99
138
  }
100
139
  /**
101
140
  * Execute a multi-source search with RRF fusion and optional enrichment.
102
141
  *
103
- * Steps:
104
- * 1. Run all available providers in parallel
105
- * 2. Fuse results with RRF
106
- * 3. Deduplicate by EntityName+RecordID
107
- * 4. Exclude redundant entity-sourced Content Items
108
- * 5. Apply minimum score threshold
109
- * 6. Enrich with icons, names, and tags (skipped in preview mode)
142
+ * When `params.ScopeIDs` is provided, each scope runs independently and the results
143
+ * are combined via cross-scope RRF before deduplication, re-ranking, and enrichment.
110
144
  *
111
145
  * @param params - Search parameters
112
146
  * @param contextUser - The user performing the search
113
147
  * @returns Aggregated search result
114
148
  */
115
149
  async Search(params, contextUser) {
150
+ return this.searchInternal(params, contextUser);
151
+ }
152
+ /**
153
+ * Internal search implementation that optionally fires `onProviderResolved`
154
+ * as each provider's promise settles. Exposed via the public {@link Search}
155
+ * (no callback) and {@link streamSearch} (queue-backed callback that
156
+ * yields `provider` events to the caller).
157
+ */
158
+ async searchInternal(params, contextUser, onProviderResolved) {
116
159
  const startTime = Date.now();
160
+ // Per-invocation tracking for the post-search SearchExecutionLog row (P3.2).
161
+ let invocationBudgetGuard = null;
162
+ let invocationRerankerName = null;
117
163
  try {
118
- if (!params.Query.trim()) {
164
+ // Defensive null-check: `params.Query.trim()` throws on null/undefined,
165
+ // and Sage's LLM has been observed to emit empty/missing tool args.
166
+ // Coerce to string before validating so we surface a clean error
167
+ // instead of a TypeError that would also skip the audit-log row.
168
+ if (params.Query == null || typeof params.Query !== 'string' || !params.Query.trim()) {
169
+ this.logSearchExecution({
170
+ Status: 'Failure',
171
+ FailureReason: 'Query cannot be empty',
172
+ Query: typeof params.Query === 'string' ? params.Query : '',
173
+ ScopeIDs: params.ScopeIDs,
174
+ StartTime: startTime,
175
+ ResultCount: 0,
176
+ RerankerName: null,
177
+ RerankerCostCents: null,
178
+ SourceCounts: undefined,
179
+ ContextUser: contextUser,
180
+ AIAgentID: params.AIAgentID ?? null,
181
+ });
119
182
  return this.buildErrorResult('Query cannot be empty', startTime);
120
183
  }
184
+ const trimmed = params.Query.trim();
185
+ if (trimmed.length < SearchEngine.MIN_TERM_LENGTH) {
186
+ // Short queries hit unindexed table-scans fanned out across every
187
+ // searchable entity with negligible relevance. Return an empty
188
+ // success result rather than burning resources.
189
+ return {
190
+ Success: true,
191
+ Results: [],
192
+ TotalCount: 0,
193
+ ElapsedMs: Date.now() - startTime,
194
+ SourceCounts: { Vector: 0, FullText: 0, Entity: 0, Storage: 0 },
195
+ Providers: this._configured ? this.buildProviderInfoList() : [],
196
+ };
197
+ }
121
198
  // Ensure configured
122
199
  if (!this._configured) {
123
200
  await this.Config({}, contextUser);
@@ -125,28 +202,114 @@ export class SearchEngine extends BaseSingleton {
125
202
  const topK = params.MaxResults ?? this._defaultMaxResults;
126
203
  const mode = params.Mode ?? 'full';
127
204
  const isPreview = mode === 'preview';
128
- // Step 1: Run all providers in parallel (respecting preview flag)
129
- const labeledLists = await this.executeProviders(params.Query, topK, params.Filters, contextUser, isPreview);
130
- const sourceCounts = this.countSources(labeledLists);
131
- // Step 2: Fuse with RRF
132
- let results = this._fusion.Fuse(labeledLists, topK);
133
- // Step 3: Deduplicate
134
- results = this._fusion.Deduplicate(results);
135
- // Step 4: Exclude redundant Content Items
205
+ const overfetchFactor = Math.max(1, params.PermissionOverfetchFactor ?? this._defaultOverfetchFactor);
206
+ const providerTopK = Math.max(topK, Math.ceil(topK * overfetchFactor));
207
+ // ──────────────────────────────────────────────────────────
208
+ // Resolve scopes (when supplied)
209
+ // ──────────────────────────────────────────────────────────
210
+ const resolvedScopes = this.resolveScopes(params.ScopeIDs);
211
+ const isUnconstrained = resolvedScopes.length === 0 || resolvedScopes.some(s => s.Scope.IsGlobal);
212
+ // ──────────────────────────────────────────────────────────
213
+ // Cache lookup (next PR #2532). Skip preview searches — they're already
214
+ // cheap and caching would mask config changes during dev. Cache key
215
+ // includes the user's ID so two users with different RLS scopes never
216
+ // share an entry.
217
+ // ──────────────────────────────────────────────────────────
218
+ const cacheKey = isPreview ? null : this.buildCacheKey(trimmed, params, contextUser);
219
+ if (cacheKey) {
220
+ const hit = this._cache.get(cacheKey);
221
+ if (hit && hit.expires > Date.now()) {
222
+ // Move to end for LRU recency
223
+ this._cache.delete(cacheKey);
224
+ this._cache.set(cacheKey, hit);
225
+ return { ...hit.result, ElapsedMs: Date.now() - startTime };
226
+ }
227
+ if (hit)
228
+ this._cache.delete(cacheKey); // expired
229
+ }
230
+ // ──────────────────────────────────────────────────────────
231
+ // Execute providers — either unscoped (original path) or per-scope
232
+ // ──────────────────────────────────────────────────────────
233
+ let sourceCounts;
234
+ let fusedResults;
235
+ if (isUnconstrained) {
236
+ const labeledLists = await this.executeProviders(params.Query, providerTopK, params.Filters, contextUser, isPreview, undefined, onProviderResolved);
237
+ sourceCounts = this.countSources(labeledLists);
238
+ const defaultFusionWeights = params.FusionWeightsOverride;
239
+ fusedResults = this._fusion.Fuse(labeledLists, providerTopK, defaultFusionWeights);
240
+ }
241
+ else {
242
+ // Run each scope independently, then cross-scope RRF
243
+ const perScopeRunResults = await Promise.all(resolvedScopes.map(bundle => this.executeScopeBundle(params.Query, providerTopK, params.Filters, contextUser, isPreview, bundle, params.SearchContext, params.FusionWeightsOverride, onProviderResolved)));
244
+ const sc = { Vector: 0, FullText: 0, Entity: 0, Storage: 0 };
245
+ const perScopeFused = new Map();
246
+ for (const r of perScopeRunResults) {
247
+ sc.Vector += r.sourceCounts.Vector;
248
+ sc.FullText += r.sourceCounts.FullText;
249
+ sc.Entity += r.sourceCounts.Entity;
250
+ sc.Storage += r.sourceCounts.Storage;
251
+ perScopeFused.set(r.scopeID, r.fused);
252
+ }
253
+ sourceCounts = sc;
254
+ if (perScopeFused.size > 1) {
255
+ fusedResults = this._fusion.CrossScopeFusion(perScopeFused, providerTopK);
256
+ }
257
+ else {
258
+ const only = perScopeFused.values().next().value;
259
+ fusedResults = only ?? [];
260
+ }
261
+ }
262
+ // ──────────────────────────────────────────────────────────
263
+ // Optional re-ranker stage (one per leading scope — pick the first active scope's config)
264
+ // ──────────────────────────────────────────────────────────
265
+ const reRankerConfig = this.pickReRankerConfig(resolvedScopes);
266
+ if (reRankerConfig?.driverClass) {
267
+ // Budget guard (P2D.6): cap real-provider rerank spend at the scope's
268
+ // RerankerBudgetCents. Pulled from the same scope that supplied the
269
+ // reranker config — keeping the policy local to the scope that opted in.
270
+ const budgetCents = this.pickRerankerBudgetCents(resolvedScopes);
271
+ invocationBudgetGuard = new RerankerBudgetGuard(budgetCents);
272
+ invocationRerankerName = reRankerConfig.driverClass;
273
+ fusedResults = await this.runReRanker(params.Query, fusedResults, reRankerConfig, contextUser, invocationBudgetGuard);
274
+ }
275
+ // ──────────────────────────────────────────────────────────
276
+ // Dedup → content-item exclusion → permission safety net → score threshold → enrich
277
+ // ──────────────────────────────────────────────────────────
278
+ let results = this._fusion.Deduplicate(fusedResults);
136
279
  results = await this._enricher.ExcludeEntitySourcedContentItems(results, contextUser);
137
- // Step 4.5: Filter by entity-level and row-level permissions
280
+ const beforePermCount = results.length;
138
281
  results = await this.filterByPermissions(results, contextUser);
139
- // Step 5: Apply minimum score threshold
282
+ const lateFilteredCount = beforePermCount - results.length;
283
+ if (lateFilteredCount > 0) {
284
+ // Observability: Section 3.6 — if a provider's push-down is complete, this
285
+ // number should be zero (the safety net should never trim anything).
286
+ LogStatus(`SearchEngine: Residual permission filter removed ${lateFilteredCount} result(s) — consider tightening provider push-down.`);
287
+ }
140
288
  const scoreThreshold = params.MinScore ?? 0;
141
289
  if (scoreThreshold > 0) {
142
290
  results = results.filter(r => r.Score >= scoreThreshold);
143
291
  }
144
- // Step 6: Enrich (skip in preview mode)
292
+ // Trim to caller's requested topK (we overfetched earlier)
293
+ if (results.length > topK)
294
+ results = results.slice(0, topK);
145
295
  if (!isPreview) {
146
296
  await this._enricher.Enrich(results, contextUser);
147
297
  }
148
- LogStatus(`SearchEngine: Search complete in ${Date.now() - startTime}ms - ${results.length} results`);
149
- return {
298
+ LogStatus(`SearchEngine: Search complete in ${Date.now() - startTime}ms - ${results.length} result(s)${resolvedScopes.length ? ` across ${resolvedScopes.length} scope(s)` : ''}`);
299
+ this.logSearchExecution({
300
+ Status: 'Success',
301
+ FailureReason: null,
302
+ Query: params.Query,
303
+ ScopeIDs: params.ScopeIDs,
304
+ StartTime: startTime,
305
+ ResultCount: results.length,
306
+ RerankerName: invocationRerankerName,
307
+ RerankerCostCents: invocationBudgetGuard ? invocationBudgetGuard.Spent : null,
308
+ SourceCounts: sourceCounts,
309
+ ContextUser: contextUser,
310
+ AIAgentID: params.AIAgentID ?? null,
311
+ });
312
+ const finalResult = {
150
313
  Success: true,
151
314
  Results: results,
152
315
  TotalCount: results.length,
@@ -154,13 +317,200 @@ export class SearchEngine extends BaseSingleton {
154
317
  SourceCounts: sourceCounts,
155
318
  Providers: this.buildProviderInfoList(),
156
319
  };
320
+ if (cacheKey) {
321
+ this.cachePut(cacheKey, finalResult);
322
+ }
323
+ return finalResult;
157
324
  }
158
325
  catch (error) {
159
326
  const msg = error instanceof Error ? error.message : String(error);
160
327
  LogError(`SearchEngine: Search failed: ${msg}`);
328
+ this.logSearchExecution({
329
+ Status: 'Failure',
330
+ FailureReason: msg,
331
+ Query: params.Query,
332
+ ScopeIDs: params.ScopeIDs,
333
+ StartTime: startTime,
334
+ ResultCount: 0,
335
+ RerankerName: invocationRerankerName,
336
+ RerankerCostCents: invocationBudgetGuard ? invocationBudgetGuard.Spent : null,
337
+ SourceCounts: undefined,
338
+ ContextUser: contextUser,
339
+ AIAgentID: params.AIAgentID ?? null,
340
+ });
161
341
  return this.buildErrorResult(msg, startTime);
162
342
  }
163
343
  }
344
+ /**
345
+ * Streaming variant of {@link Search}. Yields events as each pipeline
346
+ * stage produces output so the caller can emit partials to the UI / agent
347
+ * before fusion + reranking complete.
348
+ *
349
+ * **Phase 2C v1 semantics:** runs the same internal pipeline as
350
+ * {@link Search} and emits synthetic events at each transition. This
351
+ * preserves all existing fusion / permission / dedup / enrich behavior
352
+ * — important because those steps have subtle correctness rules that
353
+ * we don't want to re-implement in a parallel code path. Per-provider
354
+ * partials are reconstructed from the final SourceCounts; a future
355
+ * refactor (Phase 2C v2) can split provider emission to true real-time
356
+ * concurrent emission once we measure that the synthetic phase is the
357
+ * actual bottleneck.
358
+ *
359
+ * Cancellation: the consumer can stop iterating at any point — the
360
+ * underlying Search() will run to completion but its result is
361
+ * discarded. AbortSignal-based mid-pipeline cancellation is a Phase 2C
362
+ * v2 concern.
363
+ *
364
+ * Event ordering:
365
+ * 1. Zero or more `provider` events (one per non-empty source)
366
+ * 2. Exactly one `fused` event
367
+ * 3. Optional one `reranked` event (when a reranker is configured)
368
+ * 4. Exactly one `final` event
369
+ * 5. On error: a single `error` event in place of `final`.
370
+ *
371
+ * @example
372
+ * for await (const ev of SearchEngine.Instance.streamSearch(params, user)) {
373
+ * switch (ev.phase) {
374
+ * case 'provider': scratchpad.append(`${ev.providerName}: ${ev.results.length} hits`); break;
375
+ * case 'final': scratchpad.commit(ev.results); break;
376
+ * case 'error': scratchpad.fail(ev.error); break;
377
+ * }
378
+ * }
379
+ */
380
+ async *streamSearch(params, contextUser) {
381
+ // Phase 2C v2: true concurrent emission. The internal search is run
382
+ // with an `onProviderResolved` callback that pushes a `provider`
383
+ // event into a queue the moment each provider's promise settles.
384
+ // The generator drains the queue while the search keeps running, so
385
+ // `provider` events arrive as fast as their providers resolve. After
386
+ // the search completes (or errors) we emit `fused` + `final` (or
387
+ // `error`) and close the iterator.
388
+ //
389
+ // Cancellation: if the consumer breaks out of `for await`, the
390
+ // generator's `return()` runs and the underlying search is allowed
391
+ // to finish in the background (its result is discarded). Mid-pipeline
392
+ // AbortSignal propagation is a future enhancement.
393
+ const queue = [];
394
+ let resolveNext = null;
395
+ let done = false;
396
+ const push = (ev) => {
397
+ queue.push(ev);
398
+ if (resolveNext) {
399
+ const r = resolveNext;
400
+ resolveNext = null;
401
+ r();
402
+ }
403
+ };
404
+ const finish = () => {
405
+ done = true;
406
+ if (resolveNext) {
407
+ const r = resolveNext;
408
+ resolveNext = null;
409
+ r();
410
+ }
411
+ };
412
+ // Source-type → friendly provider label mapping for stable UI display.
413
+ // Keys are lowercased to match what providers report.
414
+ const sourceTypeToLabel = {
415
+ vector: 'Vector',
416
+ fulltext: 'FullText',
417
+ entity: 'Entity',
418
+ storage: 'Storage',
419
+ };
420
+ const onProviderResolved = (ev) => {
421
+ const label = sourceTypeToLabel[ev.sourceType.toLowerCase()] ?? ev.sourceType;
422
+ push({
423
+ phase: 'provider',
424
+ providerName: label,
425
+ results: ev.results,
426
+ durationMs: ev.durationMs,
427
+ });
428
+ };
429
+ // Kick off the search; do NOT await it here — we want the generator
430
+ // loop below to interleave with the provider callbacks.
431
+ const searchPromise = (async () => {
432
+ try {
433
+ const result = await this.searchInternal(params, contextUser, onProviderResolved);
434
+ if (!result.Success) {
435
+ push({ phase: 'error', error: result.ErrorMessage ?? 'Search failed' });
436
+ return;
437
+ }
438
+ push({ phase: 'fused', results: result.Results });
439
+ // Reranker emission is intentionally elided here — the engine's
440
+ // post-fusion rerank fires inside `searchInternal` before this
441
+ // point, and observers seeking that signal should look at the
442
+ // final SearchExecutionLog row. Keeping this generator narrow
443
+ // avoids leaking rerank internals into a streaming surface that
444
+ // can't faithfully separate them from fusion.
445
+ push({
446
+ phase: 'final',
447
+ results: result.Results,
448
+ sourceCounts: result.SourceCounts,
449
+ elapsedMs: result.ElapsedMs,
450
+ });
451
+ }
452
+ catch (err) {
453
+ push({ phase: 'error', error: err instanceof Error ? err.message : String(err) });
454
+ }
455
+ finally {
456
+ finish();
457
+ }
458
+ })();
459
+ // Drain the queue. The loop blocks on `resolveNext` between bursts
460
+ // so that we don't busy-spin while waiting for providers.
461
+ try {
462
+ while (true) {
463
+ if (queue.length > 0) {
464
+ yield queue.shift();
465
+ continue;
466
+ }
467
+ if (done) {
468
+ break;
469
+ }
470
+ await new Promise((resolve) => {
471
+ resolveNext = resolve;
472
+ });
473
+ }
474
+ }
475
+ finally {
476
+ // If the consumer aborts mid-iteration, surface any background
477
+ // error so it isn't silently swallowed. We don't await the
478
+ // promise on the happy path because `done` already implies it
479
+ // settled.
480
+ if (!done) {
481
+ searchPromise.catch(() => { });
482
+ }
483
+ }
484
+ }
485
+ /**
486
+ * Build a stable cache key for a search. Includes the user identity so RLS
487
+ * scopes never bleed across users, plus the trimmed query, MaxResults,
488
+ * MinScore, and a deterministic projection of Filters.
489
+ */
490
+ buildCacheKey(trimmed, params, contextUser) {
491
+ const userKey = contextUser?.ID ?? 'anonymous';
492
+ const f = params.Filters ?? {};
493
+ const filterKey = JSON.stringify({
494
+ EntityNames: f.EntityNames ? [...f.EntityNames].sort() : undefined,
495
+ SourceTypes: f.SourceTypes ? [...f.SourceTypes].sort() : undefined,
496
+ Tags: f.Tags ? [...f.Tags].sort() : undefined,
497
+ });
498
+ return `${userKey}|${trimmed}|${params.MaxResults ?? this._defaultMaxResults}|${params.MinScore ?? 0}|${filterKey}`;
499
+ }
500
+ /** Insert into the LRU cache, evicting oldest entries when over capacity. */
501
+ cachePut(key, result) {
502
+ if (this._cache.size >= SearchEngine.CACHE_MAX_ENTRIES) {
503
+ // Map iteration order is insertion order — the first entry is oldest.
504
+ const oldest = this._cache.keys().next().value;
505
+ if (oldest !== undefined)
506
+ this._cache.delete(oldest);
507
+ }
508
+ this._cache.set(key, { result, expires: Date.now() + SearchEngine.CACHE_TTL_MS });
509
+ }
510
+ /** Test / admin hook: clear the result cache. */
511
+ ClearResultCache() {
512
+ this._cache.clear();
513
+ }
164
514
  /**
165
515
  * Quick preview search optimized for autocomplete / typeahead.
166
516
  * Uses preview mode (no enrichment), limited to 8 results by default.
@@ -179,6 +529,239 @@ export class SearchEngine extends BaseSingleton {
179
529
  }, contextUser);
180
530
  }
181
531
  // ────────────────────────────────────────────────────────────────
532
+ // Scope resolution
533
+ // ────────────────────────────────────────────────────────────────
534
+ /**
535
+ * Load `ScopeBundle`s for each requested scope ID, filtering out inactive / expired.
536
+ * Returns an empty array when no scope IDs are supplied (caller treats as Global).
537
+ */
538
+ resolveScopes(scopeIDs) {
539
+ if (!scopeIDs || scopeIDs.length === 0)
540
+ return [];
541
+ const bundles = [];
542
+ for (const id of scopeIDs) {
543
+ const scope = this.Base.GetActiveScopeByID(id);
544
+ if (!scope) {
545
+ LogStatus(`SearchEngine: Requested scope "${id}" is not active or does not exist — skipping.`);
546
+ continue;
547
+ }
548
+ const bundle = this.Base.GetScopeBundle(id);
549
+ if (bundle)
550
+ bundles.push(bundle);
551
+ }
552
+ return bundles;
553
+ }
554
+ /**
555
+ * Execute all scoped providers for a single scope bundle and return per-scope fused results.
556
+ */
557
+ async executeScopeBundle(query, topK, filters, contextUser, isPreview, bundle, searchContext, agentFusionWeights, onProviderResolved) {
558
+ const scope = bundle.Scope;
559
+ const scopeConfig = this.parseJson(scope.ScopeConfig);
560
+ const constraints = this.buildScopeConstraints(bundle, searchContext);
561
+ const perProviderQueryTransforms = constraints.QueryTransforms ?? {};
562
+ // Determine which providers this scope participates in (SearchScopeProvider rows)
563
+ const scopeProviderIDs = new Set(bundle.Providers.map(p => NormalizeUUID(p.SearchProviderID)));
564
+ const allowAllProviders = scopeProviderIDs.size === 0; // empty = scope is IsGlobal or all-inclusive
565
+ const applicableProviders = this._providerEntries.filter(entry => {
566
+ if (!entry.Provider.IsAvailable())
567
+ return false;
568
+ if (isPreview && !entry.SupportsPreview)
569
+ return false;
570
+ if (allowAllProviders)
571
+ return true;
572
+ return scopeProviderIDs.has(NormalizeUUID(entry.ID));
573
+ });
574
+ if (applicableProviders.length === 0) {
575
+ LogStatus(`SearchEngine: Scope "${scope.Name}" has no applicable providers — skipping.`);
576
+ return {
577
+ scopeID: scope.ID,
578
+ fused: [],
579
+ sourceCounts: { Vector: 0, FullText: 0, Entity: 0, Storage: 0 }
580
+ };
581
+ }
582
+ // Resolve per-provider `SearchScopeProvider.MaxResultsOverride` if present
583
+ const promises = applicableProviders.map(async (entry) => {
584
+ const providerStart = Date.now();
585
+ try {
586
+ const spRow = bundle.Providers.find(r => UUIDsEqual(r.SearchProviderID, entry.ID));
587
+ const effectiveTopK = spRow?.MaxResultsOverride ?? entry.MaxResultsOverride ?? topK;
588
+ // If this provider has a per-provider QueryTransform override, stash it
589
+ // under the provider's SourceType in QueryTransforms so the provider finds it.
590
+ const perProviderConstraints = {
591
+ ...constraints,
592
+ QueryTransforms: { ...perProviderQueryTransforms }
593
+ };
594
+ // (Note: actual Nunjucks-rendered `QueryTransformTemplateID` resolution for
595
+ // stored templates lives in AgentPreExecutionRAG/ScopedSearchAction, not here.
596
+ // This engine only forwards already-rendered strings that the caller provides.)
597
+ const providerResults = await entry.Provider.Search(query, effectiveTopK, filters, contextUser, perProviderConstraints);
598
+ // Stamp provider metadata onto each result
599
+ for (const r of providerResults) {
600
+ r.ProviderId = entry.ID;
601
+ r.ProviderLabel = entry.DisplayName;
602
+ r.ProviderIcon = entry.Icon;
603
+ }
604
+ if (onProviderResolved) {
605
+ try {
606
+ onProviderResolved({
607
+ sourceType: entry.Provider.SourceType,
608
+ results: providerResults,
609
+ durationMs: Date.now() - providerStart,
610
+ scopeID: scope.ID,
611
+ });
612
+ }
613
+ catch (cbErr) {
614
+ const cbMsg = cbErr instanceof Error ? cbErr.message : String(cbErr);
615
+ LogError(`SearchEngine: onProviderResolved callback threw for "${entry.Provider.SourceType}" in scope "${scope.Name}": ${cbMsg}`);
616
+ }
617
+ }
618
+ return { Source: entry.Provider.SourceType, Results: providerResults };
619
+ }
620
+ catch (error) {
621
+ const msg = error instanceof Error ? error.message : String(error);
622
+ LogError(`SearchEngine: Provider "${entry.Provider.SourceType}" failed in scope "${scope.Name}": ${msg}`);
623
+ if (onProviderResolved) {
624
+ try {
625
+ onProviderResolved({
626
+ sourceType: entry.Provider.SourceType,
627
+ results: [],
628
+ durationMs: Date.now() - providerStart,
629
+ scopeID: scope.ID,
630
+ });
631
+ }
632
+ catch { /* swallow */ }
633
+ }
634
+ return { Source: entry.Provider.SourceType, Results: [] };
635
+ }
636
+ });
637
+ const labeled = await Promise.all(promises);
638
+ const sourceCounts = this.countSources(labeled);
639
+ // Per-scope fusion with weight resolution:
640
+ // agent fusion weights > scope.ScopeConfig.fusionWeights > engine defaults
641
+ const scopeWeights = scopeConfig && typeof scopeConfig.fusionWeights === 'object'
642
+ ? scopeConfig.fusionWeights
643
+ : undefined;
644
+ const fusionWeights = agentFusionWeights ?? scopeWeights;
645
+ const fused = this._fusion.Fuse(labeled, topK, fusionWeights);
646
+ return { scopeID: scope.ID, fused, sourceCounts };
647
+ }
648
+ /**
649
+ * Assemble a `ScopeConstraints` for a single scope: Nunjucks-render each template
650
+ * field against the `SearchContext`, then hand the rendered values to providers.
651
+ */
652
+ buildScopeConstraints(bundle, searchContext) {
653
+ const externalIndexes = bundle.ExternalIndexes.map(row => ({
654
+ SearchScopeExternalIndexID: row.ID,
655
+ IndexType: row.IndexType,
656
+ VectorIndexID: row.VectorIndexID ?? undefined,
657
+ ExternalIndexName: row.ExternalIndexName ?? undefined,
658
+ ExternalIndexConfig: this.parseJson(row.ExternalIndexConfig),
659
+ MetadataFilter: RenderScopeJsonTemplate(row.MetadataFilter, searchContext)
660
+ }));
661
+ const entities = bundle.Entities.map(row => ({
662
+ SearchScopeEntityID: row.ID,
663
+ EntityID: row.EntityID,
664
+ EntityName: this.lookupEntityName(row.EntityID),
665
+ ExtraFilter: row.ExtraFilter ? RenderScopeTemplate(row.ExtraFilter, searchContext) : undefined,
666
+ UserSearchString: row.UserSearchString ? RenderScopeTemplate(row.UserSearchString, searchContext) : undefined
667
+ }));
668
+ const storage = bundle.StorageAccounts.map(row => ({
669
+ SearchScopeStorageAccountID: row.ID,
670
+ FileStorageAccountID: row.FileStorageAccountID,
671
+ FolderPath: row.FolderPath ? RenderScopeTemplate(row.FolderPath, searchContext) : undefined
672
+ }));
673
+ // Per-provider query transforms: resolved from SearchScopeProvider.QueryTransformTemplateID
674
+ // For stored template IDs we need the TemplateEngine — that resolution happens in
675
+ // Phase 1C (AgentPreExecutionRAG) before this engine is called. We still honor any
676
+ // pre-rendered transforms that upstream callers placed in the scope config bag.
677
+ const scopeConfig = this.parseJson(bundle.Scope.ScopeConfig);
678
+ const rawTransforms = scopeConfig?.perProviderQueryTransforms;
679
+ const queryTransforms = rawTransforms && typeof rawTransforms === 'object'
680
+ ? { ...rawTransforms }
681
+ : undefined;
682
+ return {
683
+ ExternalIndexes: externalIndexes.length ? externalIndexes : undefined,
684
+ Entities: entities.length ? entities : undefined,
685
+ StorageAccounts: storage.length ? storage : undefined,
686
+ Context: searchContext,
687
+ QueryTransforms: queryTransforms,
688
+ ScopeConfig: scopeConfig ?? undefined
689
+ };
690
+ }
691
+ /** Resolve the EntityID → EntityName via MJ Metadata (for passing to providers that key by name). */
692
+ lookupEntityName(entityID) {
693
+ try {
694
+ const entity = this.ProviderToUse.Entities.find(e => UUIDsEqual(e.ID, entityID));
695
+ return entity?.Name ?? '';
696
+ }
697
+ catch {
698
+ return '';
699
+ }
700
+ }
701
+ // ────────────────────────────────────────────────────────────────
702
+ // Re-ranker
703
+ // ────────────────────────────────────────────────────────────────
704
+ /**
705
+ * Pick a re-ranker config for this search. When multiple scopes are in play, we
706
+ * use the first scope's config (matching task 1B.17: the re-rank stage is one
707
+ * call applied AFTER cross-scope fusion). A future enhancement could merge
708
+ * per-scope re-rankers, but the current plan keeps it simple.
709
+ */
710
+ pickReRankerConfig(resolvedScopes) {
711
+ for (const bundle of resolvedScopes) {
712
+ const scopeConfig = this.parseJson(bundle.Scope.ScopeConfig);
713
+ const rr = scopeConfig?.reRanker;
714
+ if (rr?.driverClass)
715
+ return rr;
716
+ }
717
+ return undefined;
718
+ }
719
+ /**
720
+ * Pick the first scope's `RerankerBudgetCents` value to apply to the reranker
721
+ * run. Mirrors `pickReRankerConfig` — the leading scope's policy wins. NULL
722
+ * (uncapped) is the default when no scope sets a budget.
723
+ */
724
+ pickRerankerBudgetCents(resolvedScopes) {
725
+ for (const bundle of resolvedScopes) {
726
+ const cents = bundle.Scope.RerankerBudgetCents;
727
+ if (cents != null)
728
+ return cents;
729
+ }
730
+ return null;
731
+ }
732
+ async runReRanker(query, candidates, cfg, contextUser, budgetGuard) {
733
+ if (!cfg.driverClass || candidates.length === 0)
734
+ return candidates;
735
+ try {
736
+ const reRanker = MJGlobal.Instance.ClassFactory.CreateInstance(BaseReRanker, cfg.driverClass) ?? new NoopReRanker();
737
+ const inputTopN = cfg.inputTopN ?? Math.min(100, candidates.length);
738
+ const outputTopN = cfg.outputTopN ?? Math.min(20, inputTopN);
739
+ const trimmed = candidates.slice(0, inputTopN);
740
+ // P2D.6 — pre-call budget short-circuit. When the projected cost would
741
+ // exceed the remaining budget, skip rerank entirely and return the
742
+ // unranked top-N. Reported via LogStatus so observability reflects the
743
+ // skip without surfacing as a failure.
744
+ if (budgetGuard) {
745
+ const estimate = reRanker.EstimateCostCents(trimmed.length);
746
+ if (!budgetGuard.CanSpend(estimate)) {
747
+ LogStatus(`SearchEngine: Re-ranker "${cfg.driverClass}" skipped — projected cost ${estimate.toFixed(4)}¢ exceeds remaining budget ${(budgetGuard.Remaining() ?? 0).toFixed(4)}¢ (Spent ${budgetGuard.Spent.toFixed(4)}¢ / Budget ${budgetGuard.Budget ?? 'uncapped'}¢).`);
748
+ return trimmed.slice(0, outputTopN);
749
+ }
750
+ // Wire post-call cost reporting through the guard so subsequent
751
+ // EstimateCostCents queries reflect accumulated spend.
752
+ reRanker.CostReporter = budgetGuard.AsCostReporter();
753
+ }
754
+ const ranked = await reRanker.ReRank(query, trimmed, outputTopN, contextUser, cfg.config);
755
+ LogStatus(`SearchEngine: Re-ranker "${cfg.driverClass}" returned ${ranked.length} result(s) (input=${trimmed.length}, outputTopN=${outputTopN}${budgetGuard ? `, spent=${budgetGuard.Spent.toFixed(4)}¢` : ''})`);
756
+ return ranked;
757
+ }
758
+ catch (error) {
759
+ const msg = error instanceof Error ? error.message : String(error);
760
+ LogError(`SearchEngine: Re-ranker "${cfg.driverClass}" failed, falling back to unranked: ${msg}`);
761
+ return candidates;
762
+ }
763
+ }
764
+ // ────────────────────────────────────────────────────────────────
182
765
  // Provider loading and initialization
183
766
  // ────────────────────────────────────────────────────────────────
184
767
  /**
@@ -232,6 +815,7 @@ export class SearchEngine extends BaseSingleton {
232
815
  Priority: record.Priority,
233
816
  SupportsPreview: record.SupportsPreview,
234
817
  MaxResultsOverride: record.MaxResultsOverride ?? null,
818
+ Record: record,
235
819
  });
236
820
  LogStatus(`SearchEngine: Provider "${record.Name}" (${driverClass}) enabled`);
237
821
  }
@@ -245,13 +829,13 @@ export class SearchEngine extends BaseSingleton {
245
829
  }
246
830
  }
247
831
  // ────────────────────────────────────────────────────────────────
248
- // Search execution helpers
832
+ // Search execution helpers (unscoped path)
249
833
  // ────────────────────────────────────────────────────────────────
250
834
  /**
251
835
  * Run all available providers in parallel and return labeled result lists.
252
836
  * When isPreview is true, only providers with SupportsPreview=true are included.
253
837
  */
254
- async executeProviders(query, topK, filters, contextUser, isPreview) {
838
+ async executeProviders(query, topK, filters, contextUser, isPreview, scopeConstraints, onProviderResolved) {
255
839
  const entries = this._providerEntries.filter(e => {
256
840
  if (!e.Provider.IsAvailable())
257
841
  return false;
@@ -264,20 +848,45 @@ export class SearchEngine extends BaseSingleton {
264
848
  return [];
265
849
  }
266
850
  const promises = entries.map(async (entry) => {
851
+ const providerStart = Date.now();
267
852
  try {
268
853
  const providerTopK = entry.MaxResultsOverride ?? topK;
269
- const results = await entry.Provider.Search(query, providerTopK, filters, contextUser);
854
+ const results = await entry.Provider.Search(query, providerTopK, filters, contextUser, scopeConstraints);
270
855
  // Stamp provider metadata onto each result
271
856
  for (const r of results) {
272
857
  r.ProviderId = entry.ID;
273
858
  r.ProviderLabel = entry.DisplayName;
274
859
  r.ProviderIcon = entry.Icon;
275
860
  }
861
+ if (onProviderResolved) {
862
+ try {
863
+ onProviderResolved({
864
+ sourceType: entry.Provider.SourceType,
865
+ results,
866
+ durationMs: Date.now() - providerStart,
867
+ });
868
+ }
869
+ catch (cbErr) {
870
+ // Streaming callback throwing must NOT corrupt the result; just log.
871
+ const cbMsg = cbErr instanceof Error ? cbErr.message : String(cbErr);
872
+ LogError(`SearchEngine: onProviderResolved callback threw for "${entry.Provider.SourceType}": ${cbMsg}`);
873
+ }
874
+ }
276
875
  return { Source: entry.Provider.SourceType, Results: results };
277
876
  }
278
877
  catch (error) {
279
878
  const msg = error instanceof Error ? error.message : String(error);
280
879
  LogError(`SearchEngine: Provider "${entry.Provider.SourceType}" failed: ${msg}`);
880
+ if (onProviderResolved) {
881
+ try {
882
+ onProviderResolved({
883
+ sourceType: entry.Provider.SourceType,
884
+ results: [],
885
+ durationMs: Date.now() - providerStart,
886
+ });
887
+ }
888
+ catch { /* swallow */ }
889
+ }
281
890
  return { Source: entry.Provider.SourceType, Results: [] };
282
891
  }
283
892
  });
@@ -291,27 +900,32 @@ export class SearchEngine extends BaseSingleton {
291
900
  for (const list of lists) {
292
901
  switch (list.Source) {
293
902
  case 'vector':
294
- counts.Vector = list.Results.length;
903
+ counts.Vector += list.Results.length;
295
904
  break;
296
905
  case 'fulltext':
297
- counts.FullText = list.Results.length;
906
+ counts.FullText += list.Results.length;
298
907
  break;
299
908
  case 'entity':
300
- counts.Entity = list.Results.length;
909
+ counts.Entity += list.Results.length;
301
910
  break;
302
911
  case 'storage':
303
- counts.Storage = list.Results.length;
912
+ counts.Storage += list.Results.length;
304
913
  break;
305
914
  }
306
915
  }
307
916
  return counts;
308
917
  }
309
918
  // ────────────────────────────────────────────────────────────────
310
- // Permission filtering
919
+ // Permission filtering (residual late safety net)
311
920
  // ────────────────────────────────────────────────────────────────
312
921
  /**
313
922
  * Filter search results by entity-level and row-level security permissions.
314
923
  *
924
+ * **This is a safety net.** Providers are expected to do per-provider permission
925
+ * push-down (Section 3.6 of plans/search-scopes-rag-plus.md). If this filter is
926
+ * removing more than a handful of results in practice, the responsible provider's
927
+ * push-down is incomplete and should be fixed.
928
+ *
315
929
  * Groups results by entity for efficient permission checking:
316
930
  * 1. Unknown entities are excluded (fail closed).
317
931
  * 2. If the user lacks entity-level CanRead, all results for that entity are dropped.
@@ -332,6 +946,12 @@ export class SearchEngine extends BaseSingleton {
332
946
  promises.push(this.filterEntityResults(entityName, groupResults, contextUser, permitted));
333
947
  }
334
948
  await Promise.all(promises);
949
+ // Preserve the input order (which is the RRF/re-rank order). groupResultsByEntity
950
+ // scrambles by entity; re-sort by original position so consumers still see the
951
+ // best-ranked result first.
952
+ const inputIndex = new Map();
953
+ results.forEach((r, i) => inputIndex.set(r, i));
954
+ permitted.sort((a, b) => (inputIndex.get(a) ?? 0) - (inputIndex.get(b) ?? 0));
335
955
  return permitted;
336
956
  }
337
957
  /**
@@ -428,6 +1048,74 @@ export class SearchEngine extends BaseSingleton {
428
1048
  }
429
1049
  }
430
1050
  /** Build an error SearchResult */
1051
+ /**
1052
+ * Public hook for callers (e.g. the GraphQL resolver) to emit a
1053
+ * Status='Forbidden' SearchExecutionLog row when they reject a request
1054
+ * before delegating to {@link Search}. Without this, forbidden invocations
1055
+ * never reach the analytics dashboard — exactly the signal admins need
1056
+ * to spot users / agents trying to access scopes they shouldn't.
1057
+ */
1058
+ async LogForbiddenSearch(input) {
1059
+ await this.logSearchExecution({
1060
+ Status: 'Forbidden',
1061
+ FailureReason: input.FailureReason,
1062
+ Query: input.Query,
1063
+ ScopeIDs: input.ScopeIDs,
1064
+ StartTime: input.StartTime,
1065
+ ResultCount: 0,
1066
+ RerankerName: null,
1067
+ RerankerCostCents: null,
1068
+ SourceCounts: undefined,
1069
+ ContextUser: input.ContextUser,
1070
+ AIAgentID: input.AIAgentID ?? null,
1071
+ });
1072
+ }
1073
+ /**
1074
+ * Best-effort hook (P3.2) that writes one MJSearchExecutionLog row per
1075
+ * SearchEngine.Search call. Captures query, timing, scope, result count,
1076
+ * reranker info, status, and a per-source-count breakdown for the analytics
1077
+ * dashboard (P3.3) and tuning CSV export (P3.4).
1078
+ *
1079
+ * Errors during the write are swallowed and logged — observability is the
1080
+ * point of this hook, not a load-bearing dependency. A logger that brings
1081
+ * down search would be the worst possible outcome.
1082
+ */
1083
+ async logSearchExecution(input) {
1084
+ try {
1085
+ const log = await this.ProviderToUse.GetEntityObject('MJ: Search Execution Logs', input.ContextUser);
1086
+ log.SearchScopeID = input.ScopeIDs && input.ScopeIDs.length > 0 ? input.ScopeIDs[0] : null;
1087
+ log.UserID = input.ContextUser.ID ?? null;
1088
+ log.AIAgentID = input.AIAgentID ?? null;
1089
+ log.Query = input.Query;
1090
+ log.TotalDurationMs = Date.now() - input.StartTime;
1091
+ log.ResultCount = input.ResultCount;
1092
+ log.RerankerName = input.RerankerName;
1093
+ log.RerankerCostCents = input.RerankerCostCents;
1094
+ log.Status = input.Status;
1095
+ log.FailureReason = input.FailureReason;
1096
+ // ProvidersJSON: per-source breakdown. Per-provider per-call timings
1097
+ // require deeper plumbing through the provider-run loop — deferred to a
1098
+ // later Phase 3 pass. For now, capture the source counts which the
1099
+ // dashboard's hit-rate / volume charts already need.
1100
+ log.ProvidersJSON = input.SourceCounts
1101
+ ? JSON.stringify({
1102
+ Vector: { ResultCount: input.SourceCounts.Vector },
1103
+ FullText: { ResultCount: input.SourceCounts.FullText },
1104
+ Entity: { ResultCount: input.SourceCounts.Entity },
1105
+ Storage: { ResultCount: input.SourceCounts.Storage },
1106
+ })
1107
+ : null;
1108
+ const saved = await log.Save();
1109
+ if (!saved) {
1110
+ LogError(`SearchEngine: SearchExecutionLog write returned false: ${log.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1111
+ }
1112
+ }
1113
+ catch (err) {
1114
+ // Swallow — this is best-effort observability and must never affect search latency / availability.
1115
+ const msg = err instanceof Error ? err.message : String(err);
1116
+ LogError(`SearchEngine: SearchExecutionLog write threw: ${msg}`);
1117
+ }
1118
+ }
431
1119
  buildErrorResult(message, startTime) {
432
1120
  return {
433
1121
  Success: false,
@@ -450,5 +1138,19 @@ export class SearchEngine extends BaseSingleton {
450
1138
  Priority: e.Priority,
451
1139
  }));
452
1140
  }
1141
+ /** Defensive JSON parse that never throws. Returns `null` on any failure. */
1142
+ parseJson(value) {
1143
+ if (!value)
1144
+ return null;
1145
+ try {
1146
+ const parsed = JSON.parse(value);
1147
+ if (parsed && typeof parsed === 'object')
1148
+ return parsed;
1149
+ return null;
1150
+ }
1151
+ catch {
1152
+ return null;
1153
+ }
1154
+ }
453
1155
  }
454
1156
  //# sourceMappingURL=SearchEngine.js.map