@memberjunction/core 5.38.0 → 5.39.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/readme.md CHANGED
@@ -858,6 +858,56 @@ Key features:
858
858
 
859
859
  ---
860
860
 
861
+ ### BaseEngineRegistry — cross-engine cache reverse lookup
862
+
863
+ Every `BaseEngine` registers itself with the process-wide `BaseEngineRegistry` on
864
+ load, so the registry always knows **which loaded engines cache which entities**.
865
+ You can use that to ask, from anywhere, *"is this entity already fully in memory?
866
+ if so, hand me the array — and don't go to the database."*
867
+
868
+ This is the introspection behind the Admin → System Diagnostics "loaded engines"
869
+ view, plus two reverse-lookup helpers:
870
+
871
+ ```typescript
872
+ import { BaseEngineRegistry, UserInfo } from '@memberjunction/core';
873
+
874
+ // All loaded engines that cache 'Users', unfiltered (full-set) caches first.
875
+ // Each match carries the engine, its config, and a LIVE pointer to the array.
876
+ const matches = BaseEngineRegistry.Instance.FindCachedEntity<UserInfo>('Users');
877
+ // matches[0] => { engineClassName, engine, config, records: UserInfo[], unfiltered }
878
+
879
+ // Or the one-liner: the best (unfiltered-preferred) cached array, or null.
880
+ const users = BaseEngineRegistry.Instance.TryGetCachedRecords<UserInfo>('Users', { unfilteredOnly: true });
881
+ if (users) {
882
+ // Small/static entity already in memory — filter/sort locally, zero DB calls.
883
+ const hits = users.filter(u => u.Name.toLowerCase().includes(q));
884
+ } else {
885
+ // Not cached as a full set → fall back to a normal RunView against the DB.
886
+ }
887
+ ```
888
+
889
+ `FindCachedEntity(entityName, { unfilteredOnly? })`:
890
+ - Considers **only loaded** engines (a registered-but-unloaded engine has no data).
891
+ - Matches an engine config when `Type === 'entity'` and `EntityName` matches (case-insensitive, trimmed).
892
+ - Orders **unfiltered caches first** — a config with no `Filter` holds the *complete*
893
+ entity set and is authoritative (safe for "show all" / in-memory search); filtered
894
+ caches (a subset) come after. `unfilteredOnly: true` omits the filtered ones.
895
+ - Returns the engine's **live array** (not a copy) — read it, don't mutate it. When the
896
+ config's `ResultType` is `'simple'`, rows are plain objects, not `BaseEntity` instances.
897
+ - Returns **all** matches when several engines cache the same entity, so the caller can
898
+ pick (by `engineClassName`, by inspecting `config`, etc.).
899
+
900
+ `TryGetCachedRecords(entityName, { unfilteredOnly? })` is the convenience wrapper —
901
+ the best match's array, or `null`.
902
+
903
+ **Why it's useful:** UI and service code that needs to look up records for a
904
+ small/static entity (FK pickers, dropdowns, validation) can serve the lookup from
905
+ an already-loaded engine cache in a single line — no extra DB round-trip, no
906
+ per-keystroke query — and transparently fall back to `RunView` when the entity
907
+ isn't cached as a full set.
908
+
909
+ ---
910
+
861
911
  ### RegisterForStartup
862
912
 
863
913
  The `@RegisterForStartup` decorator registers singleton engine classes (or any class implementing `IStartupSink`) with the `StartupManager` to automatically run configuration/setup during application boot.
@@ -1216,6 +1266,73 @@ Features:
1216
1266
 
1217
1267
  ---
1218
1268
 
