@learncard/network-brain-service 4.0.4 → 4.1.1

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 (175) hide show
  1. package/.env.example +14 -0
  2. package/AGENTS.md +16 -0
  3. package/CHANGELOG.md +54 -0
  4. package/config/config.dev.json +5 -0
  5. package/config/config.example.json +5 -0
  6. package/config/config.production.json +5 -0
  7. package/config/config.scouts.dev.json +5 -0
  8. package/config/config.scouts.production.json +5 -0
  9. package/dist/accesslayer/campaign/audience.d.ts +7 -0
  10. package/dist/accesslayer/campaign/audience.d.ts.map +1 -0
  11. package/dist/accesslayer/campaign/create.d.ts +3 -0
  12. package/dist/accesslayer/campaign/create.d.ts.map +1 -0
  13. package/dist/accesslayer/campaign/delete.d.ts +2 -0
  14. package/dist/accesslayer/campaign/delete.d.ts.map +1 -0
  15. package/dist/accesslayer/campaign/read.d.ts +6 -0
  16. package/dist/accesslayer/campaign/read.d.ts.map +1 -0
  17. package/dist/accesslayer/campaign/shared.d.ts +16 -0
  18. package/dist/accesslayer/campaign/shared.d.ts.map +1 -0
  19. package/dist/accesslayer/campaign/state.d.ts +4 -0
  20. package/dist/accesslayer/campaign/state.d.ts.map +1 -0
  21. package/dist/accesslayer/campaign/update.d.ts +3 -0
  22. package/dist/accesslayer/campaign/update.d.ts.map +1 -0
  23. package/dist/accesslayer/credential-share-previews.d.ts +5 -0
  24. package/dist/accesslayer/credential-share-previews.d.ts.map +1 -0
  25. package/dist/accesslayer/profile/delete.d.ts.map +1 -1
  26. package/dist/accesslayer/skill/read.d.ts +12 -2
  27. package/dist/accesslayer/skill/read.d.ts.map +1 -1
  28. package/dist/accesslayer/skill/update.d.ts +11 -1
  29. package/dist/accesslayer/skill/update.d.ts.map +1 -1
  30. package/dist/app.d.ts +4 -0
  31. package/dist/app.d.ts.map +1 -1
  32. package/dist/cache/app-store.caches.d.ts +2 -1
  33. package/dist/cache/app-store.caches.d.ts.map +1 -1
  34. package/dist/config/environment.d.ts +268 -178
  35. package/dist/config/environment.d.ts.map +1 -1
  36. package/dist/config/skillEmbedding.d.ts +70 -0
  37. package/dist/config/skillEmbedding.d.ts.map +1 -0
  38. package/dist/config/skillEmbedding.neo4j.test.d.ts +2 -0
  39. package/dist/config/skillEmbedding.neo4j.test.d.ts.map +1 -0
  40. package/dist/config/stageConfig.d.ts +20 -0
  41. package/dist/config/stageConfig.d.ts.map +1 -1
  42. package/dist/constants/campaigns.d.ts +3 -0
  43. package/dist/constants/campaigns.d.ts.map +1 -0
  44. package/dist/didkit_wasm_bg.wasm +0 -0
  45. package/dist/helpers/app-credential-delivery.helpers.d.ts +4 -0
  46. package/dist/helpers/app-credential-delivery.helpers.d.ts.map +1 -0
  47. package/dist/helpers/app-credential-delivery.helpers.test.d.ts +2 -0
  48. package/dist/helpers/app-credential-delivery.helpers.test.d.ts.map +1 -0
  49. package/dist/helpers/bedrock-embedding.helpers.d.ts +40 -0
  50. package/dist/helpers/bedrock-embedding.helpers.d.ts.map +1 -0
  51. package/dist/helpers/bedrock-embedding.helpers.test.d.ts +2 -0
  52. package/dist/helpers/bedrock-embedding.helpers.test.d.ts.map +1 -0
  53. package/dist/helpers/bedrock-embedding.smoke.test.d.ts +2 -0
  54. package/dist/helpers/bedrock-embedding.smoke.test.d.ts.map +1 -0
  55. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  56. package/dist/helpers/sentry.helpers.d.ts.map +1 -1
  57. package/dist/helpers/sentry.helpers.test.d.ts +2 -0
  58. package/dist/helpers/sentry.helpers.test.d.ts.map +1 -0
  59. package/dist/helpers/skill-embedding.helpers.d.ts +65 -7
  60. package/dist/helpers/skill-embedding.helpers.d.ts.map +1 -1
  61. package/dist/helpers/skill-embedding.helpers.test.d.ts +2 -0
  62. package/dist/helpers/skill-embedding.helpers.test.d.ts.map +1 -0
  63. package/dist/index.js +2 -2
  64. package/dist/models/Campaign.d.ts +10 -0
  65. package/dist/models/Campaign.d.ts.map +1 -0
  66. package/dist/models/CredentialSharePreview.d.ts +14 -0
  67. package/dist/models/CredentialSharePreview.d.ts.map +1 -0
  68. package/dist/models/Integration.d.ts +1 -1
  69. package/dist/models/Profile.d.ts.map +1 -1
  70. package/dist/models/campaign-constraints.d.ts +2 -0
  71. package/dist/models/campaign-constraints.d.ts.map +1 -0
  72. package/dist/models/index.d.ts +2 -0
  73. package/dist/models/index.d.ts.map +1 -1
  74. package/dist/publish-metafile.json +1 -1
  75. package/dist/routes/app-store.d.ts +1 -1
  76. package/dist/routes/app-store.d.ts.map +1 -1
  77. package/dist/routes/boosts.d.ts +24 -4
  78. package/dist/routes/boosts.d.ts.map +1 -1
  79. package/dist/routes/campaigns.d.ts +1411 -0
  80. package/dist/routes/campaigns.d.ts.map +1 -0
  81. package/dist/routes/contracts.d.ts +19 -6
  82. package/dist/routes/contracts.d.ts.map +1 -1
  83. package/dist/routes/credential-share-previews.d.ts +39 -0
  84. package/dist/routes/credential-share-previews.d.ts.map +1 -0
  85. package/dist/routes/credentials.d.ts +4 -1
  86. package/dist/routes/credentials.d.ts.map +1 -1
  87. package/dist/routes/inbox.d.ts +5 -5
  88. package/dist/routes/index.d.ts +4 -0
  89. package/dist/routes/index.d.ts.map +1 -1
  90. package/dist/routes/integrations.d.ts +3 -3
  91. package/dist/routes/profile-manager.d.ts +4 -0
  92. package/dist/routes/profile-manager.d.ts.map +1 -1
  93. package/dist/routes/profiles.d.ts +44 -0
  94. package/dist/routes/profiles.d.ts.map +1 -1
  95. package/dist/routes/skill-frameworks.d.ts +1 -0
  96. package/dist/routes/skill-frameworks.d.ts.map +1 -1
  97. package/dist/routes/skills-semantic-search.test.d.ts +2 -0
  98. package/dist/routes/skills-semantic-search.test.d.ts.map +1 -0
  99. package/dist/routes/skills.d.ts.map +1 -1
  100. package/dist/routes/test.d.ts +3 -1
  101. package/dist/routes/test.d.ts.map +1 -1
  102. package/dist/seed/seedSkillFrameworks.d.ts +1 -0
  103. package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
  104. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts +2 -0
  105. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts.map +1 -0
  106. package/dist/types/app-manifest.d.ts +2 -2
  107. package/dist/types/campaign.d.ts +19 -0
  108. package/dist/types/campaign.d.ts.map +1 -0
  109. package/dist/types/consentflowcontract.d.ts +1 -1
  110. package/dist/types/integration.d.ts +3 -3
  111. package/dist/types/profile.d.ts +9 -0
  112. package/dist/types/profile.d.ts.map +1 -1
  113. package/dist/types/skill.d.ts.map +1 -1
  114. package/package.json +21 -18
  115. package/project.json +15 -1
  116. package/scripts/skill-embeddings-backfill.ts +161 -0
  117. package/serverless-local.yml +4 -0
  118. package/serverless.function-env.cjs +3 -0
  119. package/serverless.local.yml +4 -0
  120. package/serverless.yml +12 -1
  121. package/src/accesslayer/campaign/README.md +174 -0
  122. package/src/accesslayer/campaign/audience.ts +152 -0
  123. package/src/accesslayer/campaign/create.ts +47 -0
  124. package/src/accesslayer/campaign/delete.ts +18 -0
  125. package/src/accesslayer/campaign/read.ts +125 -0
  126. package/src/accesslayer/campaign/shared.ts +92 -0
  127. package/src/accesslayer/campaign/state.ts +80 -0
  128. package/src/accesslayer/campaign/update.ts +50 -0
  129. package/src/accesslayer/credential-share-previews.ts +76 -0
  130. package/src/accesslayer/profile/delete.ts +6 -0
  131. package/src/accesslayer/skill/read.ts +56 -16
  132. package/src/accesslayer/skill/update.ts +43 -1
  133. package/src/app.ts +9 -0
  134. package/src/config/environment.test.ts +208 -0
  135. package/src/config/environment.ts +166 -11
  136. package/src/config/skillEmbedding.neo4j.test.ts +138 -0
  137. package/src/config/skillEmbedding.ts +202 -0
  138. package/src/constants/campaigns.ts +9 -0
  139. package/src/helpers/activity.helpers.ts +3 -3
  140. package/src/helpers/app-credential-delivery.helpers.test.ts +56 -0
  141. package/src/helpers/app-credential-delivery.helpers.ts +11 -0
  142. package/src/helpers/bedrock-embedding.helpers.test.ts +208 -0
  143. package/src/helpers/bedrock-embedding.helpers.ts +243 -0
  144. package/src/helpers/bedrock-embedding.smoke.test.ts +53 -0
  145. package/src/helpers/boost-storage.helpers.test.ts +63 -0
  146. package/src/helpers/boost.helpers.ts +23 -18
  147. package/src/helpers/sentry.helpers.test.ts +56 -0
  148. package/src/helpers/sentry.helpers.ts +4 -3
  149. package/src/helpers/skill-embedding.helpers.test.ts +1044 -0
  150. package/src/helpers/skill-embedding.helpers.ts +611 -123
  151. package/src/models/Campaign.ts +32 -0
  152. package/src/models/CredentialSharePreview.ts +30 -0
  153. package/src/models/Profile.ts +1 -0
  154. package/src/models/campaign-constraints.ts +22 -0
  155. package/src/models/index.ts +29 -1
  156. package/src/routes/app-store.ts +9 -16
  157. package/src/routes/campaigns.ts +179 -0
  158. package/src/routes/credential-share-previews.ts +74 -0
  159. package/src/routes/profiles.ts +7 -0
  160. package/src/routes/skills-semantic-search.test.ts +168 -0
  161. package/src/routes/skills.ts +42 -6
  162. package/src/seed/seedSkillFrameworks.embeddings.test.ts +82 -0
  163. package/src/seed/seedSkillFrameworks.test.ts +6 -0
  164. package/src/seed/seedSkillFrameworks.ts +83 -18
  165. package/src/types/campaign.ts +17 -0
  166. package/src/types/skill.ts +6 -0
  167. package/test/app-credential-delivery.neo4j.spec.ts +110 -0
  168. package/test/campaigns.spec.ts +789 -0
  169. package/test/credential-share-previews.spec.ts +97 -0
  170. package/test/verification/campaigns.ts +49 -0
  171. package/tsconfig.json +13 -13
  172. package/vitest.campaigns.config.ts +21 -0
  173. package/vitest.config.ts +12 -0
  174. package/vitest.inbox-e2e.config.ts +1 -0
  175. package/vitest.integration.config.ts +1 -0
