@memberjunction/server 5.23.0 → 5.24.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 (33) hide show
  1. package/dist/config.d.ts.map +1 -1
  2. package/dist/config.js +11 -0
  3. package/dist/config.js.map +1 -1
  4. package/dist/generated/generated.d.ts +462 -0
  5. package/dist/generated/generated.d.ts.map +1 -1
  6. package/dist/generated/generated.js +16584 -13956
  7. package/dist/generated/generated.js.map +1 -1
  8. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  9. package/dist/generic/RunViewResolver.js.map +1 -1
  10. package/dist/resolvers/AutotagPipelineResolver.d.ts +10 -1
  11. package/dist/resolvers/AutotagPipelineResolver.d.ts.map +1 -1
  12. package/dist/resolvers/AutotagPipelineResolver.js +97 -13
  13. package/dist/resolvers/AutotagPipelineResolver.js.map +1 -1
  14. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  15. package/dist/resolvers/FetchEntityVectorsResolver.js +6 -2
  16. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  17. package/dist/resolvers/RunAIAgentResolver.d.ts +21 -0
  18. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  19. package/dist/resolvers/RunAIAgentResolver.js +73 -33
  20. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  21. package/dist/resolvers/SearchKnowledgeResolver.d.ts +42 -1
  22. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  23. package/dist/resolvers/SearchKnowledgeResolver.js +239 -13
  24. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  25. package/package.json +63 -63
  26. package/src/__tests__/search-knowledge-tags.test.ts +415 -0
  27. package/src/config.ts +11 -0
  28. package/src/generated/generated.ts +1807 -1
  29. package/src/generic/RunViewResolver.ts +1 -0
  30. package/src/resolvers/AutotagPipelineResolver.ts +99 -10
  31. package/src/resolvers/FetchEntityVectorsResolver.ts +6 -2
  32. package/src/resolvers/RunAIAgentResolver.ts +95 -56
  33. package/src/resolvers/SearchKnowledgeResolver.ts +270 -13
@@ -1,7 +1,7 @@
1
1
  import { Resolver, Mutation, Arg, Ctx, ObjectType, Field, Float, InputType } from 'type-graphql';
2
2
  import { AppContext } from '../types.js';
3
3
  import { LogError, LogStatus, Metadata, RunView, UserInfo, ComputeRRF, ScoredCandidate, EntityRecordNameInput, CompositeKey } from '@memberjunction/core';
4
- import { MJVectorIndexEntity, MJVectorDatabaseEntity } from '@memberjunction/core-entities';
4
+ import { MJVectorIndexEntity, MJVectorDatabaseEntity, KnowledgeHubMetadataEngine } from '@memberjunction/core-entities';
5
5
  import { ResolverBase } from '../generic/ResolverBase.js';
6
6
  import { AIEngine } from '@memberjunction/aiengine';
7
7
  import { BaseEmbeddings, GetAIAPIKey } from '@memberjunction/ai';
@@ -59,6 +59,10 @@ export class SearchKnowledgeResultItem {
59
59
 
60
60
  @Field()
61
61
  MatchedAt: Date;
62
+
63
+ /** Raw vector metadata as JSON string — contains all entity fields stored in the vector DB */
64
+ @Field({ nullable: true })
65
+ RawMetadata?: string;
62
66
  }
63
67
 
64
68
  @ObjectType()