1269
+ ## Ranked Entity Record Search (`SearchEntity` / `SearchEntities`)
1270
+
1271
+ A two-tier ranked-search API for finding the most relevant **records** of an entity for a free-text request. Distinct from the other lookups MJ already exposes — see the comparison below.
1272
+
1273
+ ```typescript
1274
+ import { Metadata, EntitySearchResult } from '@memberjunction/core';
1275
+
1276
+ const md = new Metadata();
1277
+
1278
+ // Singular form — search one entity, return ranked record list
1279
+ const results: EntitySearchResult[] = await md.SearchEntity({
1280
+ entityName: 'MJ: Entities',
1281
+ searchText: userRequestText,
1282
+ options: { mode: 'hybrid', topK: 10, weights: { lexical: 1.0, semantic: 1.5 }, contextUser }
1283
+ });
1284
+
1285
+ // Plural form — search many entities in ONE round-trip
1286
+ // Returns an array of arrays, aligned by input order
1287
+ const groups = await md.SearchEntities([
1288
+ { entityName: 'Invoices', searchText: 'overdue payments', options: { topK: 5, contextUser } },
1289
+ { entityName: 'Customers', searchText: 'overdue payments', options: { topK: 5, contextUser } },
1290
+ { entityName: 'Notes', searchText: 'overdue payments', options: { topK: 5, contextUser } },
1291
+ ]);
1292
+ // groups[0] = top Invoices, groups[1] = top Customers, groups[2] = top Notes
1293
+ ```
1294
+
1295
+ **Modes:**
1296
+ - `lexical` — substring / prefix matching on the entity's name field and any `IncludeInUserSearchAPI` fields.
1297
+ - `semantic` — vector cosine against precomputed embeddings in `MJ: Entity Record Documents.VectorJSON`.
1298
+ - `hybrid` (default) — weighted RRF blend of the two, tunable via `options.weights` and `options.rrfK`.
1299
+
1300
+ **Configuration:** semantic and hybrid modes require an Active `EntityDocument` of type `Search` registered for the target entity. The MJ install seeds one for `MJ: Entities` so the entity catalog is searchable out of the box; users enable it for other entities via metadata (see `/metadata/entity-documents/`).
1301
+
1302
+ **Provider implementation:** declared on `IMetadataProvider`, implemented polymorphically by each concrete provider. `GenericDatabaseProvider` runs the ranking in-process (embedding the query via `AIEngine.EmbedTextLocal` and querying `SimpleVectorServiceProvider` directly); `GraphQLDataProvider` proxies the whole batch to the server in one round-trip via the `SearchEntities` resolver. No registration or wiring required at startup.
1303
+
1304
+ ### How this differs from MJ's other search/lookup APIs
1305
+
1306
+ | API | Purpose | Returns |
1307
+ |---|---|---|
1308
+ | `EntityByName(name)` / `EntityByID(id)` | Look up an entity **definition** (`EntityInfo`). Deterministic, not ranked. | One `EntityInfo` |
1309
+ | `FullTextSearch(params)` | Multi-entity server-side text search using each entity's `UserSearchString` rule (LIKE / FTS). Lexical only. | Groups of `FullTextSearchResultItem` |
1310
+ | **`SearchEntity(params)`** | "Find the N most relevant **records** of *this* entity for this query." Hybrid lexical + semantic. | `EntitySearchResult[]` |
1311
+ | **`SearchEntities(params[])`** | Batch — same ranking applied to multiple entities in one call. | `EntitySearchResult[][]` aligned by input |
1312
+ | `SearchEngine.Search()` ([`@memberjunction/search-engine`](../SearchEngine/README.md)) | **Cross-source** unified search across vectors, full-text, entities, and storage. Scoped via `SearchScope` metadata, optional reranker. | Aggregated `SearchResult` |
1313
+
1314
+ **Picking the right one** is straightforward: if you know the entity and want ranked records, use `SearchEntity`. If you know the candidate entities, use `SearchEntities` (plural). If you don't know which entity / want cross-source results, use `SearchEngine.Search`. For exact-name metadata lookup, use `EntityByName`.
1315
+
1316
+ ---
1317
+
1318
+ ## Weighted Reciprocal Rank Fusion (`ComputeRRF`)
1319
+
1320
+ `ComputeRRF` is the canonical RRF implementation used wherever MJ blends ranked result lists (`SearchEntity` / `SearchEntities` hybrid mode, `SearchEngine` cross-scope fusion, dupe detection). It accepts an optional per-list `weights` array:
1321
+
1322
+ ```typescript
1323
+ import { ComputeRRF, ScoredCandidate } from '@memberjunction/core';
1324
+
1325
+ const fused = ComputeRRF(
1326
+ [lexicalResults, semanticResults],
1327
+ /* k */ 60,
1328
+ /* weights */ [1.0, 1.5] // semantic contributes 1.5× per rank position
1329
+ );
1330
+ ```
1331
+
1332
+ Formula: `FusedScore(d) = Σ_i w_i / (k + rank_i(d))`. Omitting `weights` is equivalent to all-ones — canonical unweighted RRF.
1333
+
1334
+ ---
1335
+
1219
1336
  ## Utility Functions
1220
1337
 
1221
1338
  ```typescript