@@ -0,0 +1,202 @@
1
+ /**
2
+ * LC-2206: skill-matching embeddings run on Amazon Nova 2 Multimodal Embeddings (Bedrock).
3
+ *
4
+ * Values mirror AI Passport's reviewed Nova identity (`nova2-text-1024-index-retrieval-*`):
5
+ * the in-Region base model ID (Nova 2 embeddings has no geo/global inference profile),
6
+ * us-east-1, 1024 dimensions, GENERIC_INDEX for stored skills, TEXT_RETRIEVAL for queries,
7
+ * NONE truncation and client-side unit normalisation.
8
+ *
9
+ * Every (model, dimension, scheme) generation gets its own Neo4j property, key property and
10
+ * vector index, so vectors from different models or dimensions are never mixed in one index.
11
+ */
12
+
13
+ export const NOVA2_EMBEDDING_MODEL_ID = 'amazon.nova-2-multimodal-embeddings-v1:0';
14
+ export const DEFAULT_SKILL_EMBEDDING_REGION = 'us-east-1';
15
+ export const DEFAULT_SKILL_EMBEDDING_DIMENSION = 1024;
16
+ export const DEFAULT_SKILL_EMBEDDING_TIMEOUT_MS = 15_000;
17
+
18
+ /** Output sizes the Nova 2 embeddings API accepts. */
19
+ export const NOVA2_EMBEDDING_DIMENSIONS = [256, 384, 1024, 3072] as const;
20
+
21
+ /** Nova 2 synchronous text input limit (characters). */
22
+ export const NOVA2_MAX_TEXT_CHARS = 8192;
23
+
24
+ /**
25
+ * Deployed (staging and production) runtimes may only use these values. Anything else fails
26
+ * environment parsing at startup. Widening this list is a reviewed code change.
27
+ */
28
+ export const DEPLOYED_SKILL_EMBEDDING_ALLOWLIST = {
29
+ modelIds: [NOVA2_EMBEDDING_MODEL_ID],
30
+ dimensions: [1024],
31
+ regions: ['us-east-1'],
32
+ } as const;
33
+
34
+ /**
35
+ * Bump when the embedded text, purposes, truncation or normalisation change. The version is
36
+ * part of every node key and cache key, so a bump re-embeds skills and invalidates cached
37
+ * query vectors without touching the stored vector schema.
38
+ */
39
+ export const SKILL_EMBEDDING_SCHEME_VERSION = 'index-retrieval-v1';
40
+
41
+ const NOVA2_MODEL_PATTERN = /^amazon\.nova-2-multimodal-embeddings-v(\d{1,3}):(\d{1,3})$/;
42
+
43
+ export type SkillEmbeddingGeneration = {
44
+ modelId: string;
45
+ dimension: number;
46
+ region: string;
47
+ /** Stable identity used in node keys and Redis cache keys. */
48
+ identity: string;
49
+ /** Neo4j property holding the vector for this generation. */
50
+ vectorProperty: string;
51
+ /** Neo4j property holding sha256(identity + text) for idempotent backfill. */
52
+ keyProperty: string;
53
+ /** Neo4j vector index over `vectorProperty`. */
54
+ indexName: string;
55
+ };
56
+
57
+ export const isNova2EmbeddingModelId = (modelId: string): boolean =>
58
+ NOVA2_MODEL_PATTERN.test(modelId);
59
+
60
+ export const isSupportedSkillEmbeddingDimension = (dimension: number): boolean =>
61
+ (NOVA2_EMBEDDING_DIMENSIONS as readonly number[]).includes(dimension);
62
+
63
+ export const resolveSkillEmbeddingGeneration = (input: {
64
+ modelId: string;
65
+ dimension: number;
66
+ region: string;
67
+ }): SkillEmbeddingGeneration => {
68
+ const match = NOVA2_MODEL_PATTERN.exec(input.modelId);
69
+
70
+ if (!match) throw new Error('Unsupported skill embedding model');
71
+ if (!isSupportedSkillEmbeddingDimension(input.dimension)) {
72
+ throw new Error('Unsupported skill embedding dimension');
73
+ }
74
+
75
+ // Only digits from the validated model ID and dimension reach Cypher identifiers.
76
+ const slug = `nova2_v${match[1]}_${match[2]}_d${input.dimension}`;
77
+
78
+ return {
79
+ modelId: input.modelId,
80
+ dimension: input.dimension,
81
+ region: input.region,
82
+ identity: `${input.modelId}|${input.dimension}|${SKILL_EMBEDDING_SCHEME_VERSION}`,
83
+ vectorProperty: `embedding_${slug}`,
84
+ keyProperty: `embedding_${slug}_key`,
85
+ indexName: `skill_embedding_${slug}_idx`,
86
+ };
87
+ };
88
+
89
+ /** Cypher for the generation's vector index; idempotent so it is safe on every startup. */
90
+ export const buildSkillEmbeddingVectorIndexQuery = (generation: SkillEmbeddingGeneration): string =>
91
+ `CREATE VECTOR INDEX ${generation.indexName} IF NOT EXISTS FOR (s:Skill) ON (s.${generation.vectorProperty}) OPTIONS {indexConfig: {\`vector.dimensions\`: ${generation.dimension}, \`vector.similarity_function\`: 'cosine'}}`;
92
+
93
+ /**
94
+ * Build the Cypher that writes one generation's vector and key (shared with the seed path).
95
+ *
96
+ * Compare-and-set on the source: the write lands only while the skill's statement and
97
+ * description still equal `$sourceStatement` / `$sourceDescription`, the values the vector was
98
+ * computed from (null is compared as ''). A backfill or live write that embedded text which has
99
+ * since been edited matches nothing and returns count 0, so it can never replace the newer
100
+ * text's vector and key. The throwaway property write takes the node's write lock first
101
+ * (Neo4j's documented lost-update idiom), so the source check and the SET are atomic against a
102
+ * concurrent skill update.
103
+ */
104
+ export const buildSkillEmbeddingUpdateQuery = (
105
+ generation: SkillEmbeddingGeneration,
106
+ scopedToFramework: boolean
107
+ ): string => {
108
+ const match = scopedToFramework
109
+ ? 'MATCH (f:SkillFramework {id: $frameworkId})-[:CONTAINS]->(s:Skill {id: $skillId})'
110
+ : 'MATCH (s:Skill {id: $skillId})';
111
+
112
+ // Property names come from resolveSkillEmbeddingGeneration ([a-z0-9_] only).
113
+ return `${match}
114
+ SET s._embeddingWriteLock = true
115
+ REMOVE s._embeddingWriteLock
116
+ WITH s
117
+ WHERE coalesce(s.statement, '') = $sourceStatement
118
+ AND coalesce(s.description, '') = $sourceDescription
119
+ SET s.${generation.vectorProperty} = $embedding,
120
+ s.${generation.keyProperty} = $embeddingKey
121
+ RETURN count(s) AS count`;
122
+ };
123
+
124
+ /** The source fields a Nova vector was computed from, as compared by the update query. */
125
+ export type SkillEmbeddingSource = {
126
+ statement: string;
127
+ description: string;
128
+ };
129
+
130
+ export const toSkillEmbeddingSource = (skill: {
131
+ statement?: string | null;
132
+ description?: string | null;
133
+ }): SkillEmbeddingSource => ({
134
+ statement: skill.statement ?? '',
135
+ description: skill.description ?? '',
136
+ });
137
+
138
+ /** Query parameters for buildSkillEmbeddingUpdateQuery. */
139
+ export const buildSkillEmbeddingUpdateParams = (update: {
140
+ skillId: string;
141
+ frameworkId?: string;
142
+ embedding: number[];
143
+ embeddingKey: string;
144
+ source: SkillEmbeddingSource;
145
+ }) => ({
146
+ frameworkId: update.frameworkId ?? null,
147
+ skillId: update.skillId,
148
+ embedding: update.embedding,
149
+ embeddingKey: update.embeddingKey,
150
+ sourceStatement: update.source.statement,
151
+ sourceDescription: update.source.description,
152
+ });
153
+
154
+ /** A vector index semantic search can query, with the vector size it accepts. */
155
+ export type SkillEmbeddingSearchIndex = {
156
+ /** `nova` = the configured Nova 2 generation; `legacy` = the pre-LC-2206 Google index. */
157
+ source: 'nova' | 'legacy';
158
+ indexName: string;
159
+ dimension: number;
160
+ };
161
+
162
+ /**
163
+ * Legacy Google gemini-embedding-001 (768-dim) property and index, exactly as on main before
164
+ * LC-2206. Semantic search keeps reading it (with Google query embeddings) until
165
+ * SKILL_EMBEDDING_BEDROCK_READ_ENABLED is flipped on, and skill writes keep it current while
166
+ * a Google key is configured, so flipping the flag back off is the rollback. A follow-up
167
+ * cleanup drops it after the rollback window.
168
+ */
169
+ export const LEGACY_GOOGLE_SKILL_EMBEDDING_INDEX = 'skill_embedding_vector_idx';
170
+ export const LEGACY_GOOGLE_SKILL_EMBEDDING_PROPERTY = 'embedding';
171
+ export const LEGACY_GOOGLE_SKILL_EMBEDDING_DIMENSION = 768;
172
+ export const LEGACY_GOOGLE_SKILL_EMBEDDING_MODEL = 'gemini-embedding-001';
173
+
174
+ export const LEGACY_GOOGLE_SKILL_EMBEDDING_SEARCH_INDEX: SkillEmbeddingSearchIndex = {
175
+ source: 'legacy',
176
+ indexName: LEGACY_GOOGLE_SKILL_EMBEDDING_INDEX,
177
+ dimension: LEGACY_GOOGLE_SKILL_EMBEDDING_DIMENSION,
178
+ };
179
+
180
+ /** Main's legacy index DDL, unchanged; idempotent so fresh local and e2e databases have it. */
181
+ export const LEGACY_GOOGLE_SKILL_EMBEDDING_VECTOR_INDEX_QUERY = `CREATE VECTOR INDEX ${LEGACY_GOOGLE_SKILL_EMBEDDING_INDEX} IF NOT EXISTS FOR (s:Skill) ON (s.${LEGACY_GOOGLE_SKILL_EMBEDDING_PROPERTY}) OPTIONS {indexConfig: {\`vector.dimensions\`: ${LEGACY_GOOGLE_SKILL_EMBEDDING_DIMENSION}, \`vector.similarity_function\`: 'cosine'}}`;
182
+
183
+ /** Search index for the configured Nova 2 generation. */
184
+ export const toSkillEmbeddingSearchIndex = (
185
+ generation: SkillEmbeddingGeneration
186
+ ): SkillEmbeddingSearchIndex => ({
187
+ source: 'nova',
188
+ indexName: generation.indexName,
189
+ dimension: generation.dimension,
190
+ });
191
+
192
+ /**
193
+ * Staging and production Lambdas do not set NODE_ENV, so a Lambda that is not running under
194
+ * serverless-offline also counts as deployed.
195
+ */
196
+ export const isDeployedRuntime = (environment: {
197
+ NODE_ENV?: string;
198
+ IS_OFFLINE?: boolean;
199
+ AWS_LAMBDA_FUNCTION_NAME?: string;
200
+ }): boolean =>
201
+ !environment.IS_OFFLINE &&
202
+ (environment.NODE_ENV === 'production' || Boolean(environment.AWS_LAMBDA_FUNCTION_NAME));
@@ -0,0 +1,9 @@
1
+ import { environment } from '@environment';
2
+
3
+ export const CAMPAIGN_ADMIN_PROFILE_IDS = (environment.CAMPAIGN_ADMIN_PROFILE_IDS ?? '')
4
+ .split(',')
5
+ .map(value => value.trim())
6
+ .filter(Boolean);
7
+
8
+ export const isCampaignAdmin = (profileId: string): boolean =>
9
+ CAMPAIGN_ADMIN_PROFILE_IDS.includes(profileId);
@@ -32,9 +32,9 @@ export const logCredentialSent = async (params: {
32
32
  listingId?: string;
33
33
  source: CredentialActivitySourceType;
34
34
  metadata?: Record<string, unknown>;
35
- // LC-1644 Phase 1d: callers can pre-generate activityId upfront so
36
- // logCredentialSent can run in parallel with dependent writes (e.g. sendBoost
37
- // which stores activityId on the SentCredential relationship).
35
+ // Callers can supply a correlation ID for related graph writes. Finish
36
+ // activity writes before delivery writes touch the same profile/listing;
37
+ // sharing an ID does not make parallel auto-commit transactions independent.
38
38
  activityId?: string;
39
39
  }): Promise<string> => {
40
40
  const isInboxSend = params.recipientType === 'email' || params.recipientType === 'phone';
@@ -0,0 +1,56 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const mocks = vi.hoisted(() => ({ activity: vi.fn(), send: vi.fn() }));
4
+ vi.mock('./activity.helpers', () => ({ logCredentialSent: mocks.activity }));
5
+ vi.mock('./boost.helpers', () => ({ sendBoost: mocks.send }));
6
+
7
+ import { sendAppCredentialWithActivity } from './app-credential-delivery.helpers';
8
+
9
+ const activity: Parameters<typeof sendAppCredentialWithActivity>[0] = {
10
+ actorProfileId: 'owner',
11
+ recipientType: 'profile',
12
+ recipientIdentifier: 'learner',
13
+ listingId: 'app',
14
+ source: 'appEvent',
15
+ };
16
+ const delivery = { listingId: 'app' } as Parameters<typeof sendAppCredentialWithActivity>[1];
17
+
18
+ describe('app credential delivery graph ordering', () => {
19
+ beforeEach(() => {
20
+ vi.resetAllMocks();
21
+ mocks.send.mockResolvedValue('credential:1');
22
+ });
23
+
24
+ it('waits for the activity write to commit and uses its ID before starting delivery', async () => {
25
+ let resolve!: (activityId: string) => void;
26
+ const promise = new Promise<string>(complete => {
27
+ resolve = complete;
28
+ });
29
+ mocks.activity.mockReturnValue(promise);
30
+ const result = sendAppCredentialWithActivity(activity, delivery);
31
+ expect(mocks.activity).toHaveBeenCalledExactlyOnceWith(activity);
32
+ expect(mocks.send).not.toHaveBeenCalled();
33
+ resolve('activity:1');
34
+ expect(await result).toBe('credential:1');
35
+ expect(mocks.send).toHaveBeenCalledExactlyOnceWith({
36
+ ...delivery,
37
+ activityId: 'activity:1',
38
+ });
39
+ });
40
+
41
+ it('does not deliver a credential after an activity write fails', async () => {
42
+ const error = new Error('activity write failed');
43
+ mocks.activity.mockRejectedValue(error);
44
+ await expect(sendAppCredentialWithActivity(activity, delivery)).rejects.toBe(error);
45
+ expect(mocks.send).not.toHaveBeenCalled();
46
+ });
47
+
48
+ it('propagates delivery failure without retrying activity or delivery', async () => {
49
+ const error = new Error('delivery failed');
50
+ mocks.activity.mockResolvedValue('activity:1');
51
+ mocks.send.mockRejectedValue(error);
52
+ await expect(sendAppCredentialWithActivity(activity, delivery)).rejects.toBe(error);
53
+ expect(mocks.activity).toHaveBeenCalledOnce();
54
+ expect(mocks.send).toHaveBeenCalledOnce();
55
+ });
56
+ });
@@ -0,0 +1,11 @@
1
+ import { logCredentialSent } from './activity.helpers';
2
+ import { sendBoost } from './boost.helpers';
3
+
4
+ /** Complete activity graph writes before delivery writes touch the same listing. */
5
+ export const sendAppCredentialWithActivity = async (
6
+ activity: Parameters<typeof logCredentialSent>[0],
7
+ delivery: Omit<Parameters<typeof sendBoost>[0], 'activityId'>
8
+ ): Promise<string> => {
9
+ const activityId = await logCredentialSent(activity);
10
+ return sendBoost({ ...delivery, activityId });
11
+ };
@@ -0,0 +1,208 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const sdk = vi.hoisted(() => ({
4
+ clientConfigs: [] as unknown[],
5
+ commands: [] as unknown[],
6
+ send: vi.fn(),
7
+ }));
8
+
9
+ vi.mock('@aws-sdk/client-bedrock-runtime', () => ({
10
+ BedrockRuntimeClient: vi.fn(function BedrockRuntimeClient(config: unknown) {
11
+ sdk.clientConfigs.push(config);
12
+ return { send: sdk.send, destroy: vi.fn() };
13
+ }),
14
+ InvokeModelCommand: vi.fn(function InvokeModelCommand(input: unknown) {
15
+ sdk.commands.push(input);
16
+ return { input };
17
+ }),
18
+ }));
19
+
20
+ import {
21
+ SkillEmbeddingError,
22
+ buildNovaEmbeddingRequest,
23
+ createDefaultBedrockInvoke,
24
+ embedTextWithBedrock,
25
+ normalizeEmbedding,
26
+ parseNovaEmbeddingResponse,
27
+ resetBedrockEmbeddingClients,
28
+ sanitizeBedrockError,
29
+ type BedrockEmbeddingConfig,
30
+ type BedrockInvoke,
31
+ } from './bedrock-embedding.helpers';
32
+
33
+ const config: BedrockEmbeddingConfig = {
34
+ modelId: 'amazon.nova-2-multimodal-embeddings-v1:0',
35
+ region: 'us-east-1',
36
+ dimension: 4,
37
+ timeoutMs: 1_000,
38
+ };
39
+
40
+ const encode = (value: unknown): Uint8Array => new TextEncoder().encode(JSON.stringify(value));
41
+
42
+ const novaBody = (embedding: unknown, extra: Record<string, unknown> = {}) =>
43
+ encode({ embeddings: [{ embeddingType: 'TEXT', embedding, ...extra }] });
44
+
45
+ describe('Nova 2 request shape', () => {
46
+ it('uses GENERIC_INDEX for stored skills and TEXT_RETRIEVAL for queries', () => {
47
+ expect(buildNovaEmbeddingRequest('Python', 'index', 1024)).toEqual({
48
+ schemaVersion: 'nova-multimodal-embed-v1',
49
+ taskType: 'SINGLE_EMBEDDING',
50
+ singleEmbeddingParams: {
51
+ embeddingPurpose: 'GENERIC_INDEX',
52
+ embeddingDimension: 1024,
53
+ text: { truncationMode: 'NONE', value: 'Python' },
54
+ },
55
+ });
56
+ expect(
57
+ buildNovaEmbeddingRequest('python skills', 'query', 1024).singleEmbeddingParams
58
+ .embeddingPurpose
59
+ ).toBe('TEXT_RETRIEVAL');
60
+ });
61
+ });
62
+
63
+ describe('Nova 2 response parsing', () => {
64
+ it('returns a unit-normalised vector', () => {
65
+ const vector = parseNovaEmbeddingResponse(novaBody([3, 0, 4, 0]), 4);
66
+ expect(vector).toEqual([0.6, 0, 0.8, 0]);
67
+ expect(Math.hypot(...vector)).toBeCloseTo(1, 10);
68
+ });
69
+
70
+ it.each([
71
+ ['wrong dimension', novaBody([1, 2, 3])],
72
+ ['truncated input', novaBody([1, 0, 0, 0], { truncatedCharLength: 10 })],
73
+ [
74
+ 'non-finite values',
75
+ encode({ embeddings: [{ embeddingType: 'TEXT', embedding: [1, null, 0, 0] }] }),
76
+ ],
77
+ ['zero vector', novaBody([0, 0, 0, 0])],
78
+ [
79
+ 'non-text embedding',
80
+ encode({ embeddings: [{ embeddingType: 'IMAGE', embedding: [1, 0, 0, 0] }] }),
81
+ ],
82
+ [
83
+ 'multiple embeddings',
84
+ encode({
85
+ embeddings: [
86
+ { embeddingType: 'TEXT', embedding: [1, 0, 0, 0] },
87
+ { embeddingType: 'TEXT', embedding: [0, 1, 0, 0] },
88
+ ],
89
+ }),
90
+ ],
91
+ ['invalid JSON', new TextEncoder().encode('{not json')],
92
+ ])('rejects %s', (_name, body) => {
93
+ expect(() => parseNovaEmbeddingResponse(body, 4)).toThrow(SkillEmbeddingError);
94
+ });
95
+
96
+ it('normalizeEmbedding rejects a zero norm', () => {
97
+ expect(() => normalizeEmbedding([0, 0])).toThrow(SkillEmbeddingError);
98
+ });
99
+ });
100
+
101
+ describe('error sanitisation', () => {
102
+ const providerError = (name: string, status: number) =>
103
+ Object.assign(new Error(`secret provider body for skill text "Advanced Parsing"`), {
104
+ name,
105
+ $metadata: { httpStatusCode: status },
106
+ });
107
+
108
+ it.each([
109
+ ['ThrottlingException', 429, 'throttled', true],
110
+ ['ServiceQuotaExceededException', 429, 'throttled', true],
111
+ ['AccessDeniedException', 403, 'access_denied', false],
112
+ ['ServiceUnavailableException', 503, 'unavailable', true],
113
+ ['ModelTimeoutException', 408, 'unavailable', true],
114
+ ['ValidationException', 400, 'invalid_input', false],
115
+ ['InternalServerException', 500, 'failed', false],
116
+ ])('maps %s to %s without leaking the provider message', (name, status, code, retryable) => {
117
+ const sanitized = sanitizeBedrockError(providerError(name, status));
118
+
119
+ expect(sanitized.code).toBe(code);
120
+ expect(sanitized.retryable).toBe(retryable);
121
+ expect(sanitized.message).not.toContain('secret');
122
+ expect(sanitized.message).not.toContain('Advanced Parsing');
123
+ expect((sanitized as Error & { cause?: unknown }).cause).toBeUndefined();
124
+ });
125
+ });
126
+
127
+ describe('embedTextWithBedrock', () => {
128
+ beforeEach(() => {
129
+ sdk.clientConfigs.length = 0;
130
+ sdk.commands.length = 0;
131
+ sdk.send.mockReset();
132
+ resetBedrockEmbeddingClients();
133
+ });
134
+
135
+ it('sends exactly one InvokeModel request with the configured model and purpose', async () => {
136
+ const invoke = vi.fn<BedrockInvoke>().mockResolvedValue(novaBody([1, 1, 1, 1]));
137
+
138
+ const vector = await embedTextWithBedrock(' Data analysis ', 'query', config, invoke);
139
+
140
+ expect(vector).toEqual([0.5, 0.5, 0.5, 0.5]);
141
+ expect(invoke).toHaveBeenCalledTimes(1);
142
+ const [request] = invoke.mock.calls[0]!;
143
+ expect(request.modelId).toBe(config.modelId);
144
+ expect(JSON.parse(request.body)).toMatchObject({
145
+ singleEmbeddingParams: {
146
+ embeddingPurpose: 'TEXT_RETRIEVAL',
147
+ embeddingDimension: 4,
148
+ text: { value: 'Data analysis', truncationMode: 'NONE' },
149
+ },
150
+ });
151
+ });
152
+
153
+ it('rejects empty and over-long input before calling Bedrock', async () => {
154
+ const invoke = vi.fn<BedrockInvoke>();
155
+
156
+ await expect(embedTextWithBedrock(' ', 'index', config, invoke)).rejects.toMatchObject({
157
+ code: 'invalid_input',
158
+ });
159
+ await expect(
160
+ embedTextWithBedrock('x'.repeat(8193), 'index', config, invoke)
161
+ ).rejects.toMatchObject({ code: 'invalid_input' });
162
+ expect(invoke).not.toHaveBeenCalled();
163
+ });
164
+
165
+ it('times out and aborts the in-flight request', async () => {
166
+ vi.useFakeTimers();
167
+ let signal: AbortSignal | undefined;
168
+ const invoke: BedrockInvoke = (_request, options) => {
169
+ signal = options.abortSignal;
170
+ return new Promise(() => undefined);
171
+ };
172
+
173
+ const pending = embedTextWithBedrock('slow', 'index', config, invoke);
174
+ const assertion = expect(pending).rejects.toMatchObject({ code: 'timeout' });
175
+ await vi.advanceTimersByTimeAsync(config.timeoutMs + 1);
176
+ await assertion;
177
+ expect(signal?.aborted).toBe(true);
178
+ vi.useRealTimers();
179
+ });
180
+
181
+ it('builds a SigV4-only SDK client with no retries and bounded timeouts', async () => {
182
+ sdk.send.mockResolvedValue({ body: novaBody([0, 2, 0, 0]) });
183
+
184
+ const vector = await embedTextWithBedrock(
185
+ 'Leadership',
186
+ 'index',
187
+ config,
188
+ createDefaultBedrockInvoke(config)
189
+ );
190
+
191
+ expect(vector).toEqual([0, 1, 0, 0]);
192
+ expect(sdk.clientConfigs).toEqual([
193
+ {
194
+ region: 'us-east-1',
195
+ maxAttempts: 1,
196
+ authSchemePreference: ['sigv4'],
197
+ requestHandler: { requestTimeout: 1_000, connectionTimeout: 3_000 },
198
+ },
199
+ ]);
200
+ expect(sdk.commands).toEqual([
201
+ expect.objectContaining({
202
+ modelId: 'amazon.nova-2-multimodal-embeddings-v1:0',
203
+ contentType: 'application/json',
204
+ accept: 'application/json',
205
+ }),
206
+ ]);
207
+ });
208
+ });