@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.
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
@@ -0,0 +1,164 @@
1
+ /**
2
+ * @fileoverview Optional re-ranker primitive for scope search.
3
+ *
4
+ * A re-ranker runs AFTER per-scope RRF and cross-scope RRF and BEFORE deduplication.
5
+ * It takes a fused candidate list plus the original query and produces a more accurate
6
+ * final ordering — typically via a cross-encoder LLM call (Cohere Rerank, BGE Reranker,
7
+ * Voyage Rerank). Implementations are registered via `@RegisterClass(BaseReRanker, 'DriverClassName')`
8
+ * and selected per-scope via `SearchScope.ScopeConfig.reRanker.driverClass`.
9
+ *
10
+ * This class is a thin adapter over `@memberjunction/ai`'s `BaseReranker`. Subclasses
11
+ * that wrap a real provider (Cohere, BGE, Voyage) return an AI-layer reranker from
12
+ * `getAIReranker()` and inherit the AI layer's validation, timing, and error shape.
13
+ * The default `ReRank()` handles the conversion between `SearchResultItem[]` and the
14
+ * AI layer's `RerankDocument[]` / `RerankResult[]` so subclasses don't have to.
15
+ *
16
+ * Subclasses that don't need the AI layer (e.g. `NoopReRanker`) can override `ReRank()`
17
+ * directly.
18
+ *
19
+ * See Section 8.3 of plans/search-scopes-rag-plus.md.
20
+ *
21
+ * @module @memberjunction/search-engine
22
+ */
23
+ import { UserInfo } from '@memberjunction/core';
24
+ import { BaseReranker as AIBaseReranker } from '@memberjunction/ai';
25
+ import { SearchResultItem } from './search.types.js';
26
+ /**
27
+ * Lightweight catalog entry for a registered reranker, returned by
28
+ * `BaseReRanker.GetAvailableRerankers()`. Designed for UI dropdown population on
29
+ * the SearchScope form (P2D.7) — a single call gives the form everything it
30
+ * needs to render selectable options without separately instantiating each
31
+ * registered class.
32
+ */
33
+ export interface RegisteredReRankerInfo {
34
+ /** ClassFactory registration key — what goes into ScopeConfig.reRanker.driverClass */
35
+ DriverClass: string;
36
+ /** Human-friendly label for UI display (BaseReRanker.Name) */
37
+ Name: string;
38
+ /** Reranker version (BaseReRanker.Version) */
39
+ Version: string;
40
+ /** Whether this reranker incurs API cost (true) or runs free locally (false) */
41
+ HasCost: boolean;
42
+ }
43
+ /**
44
+ * Abstract primitive for a search re-ranker. Provides a default implementation of
45
+ * `ReRank()` that delegates to an AI-layer `BaseReranker` returned by
46
+ * `getAIReranker()`. When `getAIReranker()` returns null (the base default), the
47
+ * candidates are sliced to `topN` and returned unchanged.
48
+ *
49
+ * The `NoopReRanker` default implementation (in `NoopReRanker.ts`) returns candidates
50
+ * unchanged and serves as the wiring verification point — the SearchEngine's re-rank
51
+ * stage always resolves a concrete class, even when no real re-ranker is configured.
52
+ */
53
+ /**
54
+ * Optional per-call cost-reporting callback. Real-provider rerankers (Cohere, Voyage,
55
+ * OpenAI) invoke this with the actual cents charged for a single Rerank call so the
56
+ * SearchEngine / scope-level budget guard can accumulate spend and short-circuit
57
+ * runaway agents. NoopReRanker and BGE (local) report 0.
58
+ */
59
+ export type ReRankerCostReporter = (cents: number) => void;
60
+ export declare abstract class BaseReRanker {
61
+ /**
62
+ * The `DriverClass` key used by `@RegisterClass(BaseReRanker, 'DriverClassName')` so
63
+ * the SearchEngine can resolve this subclass via `ClassFactory` from the scope's
64
+ * `ScopeConfig.reRanker.driverClass` value.
65
+ */
66
+ abstract get DriverClass(): string;
67
+ /**
68
+ * Human-friendly display name. Defaults to `DriverClass` so subclasses don't have to
69
+ * override unless they want a different label in UI dropdowns / cost reports.
70
+ */
71
+ get Name(): string;
72
+ /**
73
+ * Reranker semantic version (independent of the package version). Bump this on a
74
+ * subclass when the prompt, scoring formula, or upstream model identifier changes
75
+ * in a way that invalidates cached scores. Defaults to `'1'`.
76
+ */
77
+ get Version(): string;
78
+ /**
79
+ * Maximum number of candidates a single Rerank call can score. Real providers cap
80
+ * this (Cohere: 1000, Voyage: 1000, OpenAI: varies). The SearchEngine should respect
81
+ * this cap by chunking large candidate lists. Defaults to `Number.MAX_SAFE_INTEGER`
82
+ * (no cap) — subclasses should override.
83
+ */
84
+ GetMaxResultCount(): number;
85
+ /**
86
+ * Pre-call cost estimate in cents for reranking the given candidate count. Used by
87
+ * the budget guard to short-circuit BEFORE making the call when the projected cost
88
+ * exceeds `SearchScope.RerankerBudgetCents`.
89
+ *
90
+ * Default: 0 (free / local). Real-provider subclasses override based on their pricing.
91
+ *
92
+ * @param resultCount - The number of candidates to be reranked.
93
+ * @returns Estimated cost in cents (whole + fractional, e.g. `0.25` for ¼¢).
94
+ */
95
+ EstimateCostCents(resultCount: number): number;
96
+ /**
97
+ * Optional cost reporter. When set, the reranker invokes it after each successful
98
+ * Rerank call with the actual cents charged. The SearchEngine wires this from the
99
+ * `RerankerBudgetGuard` to the scope-level spend tracker — see Phase 2D.6.
100
+ */
101
+ CostReporter: ReRankerCostReporter | null;
102
+ /**
103
+ * Helper for subclasses: report a cost AND record it on the optional callback. Always
104
+ * safe to call even when no callback is set.
105
+ */
106
+ protected reportCost(cents: number): void;
107
+ /**
108
+ * Enumerate every reranker currently registered with ClassFactory under
109
+ * `BaseReRanker`. Designed for the SearchScope form's reranker dropdown (P2D.7) —
110
+ * the form populates the dropdown from this single call rather than hardcoding
111
+ * a list, so any ClassFactory-registered reranker (including third-party ones
112
+ * published as separate packages) shows up automatically.
113
+ *
114
+ * Each entry includes the driver-class registration key, the friendly Name,
115
+ * Version, and a `HasCost` flag (true when EstimateCostCents(1) > 0). Sorted
116
+ * by Name for stable UI ordering.
117
+ */
118
+ static GetAvailableRerankers(): RegisteredReRankerInfo[];
119
+ /**
120
+ * Score and re-order candidates against the query.
121
+ *
122
+ * Default implementation:
123
+ * 1. Returns empty when `topN <= 0` or `candidates` is empty.
124
+ * 2. Resolves an AI-layer reranker via `getAIReranker()`. If none, slices to `topN`.
125
+ * 3. Maps `SearchResultItem[]` to `RerankDocument[]` (preserving each original item
126
+ * in `metadata.__mjSearchItem`).
127
+ * 4. Calls the AI reranker's `Rerank()` — inherits validation, timing, error handling.
128
+ * 5. On success, maps the `RerankResult[]` back to `SearchResultItem[]`, replacing
129
+ * `Score` with `relevanceScore` and augmenting `ScoreBreakdown` with a `ReRank` key.
130
+ * 6. On failure, logs and falls back to the unchanged top-N slice.
131
+ *
132
+ * Subclasses that bypass the AI layer (e.g. `NoopReRanker`) may override this method.
133
+ *
134
+ * @param query - The original query text.
135
+ * @param candidates - The fused candidate list (post-RRF, pre-dedup).
136
+ * @param topN - Maximum number of candidates to return after re-ranking.
137
+ * @param contextUser - The calling user (for auth / tenant propagation).
138
+ * @param config - Optional provider-specific config from `ScopeConfig.reRanker.config`.
139
+ */
140
+ ReRank(query: string, candidates: SearchResultItem[], topN: number, contextUser: UserInfo, config?: Record<string, unknown>): Promise<SearchResultItem[]>;
141
+ /**
142
+ * Return the underlying `@memberjunction/ai` `BaseReranker` instance to use.
143
+ *
144
+ * Provider-specific subclasses (Cohere, BGE, Voyage) override this to return a
145
+ * configured AI reranker. Returning `null` (the default) tells `ReRank()` to
146
+ * skip the AI layer and fall back to a simple top-N slice — useful for Noop
147
+ * and test implementations.
148
+ *
149
+ * @param _config - Provider-specific config from `ScopeConfig.reRanker.config`.
150
+ * @param _contextUser - The calling user (for auth / tenant propagation).
151
+ */
152
+ protected getAIReranker(_config: Record<string, unknown> | undefined, _contextUser: UserInfo): AIBaseReranker | null;
153
+ /**
154
+ * Build the text representation of a `SearchResultItem` for the rerank model.
155
+ * Default concatenates `Title` and `Snippet` separated by newline. Override to
156
+ * customize (e.g. include tags or entity context).
157
+ */
158
+ protected buildRerankText(item: SearchResultItem): string;
159
+ /** Convert the search result candidates into the AI layer's document shape. */
160
+ private toRerankDocuments;
161
+ /** Recover the original `SearchResultItem` and attach the rerank score. */
162
+ private fromRerankResult;
163
+ }
164
+ //# sourceMappingURL=BaseReRanker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BaseReRanker.d.ts","sourceRoot":"","sources":["../../src/generic/BaseReRanker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAY,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAE1D,OAAO,EAAE,YAAY,IAAI,cAAc,EAAgC,MAAM,oBAAoB,CAAC;AAClG,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD;;;;;;GAMG;AACH,MAAM,WAAW,sBAAsB;IACnC,sFAAsF;IACtF,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,8CAA8C;IAC9C,OAAO,EAAE,MAAM,CAAC;IAChB,gFAAgF;IAChF,OAAO,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH;;;;;GAKG;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;AAE3D,8BAAsB,YAAY;IAC9B;;;;OAIG;IACH,aAAoB,WAAW,IAAI,MAAM,CAAC;IAE1C;;;OAGG;IACH,IAAW,IAAI,IAAI,MAAM,CAExB;IAED;;;;OAIG;IACH,IAAW,OAAO,IAAI,MAAM,CAE3B;IAED;;;;;OAKG;IACI,iBAAiB,IAAI,MAAM;IAIlC;;;;;;;;;OASG;IACI,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM;IAKrD;;;;OAIG;IACI,YAAY,EAAE,oBAAoB,GAAG,IAAI,CAAQ;IAExD;;;OAGG;IACH,SAAS,CAAC,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAMzC;;;;;;;;;;OAUG;WACW,qBAAqB,IAAI,sBAAsB,EAAE;IA6B/D;;;;;;;;;;;;;;;;;;;;OAoBG;IACU,MAAM,CACf,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,gBAAgB,EAAE,EAC9B,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,QAAQ,EACrB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACjC,OAAO,CAAC,gBAAgB,EAAE,CAAC;IA4B9B;;;;;;;;;;OAUG;IACH,SAAS,CAAC,aAAa,CACnB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC5C,YAAY,EAAE,QAAQ,GACvB,cAAc,GAAG,IAAI;IAIxB;;;;OAIG;IACH,SAAS,CAAC,eAAe,CAAC,IAAI,EAAE,gBAAgB,GAAG,MAAM;IAKzD,+EAA+E;IAC/E,OAAO,CAAC,iBAAiB;IASzB,2EAA2E;IAC3E,OAAO,CAAC,gBAAgB;CAc3B"}
@@ -0,0 +1,209 @@
1
+ /**
2
+ * @fileoverview Optional re-ranker primitive for scope search.
3
+ *
4
+ * A re-ranker runs AFTER per-scope RRF and cross-scope RRF and BEFORE deduplication.
5
+ * It takes a fused candidate list plus the original query and produces a more accurate
6
+ * final ordering — typically via a cross-encoder LLM call (Cohere Rerank, BGE Reranker,
7
+ * Voyage Rerank). Implementations are registered via `@RegisterClass(BaseReRanker, 'DriverClassName')`
8
+ * and selected per-scope via `SearchScope.ScopeConfig.reRanker.driverClass`.
9
+ *
10
+ * This class is a thin adapter over `@memberjunction/ai`'s `BaseReranker`. Subclasses
11
+ * that wrap a real provider (Cohere, BGE, Voyage) return an AI-layer reranker from
12
+ * `getAIReranker()` and inherit the AI layer's validation, timing, and error shape.
13
+ * The default `ReRank()` handles the conversion between `SearchResultItem[]` and the
14
+ * AI layer's `RerankDocument[]` / `RerankResult[]` so subclasses don't have to.
15
+ *
16
+ * Subclasses that don't need the AI layer (e.g. `NoopReRanker`) can override `ReRank()`
17
+ * directly.
18
+ *
19
+ * See Section 8.3 of plans/search-scopes-rag-plus.md.
20
+ *
21
+ * @module @memberjunction/search-engine
22
+ */
23
+ import { LogError } from '@memberjunction/core';
24
+ import { MJGlobal } from '@memberjunction/global';
25
+ export class BaseReRanker {
26
+ constructor() {
27
+ /**
28
+ * Optional cost reporter. When set, the reranker invokes it after each successful
29
+ * Rerank call with the actual cents charged. The SearchEngine wires this from the
30
+ * `RerankerBudgetGuard` to the scope-level spend tracker — see Phase 2D.6.
31
+ */
32
+ this.CostReporter = null;
33
+ }
34
+ /**
35
+ * Human-friendly display name. Defaults to `DriverClass` so subclasses don't have to
36
+ * override unless they want a different label in UI dropdowns / cost reports.
37
+ */
38
+ get Name() {
39
+ return this.DriverClass;
40
+ }
41
+ /**
42
+ * Reranker semantic version (independent of the package version). Bump this on a
43
+ * subclass when the prompt, scoring formula, or upstream model identifier changes
44
+ * in a way that invalidates cached scores. Defaults to `'1'`.
45
+ */
46
+ get Version() {
47
+ return '1';
48
+ }
49
+ /**
50
+ * Maximum number of candidates a single Rerank call can score. Real providers cap
51
+ * this (Cohere: 1000, Voyage: 1000, OpenAI: varies). The SearchEngine should respect
52
+ * this cap by chunking large candidate lists. Defaults to `Number.MAX_SAFE_INTEGER`
53
+ * (no cap) — subclasses should override.
54
+ */
55
+ GetMaxResultCount() {
56
+ return Number.MAX_SAFE_INTEGER;
57
+ }
58
+ /**
59
+ * Pre-call cost estimate in cents for reranking the given candidate count. Used by
60
+ * the budget guard to short-circuit BEFORE making the call when the projected cost
61
+ * exceeds `SearchScope.RerankerBudgetCents`.
62
+ *
63
+ * Default: 0 (free / local). Real-provider subclasses override based on their pricing.
64
+ *
65
+ * @param resultCount - The number of candidates to be reranked.
66
+ * @returns Estimated cost in cents (whole + fractional, e.g. `0.25` for ¼¢).
67
+ */
68
+ EstimateCostCents(resultCount) {
69
+ void resultCount;
70
+ return 0;
71
+ }
72
+ /**
73
+ * Helper for subclasses: report a cost AND record it on the optional callback. Always
74
+ * safe to call even when no callback is set.
75
+ */
76
+ reportCost(cents) {
77
+ if (cents > 0 && this.CostReporter) {
78
+ this.CostReporter(cents);
79
+ }
80
+ }
81
+ /**
82
+ * Enumerate every reranker currently registered with ClassFactory under
83
+ * `BaseReRanker`. Designed for the SearchScope form's reranker dropdown (P2D.7) —
84
+ * the form populates the dropdown from this single call rather than hardcoding
85
+ * a list, so any ClassFactory-registered reranker (including third-party ones
86
+ * published as separate packages) shows up automatically.
87
+ *
88
+ * Each entry includes the driver-class registration key, the friendly Name,
89
+ * Version, and a `HasCost` flag (true when EstimateCostCents(1) > 0). Sorted
90
+ * by Name for stable UI ordering.
91
+ */
92
+ static GetAvailableRerankers() {
93
+ const registrations = MJGlobal.Instance.ClassFactory.GetAllRegistrations(BaseReRanker);
94
+ const seen = new Set();
95
+ const out = [];
96
+ for (const reg of registrations) {
97
+ const key = reg.Key;
98
+ if (!key || seen.has(key))
99
+ continue;
100
+ seen.add(key);
101
+ try {
102
+ const Ctor = reg.SubClass;
103
+ const instance = new Ctor();
104
+ out.push({
105
+ DriverClass: key,
106
+ Name: instance.Name,
107
+ Version: instance.Version,
108
+ HasCost: instance.EstimateCostCents(1) > 0,
109
+ });
110
+ }
111
+ catch (err) {
112
+ // Some rerankers may require args we can't supply at enumeration time.
113
+ // Fall back to the registration key alone — the UI still gets an
114
+ // option, it just lacks the friendly label.
115
+ LogError(`BaseReRanker.GetAvailableRerankers: could not introspect "${key}" — ${err instanceof Error ? err.message : String(err)}`);
116
+ out.push({ DriverClass: key, Name: key, Version: '?', HasCost: false });
117
+ }
118
+ }
119
+ out.sort((a, b) => a.Name.localeCompare(b.Name));
120
+ return out;
121
+ }
122
+ /**
123
+ * Score and re-order candidates against the query.
124
+ *
125
+ * Default implementation:
126
+ * 1. Returns empty when `topN <= 0` or `candidates` is empty.
127
+ * 2. Resolves an AI-layer reranker via `getAIReranker()`. If none, slices to `topN`.
128
+ * 3. Maps `SearchResultItem[]` to `RerankDocument[]` (preserving each original item
129
+ * in `metadata.__mjSearchItem`).
130
+ * 4. Calls the AI reranker's `Rerank()` — inherits validation, timing, error handling.
131
+ * 5. On success, maps the `RerankResult[]` back to `SearchResultItem[]`, replacing
132
+ * `Score` with `relevanceScore` and augmenting `ScoreBreakdown` with a `ReRank` key.
133
+ * 6. On failure, logs and falls back to the unchanged top-N slice.
134
+ *
135
+ * Subclasses that bypass the AI layer (e.g. `NoopReRanker`) may override this method.
136
+ *
137
+ * @param query - The original query text.
138
+ * @param candidates - The fused candidate list (post-RRF, pre-dedup).
139
+ * @param topN - Maximum number of candidates to return after re-ranking.
140
+ * @param contextUser - The calling user (for auth / tenant propagation).
141
+ * @param config - Optional provider-specific config from `ScopeConfig.reRanker.config`.
142
+ */
143
+ async ReRank(query, candidates, topN, contextUser, config) {
144
+ if (topN <= 0 || candidates.length === 0)
145
+ return [];
146
+ const aiReranker = this.getAIReranker(config, contextUser);
147
+ if (!aiReranker) {
148
+ return candidates.slice(0, topN);
149
+ }
150
+ const documents = this.toRerankDocuments(candidates);
151
+ const response = await aiReranker.Rerank({
152
+ query,
153
+ documents,
154
+ topK: topN,
155
+ options: config,
156
+ });
157
+ if (!response.success) {
158
+ LogError(`SearchEngine: AI reranker "${this.DriverClass}" failed (${response.durationMs}ms): ${response.errorMessage ?? 'unknown error'}`);
159
+ return candidates.slice(0, topN);
160
+ }
161
+ return response.results.map(r => this.fromRerankResult(r));
162
+ }
163
+ /**
164
+ * Return the underlying `@memberjunction/ai` `BaseReranker` instance to use.
165
+ *
166
+ * Provider-specific subclasses (Cohere, BGE, Voyage) override this to return a
167
+ * configured AI reranker. Returning `null` (the default) tells `ReRank()` to
168
+ * skip the AI layer and fall back to a simple top-N slice — useful for Noop
169
+ * and test implementations.
170
+ *
171
+ * @param _config - Provider-specific config from `ScopeConfig.reRanker.config`.
172
+ * @param _contextUser - The calling user (for auth / tenant propagation).
173
+ */
174
+ getAIReranker(_config, _contextUser) {
175
+ return null;
176
+ }
177
+ /**
178
+ * Build the text representation of a `SearchResultItem` for the rerank model.
179
+ * Default concatenates `Title` and `Snippet` separated by newline. Override to
180
+ * customize (e.g. include tags or entity context).
181
+ */
182
+ buildRerankText(item) {
183
+ const parts = [item.Title, item.Snippet].filter(p => p != null && p.length > 0);
184
+ return parts.join('\n');
185
+ }
186
+ /** Convert the search result candidates into the AI layer's document shape. */
187
+ toRerankDocuments(candidates) {
188
+ return candidates.map(item => ({
189
+ id: item.ID,
190
+ text: this.buildRerankText(item),
191
+ metadata: { __mjSearchItem: item },
192
+ originalScore: item.Score,
193
+ }));
194
+ }
195
+ /** Recover the original `SearchResultItem` and attach the rerank score. */
196
+ fromRerankResult(result) {
197
+ const original = result.document.metadata?.__mjSearchItem;
198
+ if (!original) {
199
+ throw new Error('BaseReRanker: rerank result missing original SearchResultItem in metadata — ' +
200
+ 'did a subclass strip the metadata.__mjSearchItem key?');
201
+ }
202
+ return {
203
+ ...original,
204
+ Score: result.relevanceScore,
205
+ ScoreBreakdown: { ...original.ScoreBreakdown, ReRank: result.relevanceScore },
206
+ };
207
+ }
208
+ }
209
+ //# sourceMappingURL=BaseReRanker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BaseReRanker.js","sourceRoot":"","sources":["../../src/generic/BaseReRanker.ts"],"names":[],"mappings":"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"}
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { UserInfo } from '@memberjunction/core';
11
11
  import { BaseSearchProvider } from './ISearchProvider.js';
