@memberjunction/search-engine 5.33.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.
- package/dist/generic/BaseReRanker.d.ts +164 -0
- package/dist/generic/BaseReRanker.d.ts.map +1 -0
- package/dist/generic/BaseReRanker.js +209 -0
- package/dist/generic/BaseReRanker.js.map +1 -0
- package/dist/generic/EntitySearchProvider.d.ts +36 -4
- package/dist/generic/EntitySearchProvider.d.ts.map +1 -1
- package/dist/generic/EntitySearchProvider.js +95 -19
- package/dist/generic/EntitySearchProvider.js.map +1 -1
- package/dist/generic/FullTextSearchProvider.d.ts +2 -2
- package/dist/generic/FullTextSearchProvider.d.ts.map +1 -1
- package/dist/generic/FullTextSearchProvider.js +14 -3
- package/dist/generic/FullTextSearchProvider.js.map +1 -1
- package/dist/generic/ISearchProvider.d.ts +44 -2
- package/dist/generic/ISearchProvider.d.ts.map +1 -1
- package/dist/generic/ISearchProvider.js +35 -1
- package/dist/generic/ISearchProvider.js.map +1 -1
- package/dist/generic/NoopReRanker.d.ts +28 -0
- package/dist/generic/NoopReRanker.d.ts.map +1 -0
- package/dist/generic/NoopReRanker.js +49 -0
- package/dist/generic/NoopReRanker.js.map +1 -0
- package/dist/generic/ScopeTemplateRenderer.d.ts +36 -0
- package/dist/generic/ScopeTemplateRenderer.d.ts.map +1 -0
- package/dist/generic/ScopeTemplateRenderer.js +110 -0
- package/dist/generic/ScopeTemplateRenderer.js.map +1 -0
- package/dist/generic/SearchEngine.d.ts +154 -12
- package/dist/generic/SearchEngine.d.ts.map +1 -1
- package/dist/generic/SearchEngine.js +662 -39
- package/dist/generic/SearchEngine.js.map +1 -1
- package/dist/generic/SearchFusion.d.ts +40 -6
- package/dist/generic/SearchFusion.d.ts.map +1 -1
- package/dist/generic/SearchFusion.js +139 -18
- package/dist/generic/SearchFusion.js.map +1 -1
- package/dist/generic/StorageSearchProvider.d.ts +9 -2
- package/dist/generic/StorageSearchProvider.d.ts.map +1 -1
- package/dist/generic/StorageSearchProvider.js +44 -12
- package/dist/generic/StorageSearchProvider.js.map +1 -1
- package/dist/generic/VectorSearchProvider.d.ts +9 -2
- package/dist/generic/VectorSearchProvider.d.ts.map +1 -1
- package/dist/generic/VectorSearchProvider.js +83 -13
- package/dist/generic/VectorSearchProvider.js.map +1 -1
- package/dist/generic/search.types.d.ts +206 -0
- package/dist/generic/search.types.d.ts.map +1 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -1
- package/dist/permissions/SearchScopePermissionResolver.d.ts +109 -0
- package/dist/permissions/SearchScopePermissionResolver.d.ts.map +1 -0
- package/dist/permissions/SearchScopePermissionResolver.js +159 -0
- package/dist/permissions/SearchScopePermissionResolver.js.map +1 -0
- package/dist/providers/AzureAISearchProvider.d.ts +37 -0
- package/dist/providers/AzureAISearchProvider.d.ts.map +1 -0
- package/dist/providers/AzureAISearchProvider.js +180 -0
- package/dist/providers/AzureAISearchProvider.js.map +1 -0
- package/dist/providers/ElasticsearchSearchProvider.d.ts +43 -0
- package/dist/providers/ElasticsearchSearchProvider.d.ts.map +1 -0
- package/dist/providers/ElasticsearchSearchProvider.js +200 -0
- package/dist/providers/ElasticsearchSearchProvider.js.map +1 -0
- package/dist/providers/OpenSearchSearchProvider.d.ts +36 -0
- package/dist/providers/OpenSearchSearchProvider.d.ts.map +1 -0
- package/dist/providers/OpenSearchSearchProvider.js +167 -0
- package/dist/providers/OpenSearchSearchProvider.js.map +1 -0
- package/dist/providers/TypesenseSearchProvider.d.ts +36 -0
- package/dist/providers/TypesenseSearchProvider.d.ts.map +1 -0
- package/dist/providers/TypesenseSearchProvider.js +161 -0
- package/dist/providers/TypesenseSearchProvider.js.map +1 -0
- package/dist/rerankers/BGEReRanker.d.ts +57 -0
- package/dist/rerankers/BGEReRanker.d.ts.map +1 -0
- package/dist/rerankers/BGEReRanker.js +193 -0
- package/dist/rerankers/BGEReRanker.js.map +1 -0
- package/dist/rerankers/CohereReRanker.d.ts +65 -0
- package/dist/rerankers/CohereReRanker.d.ts.map +1 -0
- package/dist/rerankers/CohereReRanker.js +155 -0
- package/dist/rerankers/CohereReRanker.js.map +1 -0
- package/dist/rerankers/OpenAIReRanker.d.ts +62 -0
- package/dist/rerankers/OpenAIReRanker.d.ts.map +1 -0
- package/dist/rerankers/OpenAIReRanker.js +197 -0
- package/dist/rerankers/OpenAIReRanker.js.map +1 -0
- package/dist/rerankers/RerankerBudgetGuard.d.ts +54 -0
- package/dist/rerankers/RerankerBudgetGuard.d.ts.map +1 -0
- package/dist/rerankers/RerankerBudgetGuard.js +67 -0
- package/dist/rerankers/RerankerBudgetGuard.js.map +1 -0
- package/dist/rerankers/VoyageReRanker.d.ts +59 -0
- package/dist/rerankers/VoyageReRanker.d.ts.map +1 -0
- package/dist/rerankers/VoyageReRanker.js +184 -0
- package/dist/rerankers/VoyageReRanker.js.map +1 -0
- 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
|
-
*
|
|
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
|
-
*
|
|
120
|
-
*
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
//
|
|
161
|
-
//
|
|
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
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
let
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
280
|
+
const beforePermCount = results.length;
|
|
184
281
|
results = await this.filterByPermissions(results, contextUser);
|
|
185
|
-
|
|
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
|
-
//
|
|
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}
|
|
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
|
|
903
|
+
counts.Vector += list.Results.length;
|
|
374
904
|
break;
|
|
375
905
|
case 'fulltext':
|
|
376
|
-
counts.FullText
|
|
906
|
+
counts.FullText += list.Results.length;
|
|
377
907
|
break;
|
|
378
908
|
case 'entity':
|
|
379
|
-
counts.Entity
|
|
909
|
+
counts.Entity += list.Results.length;
|
|
380
910
|
break;
|
|
381
911
|
case 'storage':
|
|
382
|
-
counts.Storage
|
|
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
|