@learncard/network-brain-service 4.0.3 → 4.1.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 (176) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +76 -0
  3. package/config/config.dev.json +5 -0
  4. package/config/config.example.json +5 -0
  5. package/config/config.production.json +5 -0
  6. package/config/config.scouts.dev.json +5 -0
  7. package/config/config.scouts.production.json +5 -0
  8. package/dist/accesslayer/campaign/audience.d.ts +7 -0
  9. package/dist/accesslayer/campaign/audience.d.ts.map +1 -0
  10. package/dist/accesslayer/campaign/create.d.ts +3 -0
  11. package/dist/accesslayer/campaign/create.d.ts.map +1 -0
  12. package/dist/accesslayer/campaign/delete.d.ts +2 -0
  13. package/dist/accesslayer/campaign/delete.d.ts.map +1 -0
  14. package/dist/accesslayer/campaign/read.d.ts +6 -0
  15. package/dist/accesslayer/campaign/read.d.ts.map +1 -0
  16. package/dist/accesslayer/campaign/shared.d.ts +16 -0
  17. package/dist/accesslayer/campaign/shared.d.ts.map +1 -0
  18. package/dist/accesslayer/campaign/state.d.ts +4 -0
  19. package/dist/accesslayer/campaign/state.d.ts.map +1 -0
  20. package/dist/accesslayer/campaign/update.d.ts +3 -0
  21. package/dist/accesslayer/campaign/update.d.ts.map +1 -0
  22. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  23. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  24. package/dist/accesslayer/credential-share-previews.d.ts +5 -0
  25. package/dist/accesslayer/credential-share-previews.d.ts.map +1 -0
  26. package/dist/accesslayer/inbox-batch/schema.d.ts +4 -0
  27. package/dist/accesslayer/inbox-batch/schema.d.ts.map +1 -0
  28. package/dist/accesslayer/inbox-batch/schema.test.d.ts +2 -0
  29. package/dist/accesslayer/inbox-batch/schema.test.d.ts.map +1 -0
  30. package/dist/accesslayer/inbox-batch/store.d.ts +1 -1
  31. package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -1
  32. package/dist/accesslayer/profile/delete.d.ts.map +1 -1
  33. package/dist/accesslayer/skill/read.d.ts +12 -2
  34. package/dist/accesslayer/skill/read.d.ts.map +1 -1
  35. package/dist/accesslayer/skill/update.d.ts +11 -1
  36. package/dist/accesslayer/skill/update.d.ts.map +1 -1
  37. package/dist/app.d.ts +4 -0
  38. package/dist/app.d.ts.map +1 -1
  39. package/dist/cache/app-store.caches.d.ts +2 -1
  40. package/dist/cache/app-store.caches.d.ts.map +1 -1
  41. package/dist/config/environment.d.ts +268 -178
  42. package/dist/config/environment.d.ts.map +1 -1
  43. package/dist/config/skillEmbedding.d.ts +70 -0
  44. package/dist/config/skillEmbedding.d.ts.map +1 -0
  45. package/dist/config/skillEmbedding.neo4j.test.d.ts +2 -0
  46. package/dist/config/skillEmbedding.neo4j.test.d.ts.map +1 -0
  47. package/dist/config/stageConfig.d.ts +20 -0
  48. package/dist/config/stageConfig.d.ts.map +1 -1
  49. package/dist/constants/campaigns.d.ts +3 -0
  50. package/dist/constants/campaigns.d.ts.map +1 -0
  51. package/dist/didkit_wasm_bg.wasm +0 -0
  52. package/dist/helpers/bedrock-embedding.helpers.d.ts +40 -0
  53. package/dist/helpers/bedrock-embedding.helpers.d.ts.map +1 -0
  54. package/dist/helpers/bedrock-embedding.helpers.test.d.ts +2 -0
  55. package/dist/helpers/bedrock-embedding.helpers.test.d.ts.map +1 -0
  56. package/dist/helpers/bedrock-embedding.smoke.test.d.ts +2 -0
  57. package/dist/helpers/bedrock-embedding.smoke.test.d.ts.map +1 -0
  58. package/dist/helpers/boost.helpers.d.ts +1 -0
  59. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  60. package/dist/helpers/skill-embedding.helpers.d.ts +65 -7
  61. package/dist/helpers/skill-embedding.helpers.d.ts.map +1 -1
  62. package/dist/helpers/skill-embedding.helpers.test.d.ts +2 -0
  63. package/dist/helpers/skill-embedding.helpers.test.d.ts.map +1 -0
  64. package/dist/index.js +2 -2
  65. package/dist/models/Campaign.d.ts +10 -0
  66. package/dist/models/Campaign.d.ts.map +1 -0
  67. package/dist/models/CredentialSharePreview.d.ts +14 -0
  68. package/dist/models/CredentialSharePreview.d.ts.map +1 -0
  69. package/dist/models/Integration.d.ts +1 -1
  70. package/dist/models/Profile.d.ts.map +1 -1
  71. package/dist/models/campaign-constraints.d.ts +2 -0
  72. package/dist/models/campaign-constraints.d.ts.map +1 -0
  73. package/dist/models/index.d.ts +2 -0
  74. package/dist/models/index.d.ts.map +1 -1
  75. package/dist/publish-metafile.json +1 -1
  76. package/dist/routes/app-store.d.ts +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/routes/workflows.d.ts.map +1 -1
  103. package/dist/seed/seedSkillFrameworks.d.ts +1 -0
  104. package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
  105. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts +2 -0
  106. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts.map +1 -0
  107. package/dist/types/app-manifest.d.ts +2 -2
  108. package/dist/types/campaign.d.ts +19 -0
  109. package/dist/types/campaign.d.ts.map +1 -0
  110. package/dist/types/consentflowcontract.d.ts +1 -1
  111. package/dist/types/integration.d.ts +3 -3
  112. package/dist/types/profile.d.ts +9 -0
  113. package/dist/types/profile.d.ts.map +1 -1
  114. package/dist/types/skill.d.ts.map +1 -1
  115. package/esbuildPlugins.cjs +9 -6
  116. package/package.json +20 -17
  117. package/project.json +14 -0
  118. package/scripts/skill-embeddings-backfill.ts +161 -0
  119. package/serverless-local.yml +4 -0
  120. package/serverless.function-env.cjs +3 -0
  121. package/serverless.local.yml +4 -0
  122. package/serverless.yml +12 -1
  123. package/src/accesslayer/campaign/README.md +174 -0
  124. package/src/accesslayer/campaign/audience.ts +152 -0
  125. package/src/accesslayer/campaign/create.ts +47 -0
  126. package/src/accesslayer/campaign/delete.ts +18 -0
  127. package/src/accesslayer/campaign/read.ts +125 -0
  128. package/src/accesslayer/campaign/shared.ts +92 -0
  129. package/src/accesslayer/campaign/state.ts +80 -0
  130. package/src/accesslayer/campaign/update.ts +50 -0
  131. package/src/accesslayer/consentflowcontract/relationships/create.ts +3 -1
  132. package/src/accesslayer/consentflowcontract/relationships/update.ts +5 -1
  133. package/src/accesslayer/credential-share-previews.ts +76 -0
  134. package/src/accesslayer/inbox-batch/schema.test.ts +91 -0
  135. package/src/accesslayer/inbox-batch/schema.ts +54 -0
  136. package/src/accesslayer/inbox-batch/store.ts +2 -38
  137. package/src/accesslayer/profile/delete.ts +6 -0
  138. package/src/accesslayer/skill/read.ts +56 -16
  139. package/src/accesslayer/skill/update.ts +43 -1
  140. package/src/app.ts +9 -0
  141. package/src/config/environment.test.ts +208 -0
  142. package/src/config/environment.ts +166 -11
  143. package/src/config/skillEmbedding.neo4j.test.ts +138 -0
  144. package/src/config/skillEmbedding.ts +202 -0
  145. package/src/constants/campaigns.ts +9 -0
  146. package/src/helpers/bedrock-embedding.helpers.test.ts +208 -0
  147. package/src/helpers/bedrock-embedding.helpers.ts +243 -0
  148. package/src/helpers/bedrock-embedding.smoke.test.ts +53 -0
  149. package/src/helpers/boost-storage.helpers.test.ts +155 -1
  150. package/src/helpers/boost.helpers.ts +52 -10
  151. package/src/helpers/skill-embedding.helpers.test.ts +1044 -0
  152. package/src/helpers/skill-embedding.helpers.ts +611 -123
  153. package/src/models/Campaign.ts +32 -0
  154. package/src/models/CredentialSharePreview.ts +30 -0
  155. package/src/models/Profile.ts +1 -0
  156. package/src/models/campaign-constraints.ts +22 -0
  157. package/src/models/index.ts +29 -1
  158. package/src/routes/campaigns.ts +179 -0
  159. package/src/routes/credential-share-previews.ts +74 -0
  160. package/src/routes/profiles.ts +7 -0
  161. package/src/routes/skills-semantic-search.test.ts +168 -0
  162. package/src/routes/skills.ts +42 -6
  163. package/src/routes/workflows.ts +8 -1
  164. package/src/seed/seedSkillFrameworks.embeddings.test.ts +82 -0
  165. package/src/seed/seedSkillFrameworks.test.ts +6 -0
  166. package/src/seed/seedSkillFrameworks.ts +83 -18
  167. package/src/types/campaign.ts +17 -0
  168. package/src/types/skill.ts +6 -0
  169. package/test/campaigns.spec.ts +789 -0
  170. package/test/consentflow.spec.ts +101 -0
  171. package/test/credential-share-previews.spec.ts +97 -0
  172. package/test/inbox-batch.spec.ts +189 -1
  173. package/test/verification/campaigns.ts +49 -0
  174. package/vitest.campaigns.config.ts +21 -0
  175. package/vitest.config.ts +11 -0
  176. 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);
