@memberjunction/server 5.24.0 → 5.26.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/agents/skip-sdk.d.ts +12 -0
- package/dist/agents/skip-sdk.d.ts.map +1 -1
- package/dist/agents/skip-sdk.js +70 -1
- package/dist/agents/skip-sdk.js.map +1 -1
- package/dist/config.d.ts +70 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +21 -0
- package/dist/config.js.map +1 -1
- package/dist/generated/generated.d.ts +498 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +2755 -0
- package/dist/generated/generated.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -2
- package/dist/index.js.map +1 -1
- package/dist/resolvers/ArtifactFileResolver.d.ts +15 -0
- package/dist/resolvers/ArtifactFileResolver.d.ts.map +1 -0
- package/dist/resolvers/ArtifactFileResolver.js +74 -0
- package/dist/resolvers/ArtifactFileResolver.js.map +1 -0
- package/dist/resolvers/AutotagPipelineResolver.d.ts +13 -0
- package/dist/resolvers/AutotagPipelineResolver.d.ts.map +1 -1
- package/dist/resolvers/AutotagPipelineResolver.js +103 -3
- package/dist/resolvers/AutotagPipelineResolver.js.map +1 -1
- package/dist/resolvers/CacheStatsResolver.d.ts +31 -0
- package/dist/resolvers/CacheStatsResolver.d.ts.map +1 -0
- package/dist/resolvers/CacheStatsResolver.js +181 -0
- package/dist/resolvers/CacheStatsResolver.js.map +1 -0
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +12 -32
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/GeoResolver.d.ts +58 -0
- package/dist/resolvers/GeoResolver.d.ts.map +1 -0
- package/dist/resolvers/GeoResolver.js +302 -0
- package/dist/resolvers/GeoResolver.js.map +1 -0
- package/dist/resolvers/RunAIAgentResolver.d.ts +13 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +115 -20
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.d.ts +21 -80
- package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.js +129 -604
- package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts +19 -0
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -0
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +149 -0
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -0
- package/package.json +66 -63
- package/src/__tests__/search-knowledge-tags.test.ts +177 -337
- package/src/__tests__/skip-sdk-organic-keys.test.ts +274 -0
- package/src/agents/skip-sdk.ts +83 -2
- package/src/config.ts +24 -0
- package/src/generated/generated.ts +1902 -1
- package/src/index.ts +18 -2
- package/src/resolvers/ArtifactFileResolver.ts +71 -0
- package/src/resolvers/AutotagPipelineResolver.ts +118 -4
- package/src/resolvers/CacheStatsResolver.ts +142 -0
- package/src/resolvers/FileResolver.ts +12 -41
- package/src/resolvers/GeoResolver.ts +258 -0
- package/src/resolvers/RunAIAgentResolver.ts +137 -23
- package/src/resolvers/SearchKnowledgeResolver.ts +114 -715
- package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +138 -0
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import { Resolver, Mutation, Arg, Ctx, ObjectType, Field, Float, InputType } from 'type-graphql';
|
|
2
2
|
import { AppContext } from '../types.js';
|
|
3
|
-
import { LogError, LogStatus
|
|
4
|
-
import { MJVectorIndexEntity, MJVectorDatabaseEntity, KnowledgeHubMetadataEngine } from '@memberjunction/core-entities';
|
|
3
|
+
import { LogError, LogStatus } from '@memberjunction/core';
|
|
5
4
|
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
6
|
-
import {
|
|
7
|
-
import { BaseEmbeddings, GetAIAPIKey } from '@memberjunction/ai';
|
|
8
|
-
import { VectorDBBase, BaseResponse } from '@memberjunction/ai-vectordb';
|
|
9
|
-
import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
|
|
5
|
+
import { SearchEngine, SearchResult as SearchEngineResult, SearchResultItem as SearchEngineResultItem, SearchProviderInfo } from '@memberjunction/search-engine';
|
|
10
6
|
|
|
11
7
|
/* ───── GraphQL types ───── */
|
|
12
8
|
|
|
@@ -20,6 +16,9 @@ export class SearchScoreBreakdown {
|
|
|
20
16
|
|
|
21
17
|
@Field(() => Float, { nullable: true })
|
|
22
18
|
Entity?: number;
|
|
19
|
+
|
|
20
|
+
@Field(() => Float, { nullable: true })
|
|
21
|
+
Storage?: number;
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
@ObjectType()
|
|
@@ -63,6 +62,22 @@ export class SearchKnowledgeResultItem {
|
|
|
63
62
|
/** Raw vector metadata as JSON string — contains all entity fields stored in the vector DB */
|
|
64
63
|
@Field({ nullable: true })
|
|
65
64
|
RawMetadata?: string;
|
|
65
|
+
|
|
66
|
+
/** Discriminator for UI rendering: 'entity-record', 'storage-file', or 'content-item' */
|
|
67
|
+
@Field()
|
|
68
|
+
ResultType: string;
|
|
69
|
+
|
|
70
|
+
/** ID of the SearchProvider metadata record that produced this result */
|
|
71
|
+
@Field({ nullable: true })
|
|
72
|
+
ProviderId?: string;
|
|
73
|
+
|
|
74
|
+
/** Display label from the SearchProvider metadata (e.g., "Database", "Semantic Search") */
|
|
75
|
+
@Field({ nullable: true })
|
|
76
|
+
ProviderLabel?: string;
|
|
77
|
+
|
|
78
|
+
/** Font Awesome icon class from the SearchProvider metadata */
|
|
79
|
+
@Field({ nullable: true })
|
|
80
|
+
ProviderIcon?: string;
|
|
66
81
|
}
|
|
67
82
|
|
|
68
83
|
@ObjectType()
|
|
@@ -75,6 +90,30 @@ export class SearchSourceCounts {
|
|
|
75
90
|
|
|
76
91
|
@Field()
|
|
77
92
|
Entity: number;
|
|
93
|
+
|
|
94
|
+
@Field()
|
|
95
|
+
Storage: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@ObjectType()
|
|
99
|
+
export class SearchProviderInfoType {
|
|
100
|
+
@Field()
|
|
101
|
+
ID: string;
|
|
102
|
+
|
|
103
|
+
@Field()
|
|
104
|
+
Name: string;
|
|
105
|
+
|
|
106
|
+
@Field()
|
|
107
|
+
DisplayName: string;
|
|
108
|
+
|
|
109
|
+
@Field()
|
|
110
|
+
Icon: string;
|
|
111
|
+
|
|
112
|
+
@Field()
|
|
113
|
+
SourceType: string;
|
|
114
|
+
|
|
115
|
+
@Field()
|
|
116
|
+
Priority: number;
|
|
78
117
|
}
|
|
79
118
|
|
|
80
119
|
@ObjectType()
|
|
@@ -94,6 +133,9 @@ export class SearchKnowledgeResult {
|
|
|
94
133
|
@Field(() => SearchSourceCounts)
|
|
95
134
|
SourceCounts: SearchSourceCounts;
|
|
96
135
|
|
|
136
|
+
@Field(() => [SearchProviderInfoType])
|
|
137
|
+
Providers: SearchProviderInfoType[];
|
|
138
|
+
|
|
97
139
|
@Field({ nullable: true })
|
|
98
140
|
ErrorMessage?: string;
|
|
99
141
|
}
|
|
@@ -110,7 +152,7 @@ export class SearchFiltersInput {
|
|
|
110
152
|
Tags?: string[];
|
|
111
153
|
}
|
|
112
154
|
|
|
113
|
-
/* ───── Resolver ───── */
|
|
155
|
+
/* ───── Resolver (thin wrapper around SearchEngine) ───── */
|
|
114
156
|
|
|
115
157
|
@Resolver()
|
|
116
158
|
export class SearchKnowledgeResolver extends ResolverBase {
|
|
@@ -130,58 +172,18 @@ export class SearchKnowledgeResolver extends ResolverBase {
|
|
|
130
172
|
return this.errorResult('Unable to determine current user', startTime);
|
|
131
173
|
}
|
|
132
174
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const [vectorResults, fullTextResults] = await Promise.all([
|
|
146
|
-
this.searchAllVectorIndexes(query, topK, filters, currentUser),
|
|
147
|
-
this.searchFullText(query, topK, filters, currentUser)
|
|
148
|
-
]);
|
|
149
|
-
LogStatus(`SearchKnowledge: Vector(${vectorResults.length}) + FTS(${fullTextResults.length}): ${Date.now() - t0}ms`);
|
|
150
|
-
|
|
151
|
-
// Fuse results with RRF if we have results from multiple sources
|
|
152
|
-
t0 = Date.now();
|
|
153
|
-
const fusedResults = this.fuseResults(vectorResults, fullTextResults, topK);
|
|
154
|
-
const dedupedResults = this.deduplicateResults(fusedResults);
|
|
155
|
-
|
|
156
|
-
// Exclude Content Items that originated from Entity sources — those entities
|
|
157
|
-
// are already searchable directly via their own vector embeddings, so showing
|
|
158
|
-
// the Content Item shadow result is redundant.
|
|
159
|
-
const withoutEntityContentItems = await this.excludeEntitySourcedContentItems(dedupedResults, currentUser);
|
|
160
|
-
|
|
161
|
-
// Apply minimum score threshold (post-RRF, so fusion can surface cross-source matches first)
|
|
162
|
-
const scoreThreshold = minScore ?? 0;
|
|
163
|
-
const filteredResults = scoreThreshold > 0
|
|
164
|
-
? withoutEntityContentItems.filter(r => r.Score >= scoreThreshold)
|
|
165
|
-
: withoutEntityContentItems;
|
|
166
|
-
LogStatus(`SearchKnowledge: Fuse + dedup + threshold≥${Math.round(scoreThreshold * 100)}% (${dedupedResults.length} → ${filteredResults.length} results): ${Date.now() - t0}ms`);
|
|
167
|
-
|
|
168
|
-
// Enrich with entity icons and record names
|
|
169
|
-
t0 = Date.now();
|
|
170
|
-
await this.enrichResults(filteredResults, currentUser);
|
|
171
|
-
LogStatus(`SearchKnowledge: Enrich (icons + names): ${Date.now() - t0}ms`);
|
|
172
|
-
LogStatus(`SearchKnowledge: Total: ${Date.now() - startTime}ms`);
|
|
173
|
-
|
|
174
|
-
return {
|
|
175
|
-
Success: true,
|
|
176
|
-
Results: filteredResults,
|
|
177
|
-
TotalCount: filteredResults.length,
|
|
178
|
-
ElapsedMs: Date.now() - startTime,
|
|
179
|
-
SourceCounts: {
|
|
180
|
-
Vector: vectorResults.length,
|
|
181
|
-
FullText: fullTextResults.length,
|
|
182
|
-
Entity: 0
|
|
183
|
-
},
|
|
184
|
-
};
|
|
175
|
+
const result = await SearchEngine.Instance.Search({
|
|
176
|
+
Query: query,
|
|
177
|
+
MaxResults: maxResults,
|
|
178
|
+
MinScore: minScore,
|
|
179
|
+
Filters: filters ? {
|
|
180
|
+
EntityNames: filters.EntityNames,
|
|
181
|
+
SourceTypes: filters.SourceTypes,
|
|
182
|
+
Tags: filters.Tags
|
|
183
|
+
} : undefined
|
|
184
|
+
}, currentUser);
|
|
185
|
+
|
|
186
|
+
return this.mapSearchResult(result);
|
|
185
187
|
} catch (error) {
|
|
186
188
|
const msg = error instanceof Error ? error.message : String(error);
|
|
187
189
|
LogError(`SearchKnowledge mutation failed: ${msg}`);
|
|
@@ -189,673 +191,69 @@ export class SearchKnowledgeResolver extends ResolverBase {
|
|
|
189
191
|
}
|
|
190
192
|
}
|
|
191
193
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
topK: number,
|
|
200
|
-
filters: SearchFiltersInput | undefined,
|
|
201
|
-
contextUser: UserInfo
|
|
202
|
-
): Promise<SearchKnowledgeResultItem[]> {
|
|
203
|
-
const rv = new RunView();
|
|
204
|
-
|
|
205
|
-
const indexResult = await rv.RunView<MJVectorIndexEntity>({
|
|
206
|
-
EntityName: 'MJ: Vector Indexes',
|
|
207
|
-
ResultType: 'entity_object'
|
|
208
|
-
}, contextUser);
|
|
209
|
-
|
|
210
|
-
if (!indexResult.Success || indexResult.Results.length === 0) {
|
|
211
|
-
LogStatus('SearchKnowledge: No vector indexes configured');
|
|
212
|
-
return [];
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Group indexes by EmbeddingModelID
|
|
216
|
-
const indexesByModel = this.groupIndexesByModel(indexResult.Results);
|
|
217
|
-
const pineFilter = this.buildPineconeFilter(filters);
|
|
218
|
-
|
|
219
|
-
// For each model group: embed query + query all indexes — all in parallel
|
|
220
|
-
const modelGroupPromises = Array.from(indexesByModel.entries()).map(
|
|
221
|
-
([embeddingModelID, indexes]) =>
|
|
222
|
-
this.embedAndQueryGroup(query, embeddingModelID, indexes, topK, pineFilter, contextUser)
|
|
223
|
-
);
|
|
224
|
-
|
|
225
|
-
const groupResults = await Promise.all(modelGroupPromises);
|
|
226
|
-
return groupResults.flat();
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** Group vector indexes by their EmbeddingModelID */
|
|
230
|
-
private groupIndexesByModel(indexes: MJVectorIndexEntity[]): Map<string, MJVectorIndexEntity[]> {
|
|
231
|
-
const groups = new Map<string, MJVectorIndexEntity[]>();
|
|
232
|
-
for (const index of indexes) {
|
|
233
|
-
const modelId = index.EmbeddingModelID;
|
|
234
|
-
const existing = groups.get(modelId);
|
|
235
|
-
if (existing) {
|
|
236
|
-
existing.push(index);
|
|
237
|
-
} else {
|
|
238
|
-
groups.set(modelId, [index]);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
return groups;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Embed query with one model, then immediately query all indexes that use that model.
|
|
246
|
-
* The embedding and subsequent queries are chained so index queries fire as soon as
|
|
247
|
-
* the embedding completes — without waiting for other models' embeddings.
|
|
248
|
-
*/
|
|
249
|
-
private async embedAndQueryGroup(
|
|
250
|
-
query: string,
|
|
251
|
-
embeddingModelID: string,
|
|
252
|
-
indexes: MJVectorIndexEntity[],
|
|
253
|
-
topK: number,
|
|
254
|
-
filter: object | undefined,
|
|
255
|
-
contextUser: UserInfo
|
|
256
|
-
): Promise<SearchKnowledgeResultItem[]> {
|
|
194
|
+
@Mutation(() => SearchKnowledgeResult)
|
|
195
|
+
async PreviewSearch(
|
|
196
|
+
@Arg('query') query: string,
|
|
197
|
+
@Arg('maxResults', () => Float, { nullable: true, defaultValue: 8 }) maxResults: number,
|
|
198
|
+
@Ctx() { userPayload }: AppContext = {} as AppContext
|
|
199
|
+
): Promise<SearchKnowledgeResult> {
|
|
200
|
+
const startTime = Date.now();
|
|
257
201
|
try {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
LogError(`SearchKnowledge: Embedding model ${embeddingModelID} not found`);
|
|
262
|
-
return [];
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Create embedding instance
|
|
266
|
-
const apiKey = GetAIAPIKey(model.DriverClass);
|
|
267
|
-
const embedding = MJGlobal.Instance.ClassFactory.CreateInstance<BaseEmbeddings>(
|
|
268
|
-
BaseEmbeddings, model.DriverClass, apiKey
|
|
269
|
-
);
|
|
270
|
-
if (!embedding) {
|
|
271
|
-
LogError(`SearchKnowledge: Failed to create embedding for ${model.DriverClass}`);
|
|
272
|
-
return [];
|
|
202
|
+
const currentUser = this.GetUserFromPayload(userPayload);
|
|
203
|
+
if (!currentUser) {
|
|
204
|
+
return this.errorResult('Unable to determine current user', startTime);
|
|
273
205
|
}
|
|
274
206
|
|
|
275
|
-
|
|
276
|
-
const embedResult = await embedding.EmbedText({ text: query, model: '' });
|
|
277
|
-
if (!embedResult?.vector?.length) {
|
|
278
|
-
LogError(`SearchKnowledge: Failed to embed with ${model.Name}`);
|
|
279
|
-
return [];
|
|
280
|
-
}
|
|
207
|
+
const result = await SearchEngine.Instance.PreviewSearch(query, maxResults, currentUser);
|
|
281
208
|
|
|
282
|
-
|
|
283
|
-
const indexPromises = indexes.map(vectorIndex =>
|
|
284
|
-
this.queryOneIndex(vectorIndex, embedResult.vector, topK, filter, contextUser)
|
|
285
|
-
.catch(error => {
|
|
286
|
-
LogError(`SearchKnowledge: Error querying index "${vectorIndex.Name}": ${error}`);
|
|
287
|
-
return [] as SearchKnowledgeResultItem[];
|
|
288
|
-
})
|
|
289
|
-
);
|
|
290
|
-
|
|
291
|
-
const indexResults = await Promise.all(indexPromises);
|
|
292
|
-
return indexResults.flat();
|
|
209
|
+
return this.mapSearchResult(result);
|
|
293
210
|
} catch (error) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* Query a single vector index by looking up its VectorDatabase provider and passing the index name.
|
|
301
|
-
*/
|
|
302
|
-
private async queryOneIndex(
|
|
303
|
-
vectorIndex: MJVectorIndexEntity,
|
|
304
|
-
queryVector: number[],
|
|
305
|
-
topK: number,
|
|
306
|
-
filter: object | undefined,
|
|
307
|
-
contextUser: UserInfo
|
|
308
|
-
): Promise<SearchKnowledgeResultItem[]> {
|
|
309
|
-
// Look up the vector database to get the ClassKey
|
|
310
|
-
const rv = new RunView();
|
|
311
|
-
const dbResult = await rv.RunView<MJVectorDatabaseEntity>({
|
|
312
|
-
EntityName: 'MJ: Vector Databases',
|
|
313
|
-
ExtraFilter: `ID='${vectorIndex.VectorDatabaseID}'`,
|
|
314
|
-
ResultType: 'entity_object'
|
|
315
|
-
}, contextUser);
|
|
316
|
-
|
|
317
|
-
if (!dbResult.Success || dbResult.Results.length === 0) {
|
|
318
|
-
LogError(`SearchKnowledge: VectorDatabase not found for index "${vectorIndex.Name}"`);
|
|
319
|
-
return [];
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const vectorDB = dbResult.Results[0];
|
|
323
|
-
const apiKey = GetAIAPIKey(vectorDB.ClassKey);
|
|
324
|
-
const vectorDBInstance = MJGlobal.Instance.ClassFactory.CreateInstance<VectorDBBase>(
|
|
325
|
-
VectorDBBase, vectorDB.ClassKey, apiKey
|
|
326
|
-
);
|
|
327
|
-
|
|
328
|
-
if (!vectorDBInstance) {
|
|
329
|
-
LogError(`SearchKnowledge: Failed to create VectorDB instance for "${vectorDB.ClassKey}"`);
|
|
330
|
-
return [];
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// Query with the specific index name
|
|
334
|
-
const response: BaseResponse = await vectorDBInstance.QueryIndex({
|
|
335
|
-
id: vectorIndex.Name,
|
|
336
|
-
vector: queryVector,
|
|
337
|
-
topK,
|
|
338
|
-
includeMetadata: true,
|
|
339
|
-
filter,
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
if (!response.success || !response.data?.matches) {
|
|
343
|
-
return [];
|
|
211
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
212
|
+
LogError(`PreviewSearch mutation failed: ${msg}`);
|
|
213
|
+
return this.errorResult(msg, startTime);
|
|
344
214
|
}
|
|
345
|
-
|
|
346
|
-
return this.convertMatches(response.data.matches, vectorIndex.Name);
|
|
347
215
|
}
|
|
348
216
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
*/
|
|
355
|
-
private async searchFullText(
|
|
356
|
-
query: string,
|
|
357
|
-
topK: number,
|
|
358
|
-
filters: SearchFiltersInput | undefined,
|
|
359
|
-
contextUser: UserInfo
|
|
360
|
-
): Promise<SearchKnowledgeResultItem[]> {
|
|
361
|
-
try {
|
|
362
|
-
const md = new Metadata();
|
|
363
|
-
const ftsResult = await md.FullTextSearch({
|
|
364
|
-
SearchText: query,
|
|
365
|
-
EntityNames: filters?.EntityNames,
|
|
366
|
-
MaxRowsPerEntity: Math.max(3, Math.ceil(topK / 10))
|
|
367
|
-
}, contextUser);
|
|
368
|
-
|
|
369
|
-
if (!ftsResult.Success) {
|
|
370
|
-
LogError(`SearchKnowledge: FTS error: ${ftsResult.ErrorMessage}`);
|
|
371
|
-
return [];
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const results: SearchKnowledgeResultItem[] = ftsResult.Results.map(r => ({
|
|
375
|
-
ID: `ft-${r.EntityName}-${r.RecordID}`,
|
|
217
|
+
private mapSearchResult(result: SearchEngineResult): SearchKnowledgeResult {
|
|
218
|
+
return {
|
|
219
|
+
Success: result.Success,
|
|
220
|
+
Results: result.Results.map((r: SearchEngineResultItem) => ({
|
|
221
|
+
ID: r.ID,
|
|
376
222
|
EntityName: r.EntityName,
|
|
377
223
|
RecordID: r.RecordID,
|
|
378
|
-
SourceType:
|
|
224
|
+
SourceType: r.SourceType,
|
|
225
|
+
ResultType: r.ResultType,
|
|
379
226
|
Title: r.Title,
|
|
380
227
|
Snippet: r.Snippet,
|
|
381
228
|
Score: r.Score,
|
|
382
|
-
ScoreBreakdown:
|
|
383
|
-
Tags:
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* Enrich search results with tags from both the TaggedItems entity (for general entities)
|
|
415
|
-
* and the ContentItemTag entity (for Content Items). Runs both queries in parallel
|
|
416
|
-
* for maximum throughput, then merges the tag sets onto each result.
|
|
417
|
-
*/
|
|
418
|
-
private async enrichResultsWithTags(
|
|
419
|
-
results: SearchKnowledgeResultItem[],
|
|
420
|
-
md: Metadata,
|
|
421
|
-
contextUser: UserInfo
|
|
422
|
-
): Promise<void> {
|
|
423
|
-
if (results.length === 0) return;
|
|
424
|
-
|
|
425
|
-
try {
|
|
426
|
-
// Separate results into Content Items vs everything else
|
|
427
|
-
const contentItemResults = results.filter(r => r.EntityName === 'MJ: Content Items');
|
|
428
|
-
const generalResults = results.filter(r => r.EntityName !== 'MJ: Content Items');
|
|
429
|
-
|
|
430
|
-
// Run both tag lookups in parallel
|
|
431
|
-
await Promise.all([
|
|
432
|
-
this.loadTaggedItemTags(generalResults, md, contextUser),
|
|
433
|
-
this.loadContentItemTags(contentItemResults, contextUser)
|
|
434
|
-
]);
|
|
435
|
-
} catch (error) {
|
|
436
|
-
LogError(`SearchKnowledge: Error enriching results with tags: ${error}`);
|
|
437
|
-
// Non-fatal — results still usable without tags
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* Load tags from the TaggedItems entity for non-Content-Item results.
|
|
443
|
-
* Queries by EntityID + RecordID pairs in a single batch.
|
|
444
|
-
*/
|
|
445
|
-
private async loadTaggedItemTags(
|
|
446
|
-
results: SearchKnowledgeResultItem[],
|
|
447
|
-
md: Metadata,
|
|
448
|
-
contextUser: UserInfo
|
|
449
|
-
): Promise<void> {
|
|
450
|
-
if (results.length === 0) return;
|
|
451
|
-
|
|
452
|
-
// Get entity IDs from metadata
|
|
453
|
-
const entityIdMap = new Map<string, string>();
|
|
454
|
-
for (const r of results) {
|
|
455
|
-
if (!entityIdMap.has(r.EntityName)) {
|
|
456
|
-
const entityInfo = md.Entities.find(e => e.Name === r.EntityName);
|
|
457
|
-
if (entityInfo) {
|
|
458
|
-
entityIdMap.set(r.EntityName, entityInfo.ID);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
if (entityIdMap.size === 0) return;
|
|
464
|
-
|
|
465
|
-
// Build filter for TaggedItems: OR across all entity+record pairs
|
|
466
|
-
const conditions: string[] = [];
|
|
467
|
-
for (const r of results) {
|
|
468
|
-
const entityID = entityIdMap.get(r.EntityName);
|
|
469
|
-
if (!entityID) continue;
|
|
470
|
-
conditions.push(`(EntityID='${entityID}' AND RecordID='${r.RecordID}')`);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
if (conditions.length === 0) return;
|
|
474
|
-
|
|
475
|
-
const rv = new RunView();
|
|
476
|
-
const tagResult = await rv.RunView<{ EntityID: string; RecordID: string; Tag: string }>({
|
|
477
|
-
EntityName: 'MJ: Tagged Items',
|
|
478
|
-
ExtraFilter: conditions.join(' OR '),
|
|
479
|
-
Fields: ['EntityID', 'RecordID', 'Tag'],
|
|
480
|
-
ResultType: 'simple'
|
|
481
|
-
}, contextUser);
|
|
482
|
-
|
|
483
|
-
if (!tagResult.Success) return;
|
|
484
|
-
|
|
485
|
-
// Build a lookup: "entityID::recordID" -> tag names
|
|
486
|
-
const tagMap = new Map<string, string[]>();
|
|
487
|
-
for (const ti of tagResult.Results) {
|
|
488
|
-
const key = `${ti.EntityID}::${ti.RecordID}`;
|
|
489
|
-
const tags = tagMap.get(key) ?? [];
|
|
490
|
-
tags.push(ti.Tag);
|
|
491
|
-
tagMap.set(key, tags);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// Apply tags to results
|
|
495
|
-
for (const r of results) {
|
|
496
|
-
const entityID = entityIdMap.get(r.EntityName);
|
|
497
|
-
if (!entityID) continue;
|
|
498
|
-
const key = `${entityID}::${r.RecordID}`;
|
|
499
|
-
r.Tags = tagMap.get(key) ?? [];
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
/**
|
|
504
|
-
* Load tags from the ContentItemTag entity for Content Item results.
|
|
505
|
-
* Queries by ItemID (which maps to the result's RecordID) in a single batch.
|
|
506
|
-
*/
|
|
507
|
-
private async loadContentItemTags(
|
|
508
|
-
results: SearchKnowledgeResultItem[],
|
|
509
|
-
contextUser: UserInfo
|
|
510
|
-
): Promise<void> {
|
|
511
|
-
if (results.length === 0) return;
|
|
512
|
-
|
|
513
|
-
const recordIDs = results.map(r => `'${r.RecordID}'`);
|
|
514
|
-
const rv = new RunView();
|
|
515
|
-
const tagResult = await rv.RunView<{ ItemID: string; Tag: string }>({
|
|
516
|
-
EntityName: 'MJ: Content Item Tags',
|
|
517
|
-
ExtraFilter: `ItemID IN (${recordIDs.join(',')})`,
|
|
518
|
-
Fields: ['ItemID', 'Tag'],
|
|
519
|
-
ResultType: 'simple'
|
|
520
|
-
}, contextUser);
|
|
521
|
-
|
|
522
|
-
if (!tagResult.Success) return;
|
|
523
|
-
|
|
524
|
-
// Build a lookup: ItemID -> tag names
|
|
525
|
-
const tagMap = new Map<string, string[]>();
|
|
526
|
-
for (const ti of tagResult.Results) {
|
|
527
|
-
const key = ti.ItemID;
|
|
528
|
-
const tags = tagMap.get(key) ?? [];
|
|
529
|
-
tags.push(ti.Tag);
|
|
530
|
-
tagMap.set(key, tags);
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
// Apply tags to results (use case-insensitive UUID comparison)
|
|
534
|
-
for (const r of results) {
|
|
535
|
-
// Check both raw and lowercase keys since UUID casing may vary
|
|
536
|
-
r.Tags = tagMap.get(r.RecordID) ?? tagMap.get(r.RecordID.toLowerCase()) ?? [];
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Fuse vector and full-text results using Reciprocal Rank Fusion (RRF).
|
|
542
|
-
* Deduplicates by RecordID, preferring the higher-scored source.
|
|
543
|
-
*/
|
|
544
|
-
private fuseResults(
|
|
545
|
-
vectorResults: SearchKnowledgeResultItem[],
|
|
546
|
-
fullTextResults: SearchKnowledgeResultItem[],
|
|
547
|
-
topK: number
|
|
548
|
-
): SearchKnowledgeResultItem[] {
|
|
549
|
-
if (vectorResults.length === 0 && fullTextResults.length === 0) {
|
|
550
|
-
return [];
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// If only one source has results, normalize scores relative to the top result
|
|
554
|
-
// so the best match shows ~90-95% instead of raw cosine similarity (~40-50%)
|
|
555
|
-
if (fullTextResults.length === 0) {
|
|
556
|
-
return this.normalizeScores(vectorResults.slice(0, topK));
|
|
557
|
-
}
|
|
558
|
-
if (vectorResults.length === 0) {
|
|
559
|
-
return this.normalizeScores(fullTextResults.slice(0, topK));
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// Build scored candidate lists for RRF
|
|
563
|
-
const vectorCandidates: ScoredCandidate[] = vectorResults.map((r, i) => ({
|
|
564
|
-
ID: r.RecordID,
|
|
565
|
-
Score: r.Score,
|
|
566
|
-
Rank: i + 1
|
|
567
|
-
}));
|
|
568
|
-
const ftCandidates: ScoredCandidate[] = fullTextResults.map((r, i) => ({
|
|
569
|
-
ID: r.RecordID,
|
|
570
|
-
Score: r.Score,
|
|
571
|
-
Rank: i + 1
|
|
572
|
-
}));
|
|
573
|
-
|
|
574
|
-
// Compute RRF scores
|
|
575
|
-
const fused = ComputeRRF([vectorCandidates, ftCandidates]);
|
|
576
|
-
|
|
577
|
-
// Map fused results back to full result items
|
|
578
|
-
const resultMap = new Map<string, SearchKnowledgeResultItem>();
|
|
579
|
-
for (const r of [...vectorResults, ...fullTextResults]) {
|
|
580
|
-
if (!resultMap.has(r.RecordID)) {
|
|
581
|
-
resultMap.set(r.RecordID, r);
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
return fused.slice(0, topK).map(candidate => {
|
|
586
|
-
const item = resultMap.get(candidate.ID);
|
|
587
|
-
if (item) {
|
|
588
|
-
item.Score = candidate.Score;
|
|
589
|
-
return item;
|
|
590
|
-
}
|
|
591
|
-
// Shouldn't happen, but fallback
|
|
592
|
-
return {
|
|
593
|
-
ID: candidate.ID,
|
|
594
|
-
EntityName: 'Unknown',
|
|
595
|
-
RecordID: candidate.ID,
|
|
596
|
-
SourceType: 'fused',
|
|
597
|
-
Title: 'Unknown',
|
|
598
|
-
Snippet: '',
|
|
599
|
-
Score: candidate.Score,
|
|
600
|
-
ScoreBreakdown: {},
|
|
601
|
-
Tags: [],
|
|
602
|
-
MatchedAt: new Date()
|
|
603
|
-
};
|
|
604
|
-
});
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
/**
|
|
608
|
-
* Normalize scores when only one search source returned results.
|
|
609
|
-
* Scales scores relative to the top result so the best match shows
|
|
610
|
-
* ~90-95% instead of raw cosine similarity (~40-50%).
|
|
611
|
-
* This prevents artificially low-looking scores when RRF isn't applied.
|
|
612
|
-
*/
|
|
613
|
-
private normalizeScores(results: SearchKnowledgeResultItem[]): SearchKnowledgeResultItem[] {
|
|
614
|
-
if (results.length === 0) return results;
|
|
615
|
-
|
|
616
|
-
const maxScore = results[0].Score; // Results are already sorted by score desc
|
|
617
|
-
if (maxScore <= 0) return results;
|
|
618
|
-
|
|
619
|
-
// Scale so the top result maps to ~0.95 and others proportionally
|
|
620
|
-
const scaleFactor = 0.95 / maxScore;
|
|
621
|
-
for (const r of results) {
|
|
622
|
-
r.Score = Math.min(0.99, r.Score * scaleFactor);
|
|
623
|
-
}
|
|
624
|
-
return results;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
/** Build Pinecone metadata filter from input */
|
|
628
|
-
private buildPineconeFilter(filters?: SearchFiltersInput): object | undefined {
|
|
629
|
-
if (!filters) return undefined;
|
|
630
|
-
const conditions: object[] = [];
|
|
631
|
-
if (filters.EntityNames?.length) {
|
|
632
|
-
conditions.push({ Entity: { $in: filters.EntityNames } });
|
|
633
|
-
}
|
|
634
|
-
if (filters.SourceTypes?.length) {
|
|
635
|
-
conditions.push({ SourceType: { $in: filters.SourceTypes } });
|
|
636
|
-
}
|
|
637
|
-
if (filters.Tags?.length) {
|
|
638
|
-
conditions.push({ Tags: { $in: filters.Tags } });
|
|
639
|
-
}
|
|
640
|
-
if (conditions.length === 0) return undefined;
|
|
641
|
-
if (conditions.length === 1) return conditions[0];
|
|
642
|
-
return { $and: conditions };
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
/** Convert Pinecone matches to SearchKnowledgeResultItem[] using enriched metadata */
|
|
646
|
-
private convertMatches(
|
|
647
|
-
matches: Array<{ id: string; score?: number; metadata?: Record<string, unknown> }>,
|
|
648
|
-
indexName: string
|
|
649
|
-
): SearchKnowledgeResultItem[] {
|
|
650
|
-
return matches.map(match => {
|
|
651
|
-
const meta = match.metadata ?? {};
|
|
652
|
-
const entityName = (meta['Entity'] as string) ?? 'Unknown';
|
|
653
|
-
const recordID = (meta['RecordID'] as string) ?? match.id;
|
|
654
|
-
|
|
655
|
-
// Extract display fields from enriched metadata
|
|
656
|
-
const title = this.extractDisplayTitle(meta, entityName);
|
|
657
|
-
const snippet = this.extractDisplaySnippet(meta, indexName, match.score);
|
|
658
|
-
const entityIcon = (meta['EntityIcon'] as string) || undefined;
|
|
659
|
-
const updatedAt = meta['__mj_UpdatedAt'] ? new Date(meta['__mj_UpdatedAt'] as string) : new Date();
|
|
660
|
-
|
|
661
|
-
// Extract tags from vector metadata if stored during indexing
|
|
662
|
-
const metaTags = Array.isArray(meta['Tags']) ? (meta['Tags'] as string[]) : [];
|
|
663
|
-
|
|
664
|
-
return {
|
|
665
|
-
ID: match.id,
|
|
666
|
-
EntityName: entityName,
|
|
667
|
-
RecordID: recordID,
|
|
668
|
-
SourceType: 'vector',
|
|
669
|
-
Title: title,
|
|
670
|
-
Snippet: snippet,
|
|
671
|
-
Score: match.score ?? 0,
|
|
672
|
-
ScoreBreakdown: { Vector: match.score ?? 0 },
|
|
673
|
-
Tags: metaTags,
|
|
674
|
-
EntityIcon: entityIcon,
|
|
675
|
-
RecordName: title,
|
|
676
|
-
MatchedAt: updatedAt,
|
|
677
|
-
RawMetadata: JSON.stringify(meta),
|
|
678
|
-
};
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
/**
|
|
683
|
-
* Extract the best display title from vector metadata using entity field metadata.
|
|
684
|
-
* Combines all IsNameField fields in Sequence order (e.g., FirstName + LastName → "Sarah Chen").
|
|
685
|
-
* Falls back to heuristic field name matching when entity metadata isn't available.
|
|
686
|
-
*/
|
|
687
|
-
private extractDisplayTitle(meta: Record<string, unknown>, fallbackEntity: string): string {
|
|
688
|
-
// 1. Use entity metadata to find IsNameField fields and combine them
|
|
689
|
-
const entityName = meta['Entity'] as string | undefined;
|
|
690
|
-
if (entityName) {
|
|
691
|
-
const md = new Metadata();
|
|
692
|
-
const entityInfo = md.Entities.find(e => e.Name === entityName);
|
|
693
|
-
if (entityInfo) {
|
|
694
|
-
const nameFields = entityInfo.Fields
|
|
695
|
-
.filter(f => f.IsNameField)
|
|
696
|
-
.sort((a, b) => (a.Sequence ?? 9999) - (b.Sequence ?? 9999));
|
|
697
|
-
if (nameFields.length > 0) {
|
|
698
|
-
const parts = nameFields
|
|
699
|
-
.map(f => meta[f.Name])
|
|
700
|
-
.filter(v => v != null && String(v).trim() !== '')
|
|
701
|
-
.map(v => String(v));
|
|
702
|
-
if (parts.length > 0) return parts.join(' ');
|
|
703
|
-
}
|
|
704
|
-
// Single NameField fallback
|
|
705
|
-
if (entityInfo.NameField && meta[entityInfo.NameField.Name]) {
|
|
706
|
-
return String(meta[entityInfo.NameField.Name]);
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
// 2. Heuristic fallbacks for common field names
|
|
712
|
-
const heuristicFields = ['Name', 'Title', 'Subject', 'Label', 'DisplayName'];
|
|
713
|
-
for (const field of heuristicFields) {
|
|
714
|
-
if (meta[field] && typeof meta[field] === 'string') {
|
|
715
|
-
return meta[field] as string;
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
return `${fallbackEntity} Record`;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
/** Extract the best display snippet from vector metadata */
|
|
722
|
-
private extractDisplaySnippet(meta: Record<string, unknown>, indexName: string, score?: number): string {
|
|
723
|
-
// Check common description fields stored in metadata
|
|
724
|
-
const descFields = ['Description', 'Summary', 'Body', 'Content', 'Text', 'Notes'];
|
|
725
|
-
for (const field of descFields) {
|
|
726
|
-
if (meta[field] && typeof meta[field] === 'string') {
|
|
727
|
-
const val = meta[field] as string;
|
|
728
|
-
return val.length > 200 ? val.substring(0, 200) + '...' : val;
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
// Build a snippet from other metadata fields (exclude system fields)
|
|
733
|
-
const skipFields = new Set(['RecordID', 'Entity', 'TemplateID', 'EntityIcon', '__mj_UpdatedAt']);
|
|
734
|
-
const parts: string[] = [];
|
|
735
|
-
for (const [key, val] of Object.entries(meta)) {
|
|
736
|
-
if (skipFields.has(key) || val == null) continue;
|
|
737
|
-
const strVal = String(val);
|
|
738
|
-
if (strVal.length > 0 && strVal.length < 100) {
|
|
739
|
-
parts.push(`${key}: ${strVal}`);
|
|
740
|
-
}
|
|
741
|
-
if (parts.length >= 3) break;
|
|
742
|
-
}
|
|
743
|
-
if (parts.length > 0) return parts.join(' · ');
|
|
744
|
-
|
|
745
|
-
return `Matched from index "${indexName}" with score ${(score ?? 0).toFixed(4)}`;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
/** Remove duplicate results by RecordID, keeping the highest-scored entry */
|
|
749
|
-
private deduplicateResults(results: SearchKnowledgeResultItem[]): SearchKnowledgeResultItem[] {
|
|
750
|
-
const seen = new Map<string, SearchKnowledgeResultItem>();
|
|
751
|
-
for (const result of results) {
|
|
752
|
-
const key = `${result.EntityName}::${result.RecordID}`;
|
|
753
|
-
const existing = seen.get(key);
|
|
754
|
-
if (!existing || result.Score > existing.Score) {
|
|
755
|
-
seen.set(key, result);
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
return Array.from(seen.values()).sort((a, b) => b.Score - a.Score);
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
/**
|
|
762
|
-
* Remove Content Item results that originated from Entity-type content sources.
|
|
763
|
-
* These are redundant because the underlying entity records are already vectorized
|
|
764
|
-
* and searchable directly — showing both creates duplicate results.
|
|
765
|
-
*/
|
|
766
|
-
private async excludeEntitySourcedContentItems(
|
|
767
|
-
results: SearchKnowledgeResultItem[],
|
|
768
|
-
contextUser: UserInfo
|
|
769
|
-
): Promise<SearchKnowledgeResultItem[]> {
|
|
770
|
-
const contentItemResults = results.filter(r => r.EntityName === 'MJ: Content Items');
|
|
771
|
-
if (contentItemResults.length === 0) return results;
|
|
772
|
-
|
|
773
|
-
// Use cached engine data — no RunView needed
|
|
774
|
-
await KnowledgeHubMetadataEngine.Instance.Config(false, contextUser);
|
|
775
|
-
const engine = KnowledgeHubMetadataEngine.Instance;
|
|
776
|
-
|
|
777
|
-
// Find source type ID for "Entity"
|
|
778
|
-
const entitySourceType = engine.ContentSourceTypes.find(st => st.Name === 'Entity');
|
|
779
|
-
if (!entitySourceType) return results;
|
|
780
|
-
|
|
781
|
-
// Collect all ContentSource IDs that are entity-type
|
|
782
|
-
const entitySourceIDs = new Set(
|
|
783
|
-
engine.ContentSources
|
|
784
|
-
.filter(cs => UUIDsEqual(cs.ContentSourceTypeID, entitySourceType.ID))
|
|
785
|
-
.map(cs => cs.ID.toLowerCase())
|
|
786
|
-
);
|
|
787
|
-
if (entitySourceIDs.size === 0) return results;
|
|
788
|
-
|
|
789
|
-
// Filter using ContentSourceID from vector metadata — no DB lookup needed
|
|
790
|
-
return results.filter(r => {
|
|
791
|
-
if (r.EntityName !== 'MJ: Content Items') return true;
|
|
792
|
-
if (!r.RawMetadata) return true;
|
|
793
|
-
try {
|
|
794
|
-
const meta = JSON.parse(r.RawMetadata) as Record<string, string>;
|
|
795
|
-
const sourceID = meta.ContentSourceID;
|
|
796
|
-
if (!sourceID) return true;
|
|
797
|
-
return !entitySourceIDs.has(sourceID.toLowerCase());
|
|
798
|
-
} catch {
|
|
799
|
-
return true;
|
|
800
|
-
}
|
|
801
|
-
});
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
/** Enrich results with entity icons and record names */
|
|
805
|
-
private async enrichResults(results: SearchKnowledgeResultItem[], contextUser: UserInfo): Promise<void> {
|
|
806
|
-
const md = new Metadata();
|
|
807
|
-
|
|
808
|
-
// Add entity icons for results that don't already have them from metadata
|
|
809
|
-
for (const result of results) {
|
|
810
|
-
if (!result.EntityIcon) {
|
|
811
|
-
const entity = md.Entities.find(e => e.Name === result.EntityName);
|
|
812
|
-
if (entity?.Icon) {
|
|
813
|
-
result.EntityIcon = entity.Icon;
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
// Only resolve record names for results that don't already have them
|
|
819
|
-
// (vector results from enriched metadata should already have names)
|
|
820
|
-
const needsNameResolution = results.filter(r =>
|
|
821
|
-
!r.RecordName || r.RecordName === `${r.EntityName} Record`
|
|
822
|
-
);
|
|
823
|
-
|
|
824
|
-
if (needsNameResolution.length === 0) return;
|
|
825
|
-
|
|
826
|
-
try {
|
|
827
|
-
const indexedResults: { index: number; input: EntityRecordNameInput }[] = [];
|
|
828
|
-
for (const r of needsNameResolution) {
|
|
829
|
-
const resultIndex = results.indexOf(r);
|
|
830
|
-
const entity = md.Entities.find(e => e.Name === r.EntityName);
|
|
831
|
-
if (!entity) continue;
|
|
832
|
-
|
|
833
|
-
const key = new CompositeKey();
|
|
834
|
-
key.LoadFromURLSegment(entity, r.RecordID);
|
|
835
|
-
|
|
836
|
-
const input = new EntityRecordNameInput();
|
|
837
|
-
input.EntityName = r.EntityName;
|
|
838
|
-
input.CompositeKey = key;
|
|
839
|
-
indexedResults.push({ index: resultIndex, input });
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
if (indexedResults.length > 0) {
|
|
843
|
-
const names = await md.GetEntityRecordNames(
|
|
844
|
-
indexedResults.map(ir => ir.input),
|
|
845
|
-
contextUser
|
|
846
|
-
);
|
|
847
|
-
for (let i = 0; i < names.length; i++) {
|
|
848
|
-
if (names[i].RecordName) {
|
|
849
|
-
const resultIndex = indexedResults[i].index;
|
|
850
|
-
results[resultIndex].RecordName = names[i].RecordName;
|
|
851
|
-
results[resultIndex].Title = names[i].RecordName;
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
} catch (error) {
|
|
856
|
-
LogError(`SearchKnowledge: Error resolving record names: ${error}`);
|
|
857
|
-
// Non-fatal — results still usable without names
|
|
858
|
-
}
|
|
229
|
+
ScoreBreakdown: r.ScoreBreakdown as SearchScoreBreakdown,
|
|
230
|
+
Tags: r.Tags || [],
|
|
231
|
+
EntityIcon: r.EntityIcon,
|
|
232
|
+
RecordName: r.RecordName,
|
|
233
|
+
MatchedAt: r.MatchedAt,
|
|
234
|
+
RawMetadata: r.RawMetadata,
|
|
235
|
+
ProviderId: r.ProviderId,
|
|
236
|
+
ProviderLabel: r.ProviderLabel,
|
|
237
|
+
ProviderIcon: r.ProviderIcon,
|
|
238
|
+
})),
|
|
239
|
+
TotalCount: result.TotalCount,
|
|
240
|
+
ElapsedMs: result.ElapsedMs,
|
|
241
|
+
SourceCounts: {
|
|
242
|
+
Vector: result.SourceCounts.Vector,
|
|
243
|
+
FullText: result.SourceCounts.FullText,
|
|
244
|
+
Entity: result.SourceCounts.Entity,
|
|
245
|
+
Storage: result.SourceCounts.Storage,
|
|
246
|
+
},
|
|
247
|
+
Providers: (result.Providers || []).map((p: SearchProviderInfo) => ({
|
|
248
|
+
ID: p.ID,
|
|
249
|
+
Name: p.Name,
|
|
250
|
+
DisplayName: p.DisplayName,
|
|
251
|
+
Icon: p.Icon,
|
|
252
|
+
SourceType: p.SourceType,
|
|
253
|
+
Priority: p.Priority,
|
|
254
|
+
})),
|
|
255
|
+
ErrorMessage: result.ErrorMessage,
|
|
256
|
+
};
|
|
859
257
|
}
|
|
860
258
|
|
|
861
259
|
private errorResult(message: string, startTime: number): SearchKnowledgeResult {
|
|
@@ -864,8 +262,9 @@ export class SearchKnowledgeResolver extends ResolverBase {
|
|
|
864
262
|
Results: [],
|
|
865
263
|
TotalCount: 0,
|
|
866
264
|
ElapsedMs: Date.now() - startTime,
|
|
867
|
-
SourceCounts: { Vector: 0, FullText: 0, Entity: 0 },
|
|
868
|
-
|
|
265
|
+
SourceCounts: { Vector: 0, FullText: 0, Entity: 0, Storage: 0 },
|
|
266
|
+
Providers: [],
|
|
267
|
+
ErrorMessage: message
|
|
869
268
|
};
|
|
870
269
|
}
|
|
871
270
|
}
|