@learncard/network-brain-service 4.0.4 → 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 (154) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +41 -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/credential-share-previews.d.ts +5 -0
  23. package/dist/accesslayer/credential-share-previews.d.ts.map +1 -0
  24. package/dist/accesslayer/profile/delete.d.ts.map +1 -1
  25. package/dist/accesslayer/skill/read.d.ts +12 -2
  26. package/dist/accesslayer/skill/read.d.ts.map +1 -1
  27. package/dist/accesslayer/skill/update.d.ts +11 -1
  28. package/dist/accesslayer/skill/update.d.ts.map +1 -1
  29. package/dist/app.d.ts +4 -0
  30. package/dist/app.d.ts.map +1 -1
  31. package/dist/cache/app-store.caches.d.ts +2 -1
  32. package/dist/cache/app-store.caches.d.ts.map +1 -1
  33. package/dist/config/environment.d.ts +268 -178
  34. package/dist/config/environment.d.ts.map +1 -1
  35. package/dist/config/skillEmbedding.d.ts +70 -0
  36. package/dist/config/skillEmbedding.d.ts.map +1 -0
  37. package/dist/config/skillEmbedding.neo4j.test.d.ts +2 -0
  38. package/dist/config/skillEmbedding.neo4j.test.d.ts.map +1 -0
  39. package/dist/config/stageConfig.d.ts +20 -0
  40. package/dist/config/stageConfig.d.ts.map +1 -1
  41. package/dist/constants/campaigns.d.ts +3 -0
  42. package/dist/constants/campaigns.d.ts.map +1 -0
  43. package/dist/didkit_wasm_bg.wasm +0 -0
  44. package/dist/helpers/bedrock-embedding.helpers.d.ts +40 -0
  45. package/dist/helpers/bedrock-embedding.helpers.d.ts.map +1 -0
  46. package/dist/helpers/bedrock-embedding.helpers.test.d.ts +2 -0
  47. package/dist/helpers/bedrock-embedding.helpers.test.d.ts.map +1 -0
  48. package/dist/helpers/bedrock-embedding.smoke.test.d.ts +2 -0
  49. package/dist/helpers/bedrock-embedding.smoke.test.d.ts.map +1 -0
  50. package/dist/helpers/skill-embedding.helpers.d.ts +65 -7
  51. package/dist/helpers/skill-embedding.helpers.d.ts.map +1 -1
  52. package/dist/helpers/skill-embedding.helpers.test.d.ts +2 -0
  53. package/dist/helpers/skill-embedding.helpers.test.d.ts.map +1 -0
  54. package/dist/index.js +2 -2
  55. package/dist/models/Campaign.d.ts +10 -0
  56. package/dist/models/Campaign.d.ts.map +1 -0
  57. package/dist/models/CredentialSharePreview.d.ts +14 -0
  58. package/dist/models/CredentialSharePreview.d.ts.map +1 -0
  59. package/dist/models/Integration.d.ts +1 -1
  60. package/dist/models/Profile.d.ts.map +1 -1
  61. package/dist/models/campaign-constraints.d.ts +2 -0
  62. package/dist/models/campaign-constraints.d.ts.map +1 -0
  63. package/dist/models/index.d.ts +2 -0
  64. package/dist/models/index.d.ts.map +1 -1
  65. package/dist/publish-metafile.json +1 -1
  66. package/dist/routes/app-store.d.ts +1 -1
  67. package/dist/routes/boosts.d.ts +24 -4
  68. package/dist/routes/boosts.d.ts.map +1 -1
  69. package/dist/routes/campaigns.d.ts +1411 -0
  70. package/dist/routes/campaigns.d.ts.map +1 -0
  71. package/dist/routes/contracts.d.ts +19 -6
  72. package/dist/routes/contracts.d.ts.map +1 -1
  73. package/dist/routes/credential-share-previews.d.ts +39 -0
  74. package/dist/routes/credential-share-previews.d.ts.map +1 -0
  75. package/dist/routes/credentials.d.ts +4 -1
  76. package/dist/routes/credentials.d.ts.map +1 -1
  77. package/dist/routes/inbox.d.ts +5 -5
  78. package/dist/routes/index.d.ts +4 -0
  79. package/dist/routes/index.d.ts.map +1 -1
  80. package/dist/routes/integrations.d.ts +3 -3
  81. package/dist/routes/profile-manager.d.ts +4 -0
  82. package/dist/routes/profile-manager.d.ts.map +1 -1
  83. package/dist/routes/profiles.d.ts +44 -0
  84. package/dist/routes/profiles.d.ts.map +1 -1
  85. package/dist/routes/skill-frameworks.d.ts +1 -0
  86. package/dist/routes/skill-frameworks.d.ts.map +1 -1
  87. package/dist/routes/skills-semantic-search.test.d.ts +2 -0
  88. package/dist/routes/skills-semantic-search.test.d.ts.map +1 -0
  89. package/dist/routes/skills.d.ts.map +1 -1
  90. package/dist/routes/test.d.ts +3 -1
  91. package/dist/routes/test.d.ts.map +1 -1
  92. package/dist/seed/seedSkillFrameworks.d.ts +1 -0
  93. package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
  94. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts +2 -0
  95. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts.map +1 -0
  96. package/dist/types/app-manifest.d.ts +2 -2
  97. package/dist/types/campaign.d.ts +19 -0
  98. package/dist/types/campaign.d.ts.map +1 -0
  99. package/dist/types/consentflowcontract.d.ts +1 -1
  100. package/dist/types/integration.d.ts +3 -3
  101. package/dist/types/profile.d.ts +9 -0
  102. package/dist/types/profile.d.ts.map +1 -1
  103. package/dist/types/skill.d.ts.map +1 -1
  104. package/package.json +20 -17
  105. package/project.json +14 -0
  106. package/scripts/skill-embeddings-backfill.ts +161 -0
  107. package/serverless-local.yml +4 -0
  108. package/serverless.function-env.cjs +3 -0
  109. package/serverless.local.yml +4 -0
  110. package/serverless.yml +12 -1
  111. package/src/accesslayer/campaign/README.md +174 -0
  112. package/src/accesslayer/campaign/audience.ts +152 -0
  113. package/src/accesslayer/campaign/create.ts +47 -0
  114. package/src/accesslayer/campaign/delete.ts +18 -0
  115. package/src/accesslayer/campaign/read.ts +125 -0
  116. package/src/accesslayer/campaign/shared.ts +92 -0
  117. package/src/accesslayer/campaign/state.ts +80 -0
  118. package/src/accesslayer/campaign/update.ts +50 -0
  119. package/src/accesslayer/credential-share-previews.ts +76 -0
  120. package/src/accesslayer/profile/delete.ts +6 -0
  121. package/src/accesslayer/skill/read.ts +56 -16
  122. package/src/accesslayer/skill/update.ts +43 -1
  123. package/src/app.ts +9 -0
  124. package/src/config/environment.test.ts +208 -0
  125. package/src/config/environment.ts +166 -11
  126. package/src/config/skillEmbedding.neo4j.test.ts +138 -0
  127. package/src/config/skillEmbedding.ts +202 -0
  128. package/src/constants/campaigns.ts +9 -0
  129. package/src/helpers/bedrock-embedding.helpers.test.ts +208 -0
  130. package/src/helpers/bedrock-embedding.helpers.ts +243 -0
  131. package/src/helpers/bedrock-embedding.smoke.test.ts +53 -0
  132. package/src/helpers/skill-embedding.helpers.test.ts +1044 -0
  133. package/src/helpers/skill-embedding.helpers.ts +611 -123
  134. package/src/models/Campaign.ts +32 -0
  135. package/src/models/CredentialSharePreview.ts +30 -0
  136. package/src/models/Profile.ts +1 -0
  137. package/src/models/campaign-constraints.ts +22 -0
  138. package/src/models/index.ts +29 -1
  139. package/src/routes/campaigns.ts +179 -0
  140. package/src/routes/credential-share-previews.ts +74 -0
  141. package/src/routes/profiles.ts +7 -0
  142. package/src/routes/skills-semantic-search.test.ts +168 -0
  143. package/src/routes/skills.ts +42 -6
  144. package/src/seed/seedSkillFrameworks.embeddings.test.ts +82 -0
  145. package/src/seed/seedSkillFrameworks.test.ts +6 -0
  146. package/src/seed/seedSkillFrameworks.ts +83 -18
  147. package/src/types/campaign.ts +17 -0
  148. package/src/types/skill.ts +6 -0
  149. package/test/campaigns.spec.ts +789 -0
  150. package/test/credential-share-previews.spec.ts +97 -0
  151. package/test/verification/campaigns.ts +49 -0
  152. package/vitest.campaigns.config.ts +21 -0
  153. package/vitest.config.ts +10 -0
  154. package/vitest.integration.config.ts +1 -0