12
- import { SearchSource, SearchFilters, SearchResultItem } from './search.types.js';
12
+ import { SearchSource, SearchFilters, SearchResultItem, ScopeConstraints } from './search.types.js';
13
13
  /**
14
14
  * Provides entity-level LIKE-based search using RunView + UserSearchString.
15
15
  * Searches all entities where AllowUserSearchAPI=true, returning results
@@ -39,15 +39,29 @@ export declare class EntitySearchProvider extends BaseSearchProvider {
39
39
  * @param contextUser - The user performing the search
40
40
  * @returns Scored result items from entity search
41
41
  */
42
- Search(query: string, topK: number, filters: SearchFilters | undefined, contextUser: UserInfo): Promise<SearchResultItem[]>;
42
+ Search(query: string, topK: number, filters: SearchFilters | undefined, contextUser: UserInfo, scopeConstraints?: ScopeConstraints): Promise<SearchResultItem[]>;
43
+ /**
44
+ * Resolve the entity list to actually search.
45
+ *
46
+ * - If `scopeConstraints.Entities` is provided, use those directly (each carries its own
47
+ * rendered ExtraFilter + UserSearchString) — this is the "scoped" path.
48
+ * - Otherwise fall back to the legacy unscoped path (`AllowUserSearchAPI=true` with
49
+ * optional `filters.EntityNames` restriction) and wrap each in a trivial constraint.
50
+ */
51
+ private buildScopedEntityList;
43
52
  /**
44
53
  * Get the list of entities eligible for search, optionally filtered by name.
45
54
  */
