@memberjunction/search-engine 5.33.0 → 5.34.1

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 +36 -4
  6. package/dist/generic/EntitySearchProvider.d.ts.map +1 -1
  7. package/dist/generic/EntitySearchProvider.js +95 -19
  8. package/dist/generic/EntitySearchProvider.js.map +1 -1
  9. package/dist/generic/FullTextSearchProvider.d.ts +2 -2
  10. package/dist/generic/FullTextSearchProvider.d.ts.map +1 -1
  11. package/dist/generic/FullTextSearchProvider.js +14 -3
  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 +154 -12
  26. package/dist/generic/SearchEngine.d.ts.map +1 -1
  27. package/dist/generic/SearchEngine.js +662 -39
  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,6 +69,7 @@ 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;
51
73
  this._cache = new Map();
52
74
  }
53
75
  /** Static accessor for the global singleton instance */
@@ -92,8 +114,9 @@ export class SearchEngine extends BaseSingleton {
92
114
  if (this._configured && !forceRefresh)
93
115
  return;
94
116
  this._defaultMaxResults = config.DefaultMaxResults ?? 20;
117
+ this._defaultOverfetchFactor = Math.max(1, config.DefaultPermissionOverfetchFactor ?? 2);
95
118
  this._providerEntries = [];
96
- // Ensure SearchEngineBase has loaded provider metadata
119
+ // Ensure SearchEngineBase has loaded provider + scope metadata
97
120
  await this.Base.Config(forceRefresh, contextUser);
98
121
  const providerRecords = this.Base.ActiveProviders;
99
122
  if (providerRecords.length === 0) {
@@ -111,30 +134,54 @@ export class SearchEngine extends BaseSingleton {
111
134
  this._providerEntries.sort((a, b) => a.Priority - b.Priority);
112
135
  this._configured = true;
113
136
  const names = this._providerEntries.map(e => e.Provider.SourceType);
114
- 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)`);
115
138
  }
116
139
  /**
117
140
  * Execute a multi-source search with RRF fusion and optional enrichment.
118
141
  *
119
- * Steps:
120
- * 1. Run all available providers in parallel
121
- * 2. Fuse results with RRF
122
- * 3. Deduplicate by EntityName+RecordID
123
- * 4. Exclude redundant entity-sourced Content Items
124
- * 5. Apply minimum score threshold
125
- * 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.
126
144
  *
127
145
  * @param params - Search parameters
128
146
  * @param contextUser - The user performing the search
129
147
  * @returns Aggregated search result
130
148
  */
131
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) {
132
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;
133
163
  try {
134
- const trimmed = (params.Query ?? '').trim();
135
- if (!trimmed) {
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
+ });
136
182
  return this.buildErrorResult('Query cannot be empty', startTime);
137
183
  }
184
+ const trimmed = params.Query.trim();
138
185
  if (trimmed.length < SearchEngine.MIN_TERM_LENGTH) {
139
186
  // Short queries hit unindexed table-scans fanned out across every
140
187
  // searchable entity with negligible relevance. Return an empty
@@ -155,10 +202,19 @@ export class SearchEngine extends BaseSingleton {
155
202
  const topK = params.MaxResults ?? this._defaultMaxResults;
156
203
  const mode = params.Mode ?? 'full';
157
204
  const isPreview = mode === 'preview';
158
- // Cache lookup. Skip preview searches — they're already cheap and
159
- // caching would mask config changes during dev. Cache key includes
160
- // the user's ID so two users with different RLS scopes never share
161
- // an entry.
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
+ // ──────────────────────────────────────────────────────────
162
218
  const cacheKey = isPreview ? null : this.buildCacheKey(trimmed, params, contextUser);
163
219
  if (cacheKey) {
164
220
  const hit = this._cache.get(cacheKey);
@@ -171,27 +227,88 @@ export class SearchEngine extends BaseSingleton {
171
227
  if (hit)
172
228
  this._cache.delete(cacheKey); // expired
173
229
  }
174
- // Step 1: Run all providers in parallel (respecting preview flag)
175
- const labeledLists = await this.executeProviders(trimmed, topK, params.Filters, contextUser, isPreview);
176
- const sourceCounts = this.countSources(labeledLists);
177
- // Step 2: Fuse with RRF
178
- let results = this._fusion.Fuse(labeledLists, topK);
179
- // Step 3: Deduplicate
180
- results = this._fusion.Deduplicate(results);
181
- // Step 4: Exclude redundant Content Items
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);
182
279
  results = await this._enricher.ExcludeEntitySourcedContentItems(results, contextUser);
183
- // Step 4.5: Filter by entity-level and row-level permissions
280
+ const beforePermCount = results.length;
184
281
  results = await this.filterByPermissions(results, contextUser);
185
- // 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
+ }
186
288
  const scoreThreshold = params.MinScore ?? 0;
187
289
  if (scoreThreshold > 0) {
188
290
  results = results.filter(r => r.Score >= scoreThreshold);
189
291
  }
190
- // 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);
191
295
  if (!isPreview) {
192
296
  await this._enricher.Enrich(results, contextUser);
193
297
  }
194
- LogStatus(`SearchEngine: Search complete in ${Date.now() - startTime}ms - ${results.length} results`);
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
+ });
195
312
  const finalResult = {
196
313
  Success: true,
197
314
  Results: results,
@@ -208,9 +325,163 @@ export class SearchEngine extends BaseSingleton {
208
325
  catch (error) {
209
326
  const msg = error instanceof Error ? error.message : String(error);
210
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
+ });
211
341
  return this.buildErrorResult(msg, startTime);
212
342
  }
213
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
+ }
214
485
  /**
215
486
  * Build a stable cache key for a search. Includes the user identity so RLS
216
487
  * scopes never bleed across users, plus the trimmed query, MaxResults,
@@ -258,6 +529,239 @@ export class SearchEngine extends BaseSingleton {
258
529
  }, contextUser);
259
530
  }
260
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
+ // ────────────────────────────────────────────────────────────────
261
765
  // Provider loading and initialization
262
766
  // ────────────────────────────────────────────────────────────────
263
767
  /**
@@ -311,6 +815,7 @@ export class SearchEngine extends BaseSingleton {
311
815
  Priority: record.Priority,
312
816
  SupportsPreview: record.SupportsPreview,
313
817
  MaxResultsOverride: record.MaxResultsOverride ?? null,
818
+ Record: record,
314
819
  });
315
820
  LogStatus(`SearchEngine: Provider "${record.Name}" (${driverClass}) enabled`);
316
821
  }
@@ -324,13 +829,13 @@ export class SearchEngine extends BaseSingleton {
324
829
  }
325
830
  }
326
831
  // ────────────────────────────────────────────────────────────────
327
- // Search execution helpers
832
+ // Search execution helpers (unscoped path)
328
833
  // ────────────────────────────────────────────────────────────────
329
834
  /**
330
835
  * Run all available providers in parallel and return labeled result lists.
331
836
  * When isPreview is true, only providers with SupportsPreview=true are included.
332
837
  */
333
- async executeProviders(query, topK, filters, contextUser, isPreview) {
838
+ async executeProviders(query, topK, filters, contextUser, isPreview, scopeConstraints, onProviderResolved) {
334
839
  const entries = this._providerEntries.filter(e => {
335
840
  if (!e.Provider.IsAvailable())
336
841
  return false;
@@ -343,20 +848,45 @@ export class SearchEngine extends BaseSingleton {
343
848
  return [];
344
849
  }
345
850
  const promises = entries.map(async (entry) => {
851
+ const providerStart = Date.now();
346
852
  try {
347
853
  const providerTopK = entry.MaxResultsOverride ?? topK;
348
- const results = await entry.Provider.Search(query, providerTopK, filters, contextUser);
854
+ const results = await entry.Provider.Search(query, providerTopK, filters, contextUser, scopeConstraints);
349
855
  // Stamp provider metadata onto each result
350
856
  for (const r of results) {
351
857
  r.ProviderId = entry.ID;
352
858
  r.ProviderLabel = entry.DisplayName;
353
859
  r.ProviderIcon = entry.Icon;
354
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
+ }
355
875
  return { Source: entry.Provider.SourceType, Results: results };
356
876
  }
357
877
  catch (error) {
358
878
  const msg = error instanceof Error ? error.message : String(error);
359
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
+ }
360
890
  return { Source: entry.Provider.SourceType, Results: [] };
361
891
  }
362
892
  });
@@ -370,27 +900,32 @@ export class SearchEngine extends BaseSingleton {
370
900
  for (const list of lists) {
371
901
  switch (list.Source) {
372
902
  case 'vector':
373
- counts.Vector = list.Results.length;
903
+ counts.Vector += list.Results.length;
374
904
  break;
375
905
  case 'fulltext':
376
- counts.FullText = list.Results.length;
906
+ counts.FullText += list.Results.length;
377
907
  break;
378
908
  case 'entity':
379
- counts.Entity = list.Results.length;
909
+ counts.Entity += list.Results.length;
380
910
  break;
381
911
  case 'storage':
382
- counts.Storage = list.Results.length;
912
+ counts.Storage += list.Results.length;
383
913
  break;
384
914
  }
385
915
  }
386
916
  return counts;
387
917
  }
388
918
  // ────────────────────────────────────────────────────────────────
389
- // Permission filtering
919
+ // Permission filtering (residual late safety net)
390
920
  // ────────────────────────────────────────────────────────────────
391
921
  /**
392
922
  * Filter search results by entity-level and row-level security permissions.
393
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
+ *
394
929
  * Groups results by entity for efficient permission checking:
395
930
  * 1. Unknown entities are excluded (fail closed).
396
931
  * 2. If the user lacks entity-level CanRead, all results for that entity are dropped.
@@ -411,6 +946,12 @@ export class SearchEngine extends BaseSingleton {
411
946
  promises.push(this.filterEntityResults(entityName, groupResults, contextUser, permitted));
412
947
  }
413
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));
414
955
  return permitted;
415
956
  }
416
957
  /**
@@ -507,6 +1048,74 @@ export class SearchEngine extends BaseSingleton {
507
1048
  }
508
1049
  }
509
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
+ }
510
1119
  buildErrorResult(message, startTime) {
511
1120
  return {
512
1121
  Success: false,
@@ -529,5 +1138,19 @@ export class SearchEngine extends BaseSingleton {
529
1138
  Priority: e.Priority,
530
1139
  }));
531
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
+ }
532
1155
  }
533
1156
  //# sourceMappingURL=SearchEngine.js.map