@@ -2,6 +2,13 @@ import { TRPCError } from '@trpc/server';
2
2
 
3
3
  import { neogma } from '@instance';
4
4
  import type { FlatSkillType } from 'types/skill';
5
+ import {
6
+ LEGACY_GOOGLE_SKILL_EMBEDDING_DIMENSION,
7
+ buildSkillEmbeddingUpdateParams,
8
+ buildSkillEmbeddingUpdateQuery,
9
+ type SkillEmbeddingGeneration,
10
+ type SkillEmbeddingSource,
11
+ } from '../../config/skillEmbedding';
5
12
 
6
13
  export type UpdateSkillInput = Partial<
7
14
  Omit<FlatSkillType, 'id' | 'frameworkId' | 'createdAt' | 'updatedAt' | 'parentId'>
@@ -154,11 +161,46 @@ export const deleteSkill = async (
154
161
  }
155
162
  };
156
163
 
157
- export const updateSkillEmbedding = async (
164
+ export type SkillEmbeddingUpdate = {
165
+ skillId: string;
166
+ frameworkId?: string;
167
+ embedding: number[];
168
+ /** sha256(identity + text); lets the backfill skip skills already embedded with this generation. */
169
+ embeddingKey: string;
170
+ generation: SkillEmbeddingGeneration;
171
+ /** Statement/description the vector was computed from; the write is skipped if they changed. */
172
+ source: SkillEmbeddingSource;
173
+ };
174
+
175
+ /** Returns false when the skill is gone or its source changed since it was embedded. */
176
+ export const updateSkillEmbedding = async (update: SkillEmbeddingUpdate): Promise<boolean> => {
177
+ const { frameworkId, embedding, generation } = update;
178
+
179
+ if (embedding.length !== generation.dimension) {
180
+ throw new Error('Skill embedding does not match the configured dimension');
181
+ }
182
+
183
+ const result = await neogma.queryRunner.run(
184
+ buildSkillEmbeddingUpdateQuery(generation, Boolean(frameworkId)),
185
+ buildSkillEmbeddingUpdateParams(update)
186
+ );
187
+
188
+ return Number(result.records[0]?.get('count') ?? 0) > 0;
189
+ };
190
+
191
+ /**
192
+ * LC-2206 legacy write: the pre-Nova `s.embedding` (Google gemini-embedding-001, 768-d) exactly
193
+ * as main writes it, so the legacy index stays current while semantic search still reads it.
194
+ */
195
+ export const updateLegacySkillEmbedding = async (
158
196
  skillId: string,
159
197
  embedding: number[],
160
198
  frameworkId?: string
161
199
  ): Promise<boolean> => {
200
+ if (embedding.length !== LEGACY_GOOGLE_SKILL_EMBEDDING_DIMENSION) {
201
+ throw new Error('Legacy skill embedding does not match the legacy dimension');
202
+ }
203
+
162
204
  if (frameworkId) {
163
205
  const result = await neogma.queryRunner.run(
164
206
  `MATCH (f:SkillFramework {id: $frameworkId})-[:CONTAINS]->(s:Skill {id: $skillId})
package/src/app.ts CHANGED
@@ -19,10 +19,15 @@ import { skillFrameworksRouter, SkillFrameworksRouter } from '@routes/skill-fram
19
19
  import { skillsRouter, SkillsRouter } from '@routes/skills';
20
20
  import { integrationsRouter, IntegrationsRouter } from '@routes/integrations';
21
21
  import { appStoreRouter, AppStoreRouter } from '@routes/app-store';
22
+ import { campaignsRouter, CampaignsRouter } from '@routes/campaigns';
22
23
  import { activityRouter, ActivityRouter } from '@routes/activity';
23
24
  import { federationRouter, FederationRouter } from '@routes/federation';
24
25
  import { shareLinksRouter, ShareLinksRouter } from '@routes/share-links';
25
26
  import { publicShareLinksRouter, PublicShareLinksRouter } from '@routes/public-share-links';
27
+ import {
28
+ credentialSharePreviewsRouter,
29
+ CredentialSharePreviewsRouter,
30
+ } from '@routes/credential-share-previews';
26
31
 
27
32
  /** For end-to-end testing, only available in test environment */
28
33
  import { testRouter, TestRouter } from '@routes/test';
@@ -52,10 +57,12 @@ export const appRouter = t.router<{
52
57
  skills: SkillsRouter;
53
58
  integrations: IntegrationsRouter;
54
59
  appStore: AppStoreRouter;
60
+ campaigns: CampaignsRouter;
55
61
  activity: ActivityRouter;
56
62
  federation: FederationRouter;
57
63
  shareLinks: ShareLinksRouter;
58
64
  publicShareLinks: PublicShareLinksRouter;
65
+ credentialSharePreviews: CredentialSharePreviewsRouter;
59
66
  test?: TestRouter;
60
67
  bench?: BenchRouter;
61
68
  }>({
@@ -78,10 +85,12 @@ export const appRouter = t.router<{
78
85
  skills: skillsRouter,
79
86
  integrations: integrationsRouter,
80
87
  appStore: appStoreRouter,
88
+ campaigns: campaignsRouter,
81
89
  activity: activityRouter,
82
90
  federation: federationRouter,
83
91
  shareLinks: shareLinksRouter,
84
92
  publicShareLinks: publicShareLinksRouter,
93
+ credentialSharePreviews: credentialSharePreviewsRouter,
85
94
  test: environment.IS_E2E_TEST ? testRouter : undefined,
86
95
  bench: environment.ENABLE_BENCH_ROUTES ? benchRouter : undefined,
87
96
  });
@@ -8,6 +8,10 @@ import {
8
8
  getCredentialRefreshRuntimeEnvironment,
9
9
  parseBrainServiceEnvironment,
10
10
  } from './environment';
11
+ import {
12
+ LEGACY_GOOGLE_SKILL_EMBEDDING_SEARCH_INDEX,
13
+ LEGACY_GOOGLE_SKILL_EMBEDDING_VECTOR_INDEX_QUERY,
14
+ } from './skillEmbedding';
11
15
 
12
16
  const REQUIRED_ENVIRONMENT = {
13
17
  SEED: 'a'.repeat(64),
@@ -103,3 +107,207 @@ describe('credential refresh configuration', () => {
103
107
  }
104
108
  );
105
109
  });
110
+
111
+ describe('LC-2206 skill embedding configuration', () => {
112
+ const DEPLOYED_LAMBDA = {
113
+ ...REQUIRED_ENVIRONMENT,
114
+ NODE_ENV: 'development',
115
+ DOMAIN_NAME: 'network.example.com',
116
+ AWS_LAMBDA_FUNCTION_NAME: 'lcn-brain-service-production-trpc',
117
+ };
118
+ const PRODUCTION_CONTAINER = {
119
+ ...REQUIRED_ENVIRONMENT,
120
+ NODE_ENV: 'production',
121
+ DOMAIN_NAME: 'network.example.com',
122
+ };
123
+
124
+ it('defaults to the reviewed Nova 2 model, us-east-1 and 1024 dimensions, disabled', () => {
125
+ expect(
126
+ parseBrainServiceEnvironment({ ...REQUIRED_ENVIRONMENT, NODE_ENV: 'test' })
127
+ ).toMatchObject({
128
+ SKILL_EMBEDDING_BEDROCK_ENABLED: false,
129
+ // Reads stay on the legacy Google index unless explicitly switched.
130
+ SKILL_EMBEDDING_BEDROCK_READ_ENABLED: false,
131
+ SKILL_EMBEDDING_BEDROCK_MODEL_ID: 'amazon.nova-2-multimodal-embeddings-v1:0',
132
+ SKILL_EMBEDDING_BEDROCK_REGION: 'us-east-1',
133
+ SKILL_EMBEDDING_DIMENSION: 1024,
134
+ SKILL_EMBEDDING_BEDROCK_TIMEOUT_MS: 15_000,
135
+ SKILL_EMBEDDING_CONCURRENCY: 1,
136
+ });
137
+ });
138
+
139
+ it.each([
140
+ ['deployed Lambda', DEPLOYED_LAMBDA],
141
+ ['production container', PRODUCTION_CONTAINER],
142
+ ])('accepts the allowlisted model and dimension in a %s', (_name, base) => {
143
+ expect(() =>
144
+ parseBrainServiceEnvironment({
145
+ ...base,
146
+ SKILL_EMBEDDING_BEDROCK_ENABLED: 'true',
147
+ SKILL_EMBEDDING_BEDROCK_MODEL_ID: 'amazon.nova-2-multimodal-embeddings-v1:0',
148
+ SKILL_EMBEDDING_DIMENSION: '1024',
149
+ })
150
+ ).not.toThrow();
151
+ });
152
+
153
+ it.each([
154
+ ['deployed Lambda', DEPLOYED_LAMBDA],
155
+ ['production container', PRODUCTION_CONTAINER],
156
+ ])('rejects any other model or dimension at startup in a %s', (_name, base) => {
157
+ // A different Nova 2 revision is valid locally but not allowlisted for deployment.
158
+ expect(() =>
159
+ parseBrainServiceEnvironment({
160
+ ...base,
161
+ SKILL_EMBEDDING_BEDROCK_MODEL_ID: 'amazon.nova-2-multimodal-embeddings-v1:1',
162
+ })
163
+ ).toThrow(/SKILL_EMBEDDING_BEDROCK_MODEL_ID/);
164
+
165
+ for (const dimension of ['256', '384', '3072']) {
166
+ expect(() =>
167
+ parseBrainServiceEnvironment({ ...base, SKILL_EMBEDDING_DIMENSION: dimension })
168
+ ).toThrow(/SKILL_EMBEDDING_DIMENSION/);
169
+ }
170
+
171
+ expect(() =>
172
+ parseBrainServiceEnvironment({ ...base, SKILL_EMBEDDING_BEDROCK_REGION: 'us-west-2' })
173
+ ).toThrow(/SKILL_EMBEDDING_BEDROCK_REGION/);
174
+ });
175
+
176
+ it('rejects the Google model as a Bedrock model ID and unsupported Nova dimensions everywhere', () => {
177
+ expect(() =>
178
+ parseBrainServiceEnvironment({
179
+ ...REQUIRED_ENVIRONMENT,
180
+ NODE_ENV: 'test',
181
+ SKILL_EMBEDDING_BEDROCK_MODEL_ID: 'gemini-embedding-001',
182
+ })
183
+ ).toThrow(/SKILL_EMBEDDING_BEDROCK_MODEL_ID/);
184
+
185
+ expect(() =>
186
+ parseBrainServiceEnvironment({
187
+ ...REQUIRED_ENVIRONMENT,
188
+ NODE_ENV: 'test',
189
+ SKILL_EMBEDDING_DIMENSION: '768',
190
+ })
191
+ ).toThrow(/SKILL_EMBEDDING_DIMENSION/);
192
+ });
193
+
194
+ it('allows other Nova 2 dimensions for local experiments', () => {
195
+ expect(
196
+ parseBrainServiceEnvironment({
197
+ ...REQUIRED_ENVIRONMENT,
198
+ NODE_ENV: 'development',
199
+ IS_OFFLINE: 'true',
200
+ SKILL_EMBEDDING_DIMENSION: '384',
201
+ }).SKILL_EMBEDDING_DIMENSION
202
+ ).toBe(384);
203
+ });
204
+
205
+ it('refuses Bedrock API keys and endpoint overrides in deployed runtimes', () => {
206
+ const enabled = { ...DEPLOYED_LAMBDA, SKILL_EMBEDDING_BEDROCK_ENABLED: 'true' };
207
+
208
+ expect(() =>
209
+ parseBrainServiceEnvironment({ ...enabled, AWS_BEARER_TOKEN_BEDROCK: 'long-term-key' })
210
+ ).toThrow(/AWS_BEARER_TOKEN_BEDROCK/);
211
+ expect(() =>
212
+ parseBrainServiceEnvironment({
213
+ ...enabled,
214
+ AWS_ENDPOINT_URL_BEDROCK_RUNTIME: 'https://proxy.example.com',
215
+ })
216
+ ).toThrow(/AWS_ENDPOINT_URL_BEDROCK_RUNTIME/);
217
+ });
218
+
219
+ it('read flag defaults off even when Nova writes are enabled in a deployed runtime', () => {
220
+ const parsed = parseBrainServiceEnvironment({
221
+ ...DEPLOYED_LAMBDA,
222
+ SKILL_EMBEDDING_BEDROCK_ENABLED: 'true',
223
+ });
224
+
225
+ expect(parsed.SKILL_EMBEDDING_BEDROCK_ENABLED).toBe(true);
226
+ expect(parsed.SKILL_EMBEDDING_BEDROCK_READ_ENABLED).toBe(false);
227
+ });
228
+
229
+ it('accepts the read flag with Nova enabled and rejects it with Nova disabled', () => {
230
+ expect(
231
+ parseBrainServiceEnvironment({
232
+ ...DEPLOYED_LAMBDA,
233
+ SKILL_EMBEDDING_BEDROCK_ENABLED: 'true',
234
+ SKILL_EMBEDDING_BEDROCK_READ_ENABLED: 'true',
235
+ }).SKILL_EMBEDDING_BEDROCK_READ_ENABLED
236
+ ).toBe(true);
237
+
238
+ expect(() =>
239
+ parseBrainServiceEnvironment({
240
+ ...REQUIRED_ENVIRONMENT,
241
+ NODE_ENV: 'test',
242
+ SKILL_EMBEDDING_BEDROCK_READ_ENABLED: 'true',
243
+ })
244
+ ).toThrow(/SKILL_EMBEDDING_BEDROCK_READ_ENABLED=true/);
245
+ });
246
+
247
+ it('keeps the legacy Google settings for the flag-off read path', () => {
248
+ expect(
249
+ parseBrainServiceEnvironment({
250
+ ...DEPLOYED_LAMBDA,
251
+ SKILL_EMBEDDING_BEDROCK_ENABLED: 'true',
252
+ SKILL_EMBEDDING_GOOGLE_API_KEY: 'legacy-google-key',
253
+ SKILL_EMBEDDING_GOOGLE_MODEL: 'gemini-embedding-001',
254
+ })
255
+ ).toMatchObject({
256
+ SKILL_EMBEDDING_GOOGLE_API_KEY: 'legacy-google-key',
257
+ SKILL_EMBEDDING_GOOGLE_MODEL: 'gemini-embedding-001',
258
+ });
259
+ });
260
+
261
+ it("keeps main's legacy 768-d index definition for the flag-off read path", () => {
262
+ expect(LEGACY_GOOGLE_SKILL_EMBEDDING_VECTOR_INDEX_QUERY).toBe(
263
+ "CREATE VECTOR INDEX skill_embedding_vector_idx IF NOT EXISTS FOR (s:Skill) ON (s.embedding) OPTIONS {indexConfig: {`vector.dimensions`: 768, `vector.similarity_function`: 'cosine'}}"
264
+ );
265
+ expect(LEGACY_GOOGLE_SKILL_EMBEDDING_SEARCH_INDEX).toEqual({
266
+ source: 'legacy',
267
+ indexName: 'skill_embedding_vector_idx',
268
+ dimension: 768,
269
+ });
270
+ });
271
+
272
+ it('requires Bedrock embeddings to be enabled for the startup backfill', () => {
273
+ expect(() =>
274
+ parseBrainServiceEnvironment({
275
+ ...REQUIRED_ENVIRONMENT,
276
+ NODE_ENV: 'test',
277
+ SKILL_EMBEDDING_BACKFILL_ON_STARTUP: 'true',
278
+ })
279
+ ).toThrow(/SKILL_EMBEDDING_BEDROCK_ENABLED/);
280
+ });
281
+
282
+ it.each([
283
+ 'config.dev.json',
284
+ 'config.production.json',
285
+ 'config.scouts.dev.json',
286
+ 'config.scouts.production.json',
287
+ ])(
288
+ 'checked-in %s enables Nova writes, keeps reads on legacy, allowlisted values only',
289
+ stageFile => {
290
+ const configPath = fileURLToPath(new URL(`../../config/${stageFile}`, import.meta.url));
291
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
292
+
293
+ expect(config).toMatchObject({
294
+ SKILL_EMBEDDING_BEDROCK_ENABLED: 'true',
295
+ SKILL_EMBEDDING_BEDROCK_MODEL_ID: 'amazon.nova-2-multimodal-embeddings-v1:0',
296
+ SKILL_EMBEDDING_BEDROCK_REGION: 'us-east-1',
297
+ SKILL_EMBEDDING_DIMENSION: '1024',
298
+ SKILL_EMBEDDING_BEDROCK_READ_ENABLED: 'false',
299
+ SKILL_EMBEDDING_GOOGLE_MODEL: 'gemini-embedding-001',
300
+ });
301
+ // Only the non-secret model name is checked in; the key comes from the secrets bundle.
302
+ expect(config).not.toHaveProperty('SKILL_EMBEDDING_GOOGLE_API_KEY');
303
+ expect(() =>
304
+ parseBrainServiceEnvironment({
305
+ ...DEPLOYED_LAMBDA,
306
+ ...config,
307
+ // Credentials come from the runtime-secrets bundle in Lambda.
308
+ CREDENTIAL_REFRESH_DIGEST_SECRET: 'bundle-secret',
309
+ })
310
+ ).not.toThrow();
311
+ }
312
+ );
313
+ });
@@ -1,6 +1,16 @@
1
1
  import 'dotenv/config';
2
2
 
3
3
  import { z } from 'zod';
4
+ import {
5
+ DEFAULT_SKILL_EMBEDDING_DIMENSION,
6
+ DEFAULT_SKILL_EMBEDDING_REGION,
7
+ DEFAULT_SKILL_EMBEDDING_TIMEOUT_MS,
8
+ DEPLOYED_SKILL_EMBEDDING_ALLOWLIST,
9
+ NOVA2_EMBEDDING_MODEL_ID,
10
+ isDeployedRuntime,
11
+ isNova2EmbeddingModelId,
12
+ isSupportedSkillEmbeddingDimension,
13
+ } from './skillEmbedding';
4
14
  import {
5
15
  environmentPort,
6
16
  optionalEnvironmentBoolean,
@@ -11,6 +21,11 @@ import {
11
21
  requiredEnvironmentString,
12
22
  } from '@learncard/helpers';
13
23
 
24
+ const optionalPositiveInteger = z.preprocess(
25
+ value => (value === '' || value === undefined ? undefined : value),
26
+ z.coerce.number().int().positive().optional()
27
+ );
28
+
14
29
  const credentialRefreshNotificationWindowHours = z.preprocess(
15
30
  value => (value === '' || value === undefined ? undefined : value),
16
31
  z.coerce.number().finite().positive().default(24)
@@ -29,6 +44,7 @@ export const brainServiceEnvironmentShape = {
29
44
  LOGIN_PROVIDER_DID: optionalEnvironmentString,
30
45
  SIGNING_AUTHORITY_ENDPOINT: optionalEnvironmentUrl,
31
46
  APP_STORE_ADMIN_PROFILE_IDS: optionalEnvironmentString,
47
+ CAMPAIGN_ADMIN_PROFILE_IDS: optionalEnvironmentString,
32
48
  SKILL_FRAMEWORK_ADMIN_PROFILE_IDS: optionalEnvironmentString,
33
49
  SKILL_FRAMEWORK_SEED_OWNER_PROFILE_ID: optionalEnvironmentString,
34
50
  TRUSTED_BRAIN_SERVICES: optionalEnvironmentString,
@@ -68,8 +84,25 @@ export const brainServiceEnvironmentShape = {
68
84
  GIT_SHA: optionalEnvironmentString,
69
85
  SENTRY_DSN: optionalEnvironmentUrl,
70
86
  SENTRY_ENV: optionalEnvironmentString,
87
+ // LC-2206 legacy path: Google gemini-embedding-001 (768-d). Semantic search reads the legacy
88
+ // index with Google query embeddings while SKILL_EMBEDDING_BEDROCK_READ_ENABLED is off, and
89
+ // skill writes keep the legacy vectors current. Removed by the post-rollback cleanup.
71
90
  SKILL_EMBEDDING_GOOGLE_API_KEY: optionalEnvironmentString,
72
91
  SKILL_EMBEDDING_GOOGLE_MODEL: optionalEnvironmentString,
92
+ // LC-2206: Amazon Nova 2 embeddings on Bedrock, SigV4 with the execution role only.
93
+ // ENABLED turns on Nova writes (new/updated skills, backfill); READ_ENABLED switches semantic
94
+ // search to the Nova index. Reads default off; flipping READ_ENABLED back off is the rollback.
95
+ SKILL_EMBEDDING_BEDROCK_ENABLED: optionalEnvironmentBoolean.default(false),
96
+ SKILL_EMBEDDING_BEDROCK_READ_ENABLED: optionalEnvironmentBoolean.default(false),
97
+ SKILL_EMBEDDING_BEDROCK_MODEL_ID: optionalEnvironmentString,
98
+ SKILL_EMBEDDING_BEDROCK_REGION: optionalEnvironmentString,
99
+ SKILL_EMBEDDING_DIMENSION: optionalPositiveInteger,
100
+ SKILL_EMBEDDING_BEDROCK_TIMEOUT_MS: optionalPositiveInteger,
101
+ SKILL_EMBEDDING_CONCURRENCY: optionalPositiveInteger,
102
+ // Read only to refuse ambient overrides in deployed runtimes; never used to authenticate.
103
+ AWS_ENDPOINT_URL: optionalEnvironmentString,
104
+ AWS_ENDPOINT_URL_BEDROCK_RUNTIME: optionalEnvironmentString,
105
+ AWS_BEARER_TOKEN_BEDROCK: optionalEnvironmentString,
73
106
  SKILL_EMBEDDING_CACHE_TTL_SECONDS: optionalEnvironmentPort,
74
107
  SKILL_EMBEDDING_BATCH_SIZE: optionalEnvironmentPort,
75
108
  SKILL_EMBEDDING_BACKFILL_BATCH_SIZE: optionalEnvironmentPort,
@@ -100,14 +133,147 @@ export const brainServiceEnvironmentShape = {
100
133
  BITSTRING_STATUS_LIST_ALLOW_SMALL: optionalEnvironmentBoolean.default(false),
101
134
  } satisfies z.ZodRawShape;
102
135
 
136
+ type SkillEmbeddingEnvironment = {
137
+ NODE_ENV: string;
138
+ IS_OFFLINE: boolean;
139
+ AWS_LAMBDA_FUNCTION_NAME?: string;
140
+ SKILL_EMBEDDING_BEDROCK_ENABLED: boolean;
141
+ SKILL_EMBEDDING_BEDROCK_READ_ENABLED: boolean;
142
+ SKILL_EMBEDDING_BACKFILL_ON_STARTUP: boolean;
143
+ SKILL_EMBEDDING_BEDROCK_MODEL_ID: string;
144
+ SKILL_EMBEDDING_BEDROCK_REGION: string;
145
+ SKILL_EMBEDDING_DIMENSION: number;
146
+ AWS_ENDPOINT_URL?: string;
147
+ AWS_ENDPOINT_URL_BEDROCK_RUNTIME?: string;
148
+ AWS_BEARER_TOKEN_BEDROCK?: string;
149
+ };
150
+
151
+ /**
152
+ * LC-2206 startup guard. Any model or dimension that Nova 2 cannot serve is always rejected;
153
+ * deployed runtimes (staging and production) additionally only accept the reviewed allowlist,
154
+ * and refuse endpoint overrides and long-term Bedrock API keys.
155
+ */
156
+ export const refineSkillEmbeddingEnvironment = (
157
+ environment: SkillEmbeddingEnvironment,
158
+ context: z.RefinementCtx
159
+ ): void => {
160
+ const modelId = environment.SKILL_EMBEDDING_BEDROCK_MODEL_ID;
161
+ const dimension = environment.SKILL_EMBEDDING_DIMENSION;
162
+ const region = environment.SKILL_EMBEDDING_BEDROCK_REGION;
163
+
164
+ if (!isNova2EmbeddingModelId(modelId)) {
165
+ context.addIssue({
166
+ code: 'custom',
167
+ path: ['SKILL_EMBEDDING_BEDROCK_MODEL_ID'],
168
+ message: 'Must be an Amazon Nova 2 multimodal embeddings model ID',
169
+ });
170
+ }
171
+
172
+ if (!isSupportedSkillEmbeddingDimension(dimension)) {
173
+ context.addIssue({
174
+ code: 'custom',
175
+ path: ['SKILL_EMBEDDING_DIMENSION'],
176
+ message: 'Must be one of 256, 384, 1024 or 3072',
177
+ });
178
+ }
179
+
180
+ if (
181
+ environment.SKILL_EMBEDDING_BACKFILL_ON_STARTUP &&
182
+ !environment.SKILL_EMBEDDING_BEDROCK_ENABLED
183
+ ) {
184
+ context.addIssue({
185
+ code: 'custom',
186
+ path: ['SKILL_EMBEDDING_BEDROCK_ENABLED'],
187
+ message: 'Required when SKILL_EMBEDDING_BACKFILL_ON_STARTUP=true',
188
+ });
189
+ }
190
+
191
+ // Nova query embeddings need Bedrock; reading the Nova index without it would fail every search.
192
+ if (
193
+ environment.SKILL_EMBEDDING_BEDROCK_READ_ENABLED &&
194
+ !environment.SKILL_EMBEDDING_BEDROCK_ENABLED
195
+ ) {
196
+ context.addIssue({
197
+ code: 'custom',
198
+ path: ['SKILL_EMBEDDING_BEDROCK_ENABLED'],
199
+ message: 'Required when SKILL_EMBEDDING_BEDROCK_READ_ENABLED=true',
200
+ });
201
+ }
202
+
203
+ if (!isDeployedRuntime(environment)) return;
204
+
205
+ const allowlist = DEPLOYED_SKILL_EMBEDDING_ALLOWLIST;
206
+
207
+ if (!(allowlist.modelIds as readonly string[]).includes(modelId)) {
208
+ context.addIssue({
209
+ code: 'custom',
210
+ path: ['SKILL_EMBEDDING_BEDROCK_MODEL_ID'],
211
+ message: `Deployed runtimes only allow ${allowlist.modelIds.join(', ')}`,
212
+ });
213
+ }
214
+
215
+ if (!(allowlist.dimensions as readonly number[]).includes(dimension)) {
216
+ context.addIssue({
217
+ code: 'custom',
218
+ path: ['SKILL_EMBEDDING_DIMENSION'],
219
+ message: `Deployed runtimes only allow ${allowlist.dimensions.join(', ')}`,
220
+ });
221
+ }
222
+
223
+ if (!(allowlist.regions as readonly string[]).includes(region)) {
224
+ context.addIssue({
225
+ code: 'custom',
226
+ path: ['SKILL_EMBEDDING_BEDROCK_REGION'],
227
+ message: `Deployed runtimes only allow ${allowlist.regions.join(', ')}`,
228
+ });
229
+ }
230
+
231
+ if (!environment.SKILL_EMBEDDING_BEDROCK_ENABLED) return;
232
+
233
+ for (const key of ['AWS_ENDPOINT_URL', 'AWS_ENDPOINT_URL_BEDROCK_RUNTIME'] as const) {
234
+ if (environment[key]) {
235
+ context.addIssue({
236
+ code: 'custom',
237
+ path: [key],
238
+ message: 'Bedrock endpoint overrides are not allowed in deployed runtimes',
239
+ });
240
+ }
241
+ }
242
+
243
+ if (environment.AWS_BEARER_TOKEN_BEDROCK) {
244
+ context.addIssue({
245
+ code: 'custom',
246
+ path: ['AWS_BEARER_TOKEN_BEDROCK'],
247
+ message: 'Skill embeddings use the execution role (SigV4), not Bedrock API keys',
248
+ });
249
+ }
250
+ };
251
+
103
252
  export const brainServiceEnvironmentSchema = z
104
253
  .object(brainServiceEnvironmentShape)
105
254
  .transform(environment => ({
106
255
  ...environment,
107
256
  NOTIFICATIONS_SERVICE_PORT: environment.NOTIFICATIONS_SERVICE_PORT ?? 5100,
108
257
  CLIENT_APP_PORT: environment.CLIENT_APP_PORT ?? 3000,
258
+ SKILL_EMBEDDING_BEDROCK_MODEL_ID:
259
+ environment.SKILL_EMBEDDING_BEDROCK_MODEL_ID ?? NOVA2_EMBEDDING_MODEL_ID,
260
+ SKILL_EMBEDDING_BEDROCK_REGION:
261
+ environment.SKILL_EMBEDDING_BEDROCK_REGION ?? DEFAULT_SKILL_EMBEDDING_REGION,
262
+ SKILL_EMBEDDING_DIMENSION:
263
+ environment.SKILL_EMBEDDING_DIMENSION ?? DEFAULT_SKILL_EMBEDDING_DIMENSION,
264
+ SKILL_EMBEDDING_BEDROCK_TIMEOUT_MS: Math.min(
265
+ Math.max(
266
+ environment.SKILL_EMBEDDING_BEDROCK_TIMEOUT_MS ??
267
+ DEFAULT_SKILL_EMBEDDING_TIMEOUT_MS,
268
+ 1_000
269
+ ),
270
+ 60_000
271
+ ),
272
+ SKILL_EMBEDDING_CONCURRENCY: Math.min(environment.SKILL_EMBEDDING_CONCURRENCY ?? 1, 8),
109
273
  }))
110
274
  .superRefine((environment, context) => {
275
+ refineSkillEmbeddingEnvironment(environment, context);
276
+
111
277
  if (
112
278
  environment.NODE_ENV === 'production' &&
113
279
  !environment.IS_OFFLINE &&
@@ -128,17 +294,6 @@ export const brainServiceEnvironmentSchema = z
128
294
  });
129
295
  }
130
296
 
131
- if (
132
- environment.SKILL_EMBEDDING_BACKFILL_ON_STARTUP &&
133
- !environment.SKILL_EMBEDDING_GOOGLE_API_KEY
134
- ) {
135
- context.addIssue({
136
- code: 'custom',
137
- path: ['SKILL_EMBEDDING_GOOGLE_API_KEY'],
138
- message: 'Required when SKILL_EMBEDDING_BACKFILL_ON_STARTUP=true',
139
- });
140
- }
141
-
142
297
  if (
143
298
  environment.NODE_ENV !== 'test' &&
144
299
  environment.CREDENTIAL_REFRESH_ENABLED &&
@@ -0,0 +1,138 @@
1
+ /**
2
+ * LC-2206 opt-in check of the skill-embedding compare-and-set against a REAL, LOCAL Neo4j 5.
3
+ * Skipped unless SKILL_EMBEDDING_TEST_NEO4J_URI points at localhost; it clears every node in
4
+ * that database, so it refuses any other host. Never point it at staging or production.
5
+ *
6
+ * SKILL_EMBEDDING_TEST_NEO4J_URI=bolt://127.0.0.1:7687 \
7
+ * bunx vitest run --config vitest.config.ts src/config/skillEmbedding.neo4j.test.ts
8
+ *
9
+ * Unauthenticated local instances work as-is; set SKILL_EMBEDDING_TEST_NEO4J_PASSWORD otherwise.
10
+ */
11
+ import neo4j, { type Driver } from 'neo4j-driver';
12
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
13
+
14
+ import { buildSkillEmbeddingUpdateQuery, resolveSkillEmbeddingGeneration } from './skillEmbedding';
15
+
16
+ const uri = process.env.SKILL_EMBEDDING_TEST_NEO4J_URI;
17
+ const isLocal = Boolean(uri && /^(bolt|neo4j):\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(uri));
18
+
19
+ const generation = resolveSkillEmbeddingGeneration({
20
+ modelId: 'amazon.nova-2-multimodal-embeddings-v1:0',
21
+ dimension: 1024,
22
+ region: 'us-east-1',
23
+ });
24
+ const VECTOR = generation.vectorProperty;
25
+ const KEY = generation.keyProperty;
26
+
27
+ const vectorOf = (seed: number) => Array.from({ length: 4 }, (_, i) => seed + i / 10);
28
+
29
+ /** Same parameters updateSkillEmbedding sends. */
30
+ const writeParams = (key: string, statement: string, description: string | null) => ({
31
+ frameworkId: 'fw',
32
+ skillId: 's1',
33
+ embedding: vectorOf(key === 'old-key' ? 1 : 2),
34
+ embeddingKey: key,
35
+ sourceStatement: statement,
36
+ sourceDescription: description ?? '',
37
+ });
38
+
39
+ describe.skipIf(!isLocal)('skill embedding compare-and-set (local Neo4j)', () => {
40
+ let driver: Driver;
41
+
42
+ const run = async (query: string, params: Record<string, unknown> = {}) => {
43
+ const session = driver.session();
44
+ try {
45
+ return await session.run(query, params);
46
+ } finally {
47
+ await session.close();
48
+ }
49
+ };
50
+
51
+ const readSkill = async () => {
52
+ const result = await run(
53
+ `MATCH (s:Skill {id: 's1'}) RETURN s.statement AS statement, s.${KEY} AS key, s.${VECTOR} AS vector`
54
+ );
55
+ return result.records[0]!.toObject() as {
56
+ statement: string;
57
+ key: string | null;
58
+ vector: number[] | null;
59
+ };
60
+ };
61
+
62
+ const countOf = (result: Awaited<ReturnType<typeof run>>) =>
63
+ Number(result.records[0]?.get('count') ?? 0);
64
+
65
+ beforeAll(async () => {
66
+ const password = process.env.SKILL_EMBEDDING_TEST_NEO4J_PASSWORD;
67
+ driver = password
68
+ ? neo4j.driver(uri!, neo4j.auth.basic('neo4j', password))
69
+ : neo4j.driver(uri!);
70
+ await driver.verifyConnectivity();
71
+ });
72
+
73
+ afterAll(async () => {
74
+ await driver?.close();
75
+ });
76
+
77
+ beforeEach(async () => {
78
+ await run('MATCH (n) DETACH DELETE n');
79
+ await run(
80
+ `CREATE (f:SkillFramework {id: 'fw'})-[:CONTAINS]->(:Skill {id: 's1', frameworkId: 'fw', statement: 'Old text'})`
81
+ );
82
+ });
83
+
84
+ it('writes when the source is unchanged (null description compares as empty)', async () => {
85
+ for (const scoped of [true, false]) {
86
+ const result = await run(
87
+ buildSkillEmbeddingUpdateQuery(generation, scoped),
88
+ writeParams('new-key', 'Old text', null)
89
+ );
90
+ expect(countOf(result)).toBe(1);
91
+ }
92
+
93
+ expect(await readSkill()).toMatchObject({ key: 'new-key', vector: vectorOf(2) });
94
+ });
95
+
96
+ it('a delayed backfill write for old text cannot replace the newer live write', async () => {
97
+ // Live path: the edit commits, then its own vector is stored.
98
+ await run(`MATCH (s:Skill {id: 's1'}) SET s.statement = 'New text'`);
99
+ await run(
100
+ buildSkillEmbeddingUpdateQuery(generation, true),
101
+ writeParams('new-key', 'New text', null)
102
+ );
103
+
104
+ // Backfill read "Old text" before the edit and finishes afterwards.
105
+ const stale = await run(
106
+ buildSkillEmbeddingUpdateQuery(generation, true),
107
+ writeParams('old-key', 'Old text', null)
108
+ );
109
+
110
+ expect(countOf(stale)).toBe(0);
111
+ expect(await readSkill()).toMatchObject({
112
+ statement: 'New text',
113
+ key: 'new-key',
114
+ vector: vectorOf(2),
115
+ });
116
+ });
117
+
118
+ it('waits for an in-flight skill update and then re-checks the source', async () => {
119
+ // A skill update holds the node's write lock with new text, not yet committed.
120
+ const editSession = driver.session();
121
+ const edit = editSession.beginTransaction();
122
+ await edit.run(`MATCH (s:Skill {id: 's1'}) SET s.statement = 'New text'`);
123
+
124
+ // The backfill write for the old text starts while that update is open...
125
+ const stale = run(
126
+ buildSkillEmbeddingUpdateQuery(generation, true),
127
+ writeParams('old-key', 'Old text', null)
128
+ );
129
+ await new Promise(resolve => setTimeout(resolve, 300));
130
+
131
+ // ...and the update commits.
132
+ await edit.commit();
133
+ await editSession.close();
134
+
135
+ expect(countOf(await stale)).toBe(0);
136
+ expect(await readSkill()).toMatchObject({ statement: 'New text', key: null, vector: null });
137
+ });
138
+ });