46
55
  private getSearchableEntities;
47
56
  /**
48
57
  * Search a single entity using RunView with UserSearchString. Wraps the
49
- * underlying RunView in a hard timeout so a slow entity cannot hold up
50
- * the whole fan-out — partial results from the other entities still land.
58
+ * underlying RunView in a hard PER_ENTITY_TIMEOUT_MS timeout so a slow
59
+ * entity cannot hold up the whole fan-out — partial results from the
60
+ * other entities still land.
61
+ *
62
+ * Note: `contextUser` is passed to RunView so row-level security (RLS) is applied
63
+ * automatically — this is the Entity provider's permission push-down per Section 3.6
64
+ * of plans/search-scopes-rag-plus.md.
51
65
  */
52
66
  private searchOneEntity;
53
67
  private searchOneEntityRaw;
@@ -66,5 +80,23 @@ export declare class EntitySearchProvider extends BaseSearchProvider {
66
80
  * Extract a display snippet from record data using entity metadata.
67
81
  */
68
82
  private extractSnippet;
83
+ /**
84
+ * Remove SQL LIKE wildcard characters from a user-supplied search string.
85
+ *
86
+ * The downstream `GenericDatabaseProvider.createViewUserSearchSQL`
87
+ * interpolates user input directly into `LIKE '%${input}%'`, only
88
+ * escaping single quotes. Unstripped LIKE wildcards (`%`, `_`, `[`, `]`)
89
+ * would either match too much (e.g. `Query="%"` matches every row) or
90
+ * trigger LIKE character-class parsing (`Query="[abc]"`).
91
+ *
92
+ * Behavior intent: these characters are treated as not-meaningful for
93
+ * entity LIKE search. A query containing literal `%` (e.g. `100%`) will
94
+ * not find records that contain `100%` — the trade-off is documented
95
+ * to keep the behavior predictable and safe.
96
+ *
97
+ * Trailing/leading whitespace is collapsed; an all-wildcard query
98
+ * returns empty and the caller short-circuits to zero results.
99
+ */
100
+ private sanitizeUserSearchString;
69
101
  }