@@ -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
+ });
@@ -0,0 +1,243 @@
1
+ import { BedrockRuntimeClient, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime';
2
+
3
+ import { NOVA2_MAX_TEXT_CHARS } from '../config/skillEmbedding';
4
+
5
+ /**
6
+ * LC-2206 Bedrock adapter for Amazon Nova 2 Multimodal Embeddings.
7
+ *
8
+ * Style follows lca-api's Bedrock image adapter (LC-2205): one InvokeModel per request,
9
+ * SigV4 with the execution role's rotating credentials (never a long-term
10
+ * AWS_BEARER_TOKEN_BEDROCK key), no SDK retries, a hard timeout, and sanitised errors that
11
+ * never carry provider bodies, input text, credentials or vectors.
12
+ */
13
+
14
+ /** `index` embeds stored skills (GENERIC_INDEX); `query` embeds search text (TEXT_RETRIEVAL). */
15
+ export type EmbeddingPurpose = 'index' | 'query';
16
+
17
+ export type BedrockEmbeddingConfig = {
18
+ modelId: string;
19
+ region: string;
20
+ dimension: number;
21
+ timeoutMs: number;
22
+ };
23
+
24
+ export type SkillEmbeddingErrorCode =
25
+ | 'disabled'
26
+ | 'invalid_input'
27
+ | 'throttled'
28
+ | 'unavailable'
29
+ | 'access_denied'
30
+ | 'timeout'
31
+ | 'invalid_response'
32
+ | 'failed';
33
+
34
+ export class SkillEmbeddingError extends Error {
35
+ readonly code: SkillEmbeddingErrorCode;
36
+
37
+ readonly retryable: boolean;
38
+
39
+ readonly httpStatus?: number;
40
+
41
+ constructor(code: SkillEmbeddingErrorCode, httpStatus?: number) {
42
+ super(`Skill embedding failed (${code})`);
43
+ this.name = 'SkillEmbeddingError';
44
+ this.code = code;
45
+ this.retryable = code === 'throttled' || code === 'unavailable' || code === 'timeout';
46
+ this.httpStatus = httpStatus;
47
+ }
48
+ }
49
+
50
+ export const isSkillEmbeddingError = (error: unknown): error is SkillEmbeddingError =>
51
+ error instanceof SkillEmbeddingError;
52
+
53
+ /** Raw InvokeModel transport; injectable so unit tests never reach AWS. */
54
+ export type BedrockInvoke = (
55
+ request: { modelId: string; body: string },
56
+ options: { abortSignal: AbortSignal }
57
+ ) => Promise<Uint8Array>;
58
+
59
+ export const buildNovaEmbeddingRequest = (
60
+ text: string,
61
+ purpose: EmbeddingPurpose,
62
+ dimension: number
63
+ ) => ({
64
+ schemaVersion: 'nova-multimodal-embed-v1',
65
+ taskType: 'SINGLE_EMBEDDING',
66
+ singleEmbeddingParams: {
67
+ embeddingPurpose: purpose === 'index' ? 'GENERIC_INDEX' : 'TEXT_RETRIEVAL',
68
+ embeddingDimension: dimension,
69
+ // Fail instead of silently embedding a truncated text; callers bound input length.
70
+ text: { truncationMode: 'NONE', value: text },
71
+ },
72
+ });
73
+
74
+ /** L2-normalise so cosine and dot-product scores agree (AI Passport does the same). */
75
+ export const normalizeEmbedding = (vector: number[]): number[] => {
76
+ const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
77
+ if (!Number.isFinite(norm) || norm === 0) throw new SkillEmbeddingError('invalid_response');
78
+ return vector.map(value => value / norm);
79
+ };
80
+
81
+ /** Validate the documented Nova InvokeModel envelope: exactly one finite TEXT vector. */
82
+ export const parseNovaEmbeddingResponse = (body: Uint8Array, dimension: number): number[] => {
83
+ let parsed: unknown;
84
+
85
+ try {
86
+ parsed = JSON.parse(new TextDecoder().decode(body));
87
+ } catch {
88
+ throw new SkillEmbeddingError('invalid_response');
89
+ }
90
+
91
+ const rows = (parsed as { embeddings?: unknown } | null)?.embeddings;
92
+ if (!Array.isArray(rows) || rows.length !== 1)
93
+ throw new SkillEmbeddingError('invalid_response');
94
+
95
+ const row = rows[0] as {
96
+ embeddingType?: unknown;
97
+ embedding?: unknown;
98
+ truncatedCharLength?: unknown;
99
+ } | null;
100
+
101
+ if (
102
+ !row ||
103
+ row.embeddingType !== 'TEXT' ||
104
+ row.truncatedCharLength !== undefined ||
105
+ !Array.isArray(row.embedding) ||
106
+ row.embedding.length !== dimension ||
107
+ !row.embedding.every(value => typeof value === 'number' && Number.isFinite(value))
108
+ ) {
109
+ throw new SkillEmbeddingError('invalid_response');
110
+ }
111
+
112
+ return normalizeEmbedding(row.embedding as number[]);
113
+ };
114
+
115
+ const providerStatus = (error: unknown): number | undefined => {
116
+ if (!error || typeof error !== 'object') return undefined;
117
+ const { $metadata: metadata } = error as { $metadata?: { httpStatusCode?: unknown } };
118
+ return typeof metadata?.httpStatusCode === 'number' ? metadata.httpStatusCode : undefined;
119
+ };
120
+
121
+ const providerName = (error: unknown): string | undefined => {
122
+ if (!error || typeof error !== 'object') return undefined;
123
+ const { name } = error as { name?: unknown };
124
+ return typeof name === 'string' ? name : undefined;
125
+ };
126
+
127
+ /** Map an SDK failure to a code. The provider message and body are deliberately dropped. */
128
+ export const sanitizeBedrockError = (error: unknown): SkillEmbeddingError => {
129
+ if (error instanceof SkillEmbeddingError) return error;
130
+
131
+ const status = providerStatus(error);
132
+ const name = providerName(error);
133
+
134
+ if (name === 'AbortError' || name === 'TimeoutError') return new SkillEmbeddingError('timeout');
135
+ if (
136
+ status === 429 ||
137
+ name === 'ThrottlingException' ||
138
+ name === 'ServiceQuotaExceededException'
139
+ ) {
140
+ return new SkillEmbeddingError('throttled', 429);
141
+ }
142
+ if (status === 403 || name === 'AccessDeniedException') {
143
+ return new SkillEmbeddingError('access_denied', 403);
144
+ }
145
+ if (
146
+ status === 503 ||
147
+ status === 504 ||
148
+ name === 'ServiceUnavailableException' ||
149
+ name === 'ModelNotReadyException' ||
150
+ name === 'ModelTimeoutException'
151
+ ) {
152
+ return new SkillEmbeddingError('unavailable', status);
153
+ }
154
+ if (status === 400 || name === 'ValidationException') {
155
+ return new SkillEmbeddingError('invalid_input', 400);
156
+ }
157
+
158
+ return new SkillEmbeddingError('failed', status);
159
+ };
160
+
161
+ const clients = new Map<string, BedrockRuntimeClient>();
162
+
163
+ const getClient = (config: BedrockEmbeddingConfig): BedrockRuntimeClient => {
164
+ const key = `${config.region}:${config.timeoutMs}`;
165
+ let client = clients.get(key);
166
+
167
+ if (!client) {
168
+ client = new BedrockRuntimeClient({
169
+ region: config.region,
170
+ // One paid attempt per call; the backfill decides whether to retry later.
171
+ maxAttempts: 1,
172
+ // Execution-role SigV4 only, matching lca-api's credential policy.
173
+ authSchemePreference: ['sigv4'],
174
+ requestHandler: { requestTimeout: config.timeoutMs, connectionTimeout: 3_000 },
175
+ });
176
+ clients.set(key, client);
177
+ }
178
+
179
+ return client;
180
+ };
181
+
182
+ /** Test hook: drop cached SDK clients. */
183
+ export const resetBedrockEmbeddingClients = (): void => {
184
+ for (const client of clients.values()) client.destroy();
185
+ clients.clear();
186
+ };
187
+
188
+ export const createDefaultBedrockInvoke =
189
+ (config: BedrockEmbeddingConfig): BedrockInvoke =>
190
+ async (request, options) => {
191
+ const response = await getClient(config).send(
192
+ new InvokeModelCommand({
193
+ modelId: request.modelId,
194
+ contentType: 'application/json',
195
+ accept: 'application/json',
196
+ body: request.body,
197
+ }),
198
+ { abortSignal: options.abortSignal }
199
+ );
200
+
201
+ return response.body;
202
+ };
203
+
204
+ /** Embed one text with Nova 2. Exactly one InvokeModel call; throws SkillEmbeddingError. */
205
+ export const embedTextWithBedrock = async (
206
+ text: string,
207
+ purpose: EmbeddingPurpose,
208
+ config: BedrockEmbeddingConfig,
209
+ invoke: BedrockInvoke = createDefaultBedrockInvoke(config)
210
+ ): Promise<number[]> => {
211
+ const value = text.trim();
212
+ if (!value || value.length > NOVA2_MAX_TEXT_CHARS)
213
+ throw new SkillEmbeddingError('invalid_input');
214
+
215
+ const controller = new AbortController();
216
+ let timer: ReturnType<typeof setTimeout> | undefined;
217
+
218
+ try {
219
+ const body = await Promise.race([
220
+ invoke(
221
+ {
222
+ modelId: config.modelId,
223
+ body: JSON.stringify(
224
+ buildNovaEmbeddingRequest(value, purpose, config.dimension)
225
+ ),
226
+ },
227
+ { abortSignal: controller.signal }
228
+ ),
229
+ new Promise<never>((_, reject) => {
230
+ timer = setTimeout(() => {
231
+ controller.abort();
232
+ reject(new SkillEmbeddingError('timeout'));
233
+ }, config.timeoutMs);
234
+ }),
235
+ ]);
236
+
237
+ return parseNovaEmbeddingResponse(body, config.dimension);
238
+ } catch (error) {
239
+ throw sanitizeBedrockError(error);
240
+ } finally {
241
+ clearTimeout(timer);
242
+ }
243
+ };