@@ -149,11 +153,16 @@ export class SearchKnowledgeResolver extends ResolverBase {
149
153
  const fusedResults = this.fuseResults(vectorResults, fullTextResults, topK);
150
154
  const dedupedResults = this.deduplicateResults(fusedResults);
151
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
+
152
161
  // Apply minimum score threshold (post-RRF, so fusion can surface cross-source matches first)
153
162
  const scoreThreshold = minScore ?? 0;
154
163
  const filteredResults = scoreThreshold > 0
155
- ? dedupedResults.filter(r => r.Score >= scoreThreshold)
156
- : dedupedResults;
164
+ ? withoutEntityContentItems.filter(r => r.Score >= scoreThreshold)
165
+ : withoutEntityContentItems;
157
166
  LogStatus(`SearchKnowledge: Fuse + dedup + threshold≥${Math.round(scoreThreshold * 100)}% (${dedupedResults.length} → ${filteredResults.length} results): ${Date.now() - t0}ms`);
158
167
 
159
168
  // Enrich with entity icons and record names
@@ -341,6 +350,7 @@ export class SearchKnowledgeResolver extends ResolverBase {
341
350
  * Full-text search using the MJCore Metadata.FullTextSearch() method.
342
351
  * Delegates to the provider stack which uses database-native FTS
343
352
  * (SQL Server FREETEXT, PostgreSQL tsvector) via RunView + UserSearchString.
353
+ * When Tags filters are provided, post-filters results by checking tag associations.
344
354
  */
345
355
  private async searchFullText(
346
356
  query: string,
@@ -361,7 +371,7 @@ export class SearchKnowledgeResolver extends ResolverBase {
361
371
  return [];
362
372
  }
363
373
 
364
- return ftsResult.Results.map(r => ({
374
+ const results: SearchKnowledgeResultItem[] = ftsResult.Results.map(r => ({
365
375
  ID: `ft-${r.EntityName}-${r.RecordID}`,
366
376
  EntityName: r.EntityName,
367
377
  RecordID: r.RecordID,
@@ -370,15 +380,163 @@ export class SearchKnowledgeResolver extends ResolverBase {
370
380
  Snippet: r.Snippet,
371
381
  Score: r.Score,
372
382
  ScoreBreakdown: { FullText: r.Score },
373
- Tags: [],
383
+ Tags: [] as string[],
374
384
  MatchedAt: new Date()
375
385
  }));
386
+
387
+ // Batch-load tags from TaggedItems/ContentItemTags for FTS results
388
+ await this.enrichResultsWithTags(results, md, contextUser);
389
+
390
+ // Apply tag filter: keep only results that have at least one matching tag
391
+ if (filters?.Tags?.length) {
392
+ return this.filterResultsByTags(results, filters.Tags);
393
+ }
394
+
395
+ return results;
376
396
  } catch (error) {
377
397
  LogError(`SearchKnowledge: Full-text search error: ${error}`);
378
398
  return [];
379
399
  }
380
400
  }
381
401
 
402
+ /**
403
+ * Filter results to only include those with at least one tag matching the specified tag list.
404
+ * Uses case-insensitive comparison for robustness.
405
+ */
406
+ private filterResultsByTags(results: SearchKnowledgeResultItem[], requiredTags: string[]): SearchKnowledgeResultItem[] {
407
+ const lowerTags = new Set(requiredTags.map(t => t.toLowerCase()));
408
+ return results.filter(r =>
409
+ r.Tags.some(t => lowerTags.has(t.toLowerCase()))
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
+
382
540
  /**
383
541
  * Fuse vector and full-text results using Reciprocal Rank Fusion (RRF).
384
542
  * Deduplicates by RecordID, preferring the higher-scored source.
@@ -392,9 +550,14 @@ export class SearchKnowledgeResolver extends ResolverBase {
392
550
  return [];
393
551
  }
394
552
 
395
- // If only one source has results, just return those
396
- if (fullTextResults.length === 0) return vectorResults.slice(0, topK);
397
- if (vectorResults.length === 0) return fullTextResults.slice(0, topK);
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
+ }
398
561
 
399
562
  // Build scored candidate lists for RRF
400
563
  const vectorCandidates: ScoredCandidate[] = vectorResults.map((r, i) => ({
@@ -441,6 +604,26 @@ export class SearchKnowledgeResolver extends ResolverBase {
441
604
  });
442
605
  }
443
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
+
444
627
  /** Build Pinecone metadata filter from input */
445
628
  private buildPineconeFilter(filters?: SearchFiltersInput): object | undefined {
446
629
  if (!filters) return undefined;
@@ -475,6 +658,9 @@ export class SearchKnowledgeResolver extends ResolverBase {
475
658
  const entityIcon = (meta['EntityIcon'] as string) || undefined;
476
659
  const updatedAt = meta['__mj_UpdatedAt'] ? new Date(meta['__mj_UpdatedAt'] as string) : new Date();
477
660
 
661
+ // Extract tags from vector metadata if stored during indexing
662
+ const metaTags = Array.isArray(meta['Tags']) ? (meta['Tags'] as string[]) : [];
663
+
478
664
  return {
479
665
  ID: match.id,
480
666
  EntityName: entityName,
@@ -484,19 +670,47 @@ export class SearchKnowledgeResolver extends ResolverBase {
484
670
  Snippet: snippet,
485
671
  Score: match.score ?? 0,
486
672
  ScoreBreakdown: { Vector: match.score ?? 0 },
487
- Tags: [],
673
+ Tags: metaTags,
488
674
  EntityIcon: entityIcon,
489
675
  RecordName: title,
490
676
  MatchedAt: updatedAt,
677
+ RawMetadata: JSON.stringify(meta),
491
678
  };
492
679
  });
493
680
  }
494
681
 
495
- /** Extract the best display title from vector metadata */
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
+ */
496
687
  private extractDisplayTitle(meta: Record<string, unknown>, fallbackEntity: string): string {
497
- // Check common name fields stored in metadata
498
- const nameFields = ['Name', 'Title', 'Subject', 'Label', 'DisplayName'];
499
- for (const field of nameFields) {
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) {
500
714
  if (meta[field] && typeof meta[field] === 'string') {
501
715
  return meta[field] as string;
502
716
  }
@@ -544,6 +758,49 @@ export class SearchKnowledgeResolver extends ResolverBase {
544
758
  return Array.from(seen.values()).sort((a, b) => b.Score - a.Score);
545
759
  }
546
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
+
547
804
  /** Enrich results with entity icons and record names */
548
805
  private async enrichResults(results: SearchKnowledgeResultItem[], contextUser: UserInfo): Promise<void> {
549
806
  const md = new Metadata();