70
102
  //# sourceMappingURL=EntitySearchProvider.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"EntitySearchProvider.d.ts","sourceRoot":"","sources":["../../src/generic/EntitySearchProvider.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAA6D,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAE3G,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,gBAAgB,EAAoB,MAAM,gBAAgB,CAAC;AAEjG;;;;GAIG;AACH,qBACa,oBAAqB,SAAQ,kBAAkB;IACxD,SAAgB,UAAU,EAAE,YAAY,CAAY;IAEpD;;;;OAIG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAK;IAE5C;;;;;OAKG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,qBAAqB,CAAU;IAEvD;;;;;;;;OAQG;IACU,MAAM,CACf,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,aAAa,GAAG,SAAS,EAClC,WAAW,EAAE,QAAQ,GACtB,OAAO,CAAC,gBAAgB,EAAE,CAAC;IA2C9B;;OAEG;IACH,OAAO,CAAC,qBAAqB;IAc7B;;;;OAIG;YACW,eAAe;YAqBf,kBAAkB;IA4BhC;;;;OAIG;IACH,OAAO,CAAC,cAAc;IAwDtB;;;OAGG;IACH,OAAO,CAAC,YAAY;IAmCpB;;OAEG;IACH,OAAO,CAAC,cAAc;CAczB"}
1
+ {"version":3,"file":"EntitySearchProvider.d.ts","sourceRoot":"","sources":["../../src/generic/EntitySearchProvider.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAmD,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAEjG,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,gBAAgB,EAAoB,gBAAgB,EAAyB,MAAM,gBAAgB,CAAC;AAE1I;;;;GAIG;AACH,qBACa,oBAAqB,SAAQ,kBAAkB;IACxD,SAAgB,UAAU,EAAE,YAAY,CAAY;IAEpD;;;;OAIG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAK;IAE5C;;;;;OAKG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,qBAAqB,CAAU;IAEvD;;;;;;;;OAQG;IACU,MAAM,CACf,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,aAAa,GAAG,SAAS,EAClC,WAAW,EAAE,QAAQ,EACrB,gBAAgB,CAAC,EAAE,gBAAgB,GACpC,OAAO,CAAC,gBAAgB,EAAE,CAAC;IA8E9B;;;;;;;OAOG;IACH,OAAO,CAAC,qBAAqB;IAoB7B;;OAEG;IACH,OAAO,CAAC,qBAAqB;IAc7B;;;;;;;;;OASG;YACW,eAAe;YAsBf,kBAAkB;IA8BhC;;;;OAIG;IACH,OAAO,CAAC,cAAc;IAwDtB;;;OAGG;IACH,OAAO,CAAC,YAAY;IAmCpB;;OAEG;IACH,OAAO,CAAC,cAAc;IAetB;;;;;;;;;;;;;;;;OAgBG;IACH,OAAO,CAAC,wBAAwB;CAGnC"}
@@ -50,31 +50,59 @@ let EntitySearchProvider = class EntitySearchProvider extends BaseSearchProvider
50
50
  * @param contextUser - The user performing the search
51
51
  * @returns Scored result items from entity search
52
52
  */
53
- async Search(query, topK, filters, contextUser) {
53
+ async Search(query, topK, filters, contextUser, scopeConstraints) {
54
54
  const trimmed = (query ?? '').trim();
55
55
  if (trimmed.length < EntitySearchProvider_1.MIN_TERM_LENGTH)
56
56
  return [];
57
57
  try {
58
+ // Honor per-provider query transform (e.g., FTS keyword extraction, AI rewrite)
59
+ const rawQuery = scopeConstraints?.QueryTransforms?.[this.SourceType] ?? query;
60
+ // Strip SQL LIKE wildcards (`%`, `_`, `[`, `]`) before passing through to
61
+ // RunView's UserSearchString. The downstream `GenericDatabaseProvider`
62
+ // builds `LIKE '%${input}%'` clauses with only single-quote escaping —
63
+ // unstripped `%` would silently match every row, and `[abc]` would
64
+ // become a LIKE character-class. We treat these characters as
65
+ // not-meaningful for entity LIKE search rather than offering a
66
+ // user-facing "match wildcard" feature.
67
+ const effectiveQuery = this.sanitizeUserSearchString(rawQuery);
68
+ if (!effectiveQuery) {
69
+ // Query was entirely wildcard chars — nothing meaningful to match
70
+ LogStatus('EntitySearchProvider: Query reduced to empty after wildcard strip — returning no results');
71
+ return [];
72
+ }
73
+ // Multi-provider migration (v5.31+): use `this.Provider` instead of
74
+ // `new Metadata()` so the search honors a non-default IMetadataProvider
75
+ // when the calling component supplies one. Falls back to the global
76
+ // default when unset.
58
77
  const md = this.Provider;
59
- const searchableEntities = this.getSearchableEntities(md, filters);
60
- if (searchableEntities.length === 0) {
61
- LogStatus('EntitySearchProvider: No searchable entities found');
78
+ // Build the scoped subset: if scopeConstraints.Entities is provided, use those
79
+ // verbatim (they already went through the scope's Nunjucks-rendered ExtraFilter +
80
+ // UserSearchString pipeline). Otherwise fall back to legacy AllowUserSearchAPI
81
+ // behavior with optional filters.EntityNames restriction.
82
+ const scoped = this.buildScopedEntityList(md, scopeConstraints, filters);
83
+ if (scoped.length === 0) {
84
+ LogStatus('EntitySearchProvider: No searchable entities (scope or metadata match empty)');
62
85
  return [];
63
86
  }
64
- // Debug: log searchable entities and their search fields
65
- LogStatus(`EntitySearchProvider: Searching ${searchableEntities.length} entities for "${trimmed}"`);
66
- for (const e of searchableEntities.slice(0, 3)) {
67
- const entity = md.EntityByName(e.Name);
87
+ // Debug: log scoped entities and their search fields
88
+ LogStatus(`EntitySearchProvider: Searching ${scoped.length} entities for "${effectiveQuery}"${scopeConstraints ? ' (scoped)' : ''}`);
89
+ for (const e of scoped.slice(0, 3)) {
90
+ const entity = md.EntityByName(e.EntityName);
68
91
  if (entity) {
69
92
  const searchFields = entity.Fields.filter(f => f.IncludeInUserSearchAPI);
70
- LogStatus(` Entity "${e.Name}": ${searchFields.length} searchable fields [${searchFields.slice(0, 5).map(f => f.Name).join(', ')}${searchFields.length > 5 ? '...' : ''}]`);
93
+ LogStatus(` Entity "${e.EntityName}": ${searchFields.length} searchable fields [${searchFields.slice(0, 5).map(f => f.Name).join(', ')}${searchFields.length > 5 ? '...' : ''}]`);
71
94
  }
72
95
  }
73
96
  // Calculate per-entity limit: distribute topK across entities
74
- const perEntityLimit = Math.max(3, Math.ceil(topK / Math.max(1, searchableEntities.length)));
75
- // Search all entities in parallel, each gated by a hard timeout
76
- const searchPromises = searchableEntities.map(entity => this.searchOneEntity(entity.Name, trimmed, perEntityLimit, contextUser));
97
+ const perEntityLimit = Math.max(3, Math.ceil(topK / Math.max(1, scoped.length)));
98
+ // Search all entities in parallel, threading per-entity ExtraFilter + UserSearchString
99
+ // override; each call is gated by a hard PER_ENTITY_TIMEOUT_MS timeout (next PR #2532)
100
+ // so a slow entity cannot hold up the whole fan-out — partial results from the other
101
+ // entities still land.
102
+ const searchPromises = scoped.map(item => this.searchOneEntity(item.EntityName, item.UserSearchString ?? effectiveQuery, perEntityLimit, contextUser, item.ExtraFilter));
77
103
  const results = await Promise.all(searchPromises);
104
+ // Re-score against the original query for field-match relevance (not the transform)
105
+ // to keep snippets/field-match semantics consistent with what the user typed.
78
106
  const allResults = results.flat();
79
107
  // Sort by score descending and limit to topK
80
108
  allResults.sort((a, b) => b.Score - a.Score);
@@ -86,6 +114,28 @@ let EntitySearchProvider = class EntitySearchProvider extends BaseSearchProvider
86
114
  return [];
87
115
  }
88
116
  }
117
+ /**
118
+ * Resolve the entity list to actually search.
119
+ *
120
+ * - If `scopeConstraints.Entities` is provided, use those directly (each carries its own
121
+ * rendered ExtraFilter + UserSearchString) — this is the "scoped" path.
122
+ * - Otherwise fall back to the legacy unscoped path (`AllowUserSearchAPI=true` with
123
+ * optional `filters.EntityNames` restriction) and wrap each in a trivial constraint.
124
+ */
125
+ buildScopedEntityList(md, scopeConstraints, filters) {
126
+ if (scopeConstraints?.Entities?.length) {
127
+ // Honor the scope's explicit entity list verbatim.
128
+ return scopeConstraints.Entities;
129
+ }
130
+ const unscoped = this.getSearchableEntities(md, filters);
131
+ return unscoped.map(e => {
132
+ const info = md.EntityByName(e.Name);
133
+ return {
134
+ EntityID: info?.ID ?? '',
135
+ EntityName: e.Name,
136
+ };
137
+ });
138
+ }
89
139
  /**
90
140
  * Get the list of entities eligible for search, optionally filtered by name.
91
141
  */
@@ -99,11 +149,16 @@ let EntitySearchProvider = class EntitySearchProvider extends BaseSearchProvider
99
149
  }
100
150
  /**
101
151
  * Search a single entity using RunView with UserSearchString. Wraps the
102
- * underlying RunView in a hard timeout so a slow entity cannot hold up
103
- * the whole fan-out — partial results from the other entities still land.
152
+ * underlying RunView in a hard PER_ENTITY_TIMEOUT_MS timeout so a slow
153
+ * entity cannot hold up the whole fan-out — partial results from the
154
+ * other entities still land.
155
+ *
156
+ * Note: `contextUser` is passed to RunView so row-level security (RLS) is applied
157
+ * automatically — this is the Entity provider's permission push-down per Section 3.6
158
+ * of plans/search-scopes-rag-plus.md.
104
159
  */
105
- async searchOneEntity(entityName, query, maxRows, contextUser) {
106
- const work = this.searchOneEntityRaw(entityName, query, maxRows, contextUser);
160
+ async searchOneEntity(entityName, userSearchString, maxRows, contextUser, extraFilter) {
161
+ const work = this.searchOneEntityRaw(entityName, userSearchString, maxRows, contextUser, extraFilter);
107
162
  let timer;
108
163
  const timeout = new Promise(resolve => {
109
164
  timer = setTimeout(() => {
@@ -119,12 +174,13 @@ let EntitySearchProvider = class EntitySearchProvider extends BaseSearchProvider
119
174
  clearTimeout(timer);
120
175
  }
121
176
  }
122
- async searchOneEntityRaw(entityName, query, maxRows, contextUser) {
177
+ async searchOneEntityRaw(entityName, userSearchString, maxRows, contextUser, extraFilter) {
123
178
  try {
124
179
  const rv = new RunView();
125
180
  const result = await rv.RunView({
126
181
  EntityName: entityName,
127
- UserSearchString: query,
182
+ UserSearchString: userSearchString,
183
+ ExtraFilter: extraFilter && extraFilter.trim() ? extraFilter : undefined,
128
184
  MaxRows: maxRows,
129
185
  ResultType: 'simple'
130
186
  }, contextUser);
@@ -132,7 +188,7 @@ let EntitySearchProvider = class EntitySearchProvider extends BaseSearchProvider
132
188
  LogError(`EntitySearchProvider: Failed to search "${entityName}": ${result.ErrorMessage}`);
133
189
  return [];
134
190
  }
135
- return this.convertResults(result.Results, entityName, query);
191
+ return this.convertResults(result.Results, entityName, userSearchString);
136
192
  }
137
193
  catch (error) {
138
194
  const msg = error instanceof Error ? error.message : String(error);
@@ -236,6 +292,26 @@ let EntitySearchProvider = class EntitySearchProvider extends BaseSearchProvider
236
292
  }
237
293
  return entityInfo ? `Matched in ${entityInfo.Name}` : 'Matched record';
238
294
  }
295
+ /**
296
+ * Remove SQL LIKE wildcard characters from a user-supplied search string.
297
+ *
298
+ * The downstream `GenericDatabaseProvider.createViewUserSearchSQL`
299
+ * interpolates user input directly into `LIKE '%${input}%'`, only
300
+ * escaping single quotes. Unstripped LIKE wildcards (`%`, `_`, `[`, `]`)
301
+ * would either match too much (e.g. `Query="%"` matches every row) or
302
+ * trigger LIKE character-class parsing (`Query="[abc]"`).
303
+ *
304
+ * Behavior intent: these characters are treated as not-meaningful for
305
+ * entity LIKE search. A query containing literal `%` (e.g. `100%`) will
306
+ * not find records that contain `100%` — the trade-off is documented
307
+ * to keep the behavior predictable and safe.
308
+ *
309
+ * Trailing/leading whitespace is collapsed; an all-wildcard query
310
+ * returns empty and the caller short-circuits to zero results.
311
+ */
312
+ sanitizeUserSearchString(input) {
313
+ return input.replace(/[%_[\]]/g, '').trim();
314
+ }
239
315
  };
240
316
  EntitySearchProvider = EntitySearchProvider_1 = __decorate([
241
317
  RegisterClass(BaseSearchProvider, 'EntitySearchProvider')