@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
@@ -22,10 +22,14 @@ import {
22
22
  import { createSkill } from '@accesslayer/skill/create';
23
23
  import { updateSkill, deleteSkill } from '@accesslayer/skill/update';
24
24
  import {
25
- generateEmbeddingForText,
26
- getCachedEmbeddingForText,
25
+ generateQueryEmbedding,
26
+ getCachedQueryEmbedding,
27
+ getSkillEmbeddingSearchIndex,
28
+ isSkillEmbeddingNovaReadEnabled,
29
+ isSkillEmbeddingError,
27
30
  upsertSkillEmbeddings,
28
31
  } from '@helpers/skill-embedding.helpers';
32
+ import { NOVA2_MAX_TEXT_CHARS } from '../config/skillEmbedding';
29
33
  import { listTagsForSkill, addTagToSkill, removeTagFromSkill } from '@accesslayer/skill/tags';
30
34
  import {
31
35
  TagValidator,
@@ -226,7 +230,16 @@ export const skillsRouter = t.router({
226
230
  .query(async ({ ctx, input }) => {
227
231
  const profileId = ctx.user.profile.profileId;
228
232
  const { text, frameworkId, limit } = input;
229
- const cachedEmbedding = await getCachedEmbeddingForText(text);
233
+
234
+ // Nova's synchronous input limit; the legacy Google read path keeps main's behaviour.
235
+ if (isSkillEmbeddingNovaReadEnabled() && text.trim().length > NOVA2_MAX_TEXT_CHARS) {
236
+ throw new TRPCError({
237
+ code: 'BAD_REQUEST',
238
+ message: `Semantic search text must be at most ${NOVA2_MAX_TEXT_CHARS} characters`,
239
+ });
240
+ }
241
+
242
+ const cachedEmbedding = await getCachedQueryEmbedding(text);
230
243
 
231
244
  if (!cachedEmbedding) {
232
245
  const rateLimitKey = `skill-semantic-search-rate:${profileId}`;
@@ -249,16 +262,39 @@ export const skillsRouter = t.router({
249
262
 
250
263
  let embedding: number[];
251
264
  try {
252
- embedding = cachedEmbedding ?? (await generateEmbeddingForText(text));
265
+ embedding = cachedEmbedding ?? (await generateQueryEmbedding(text));
253
266
  } catch (error) {
254
- console.error('Failed to generate embedding for skill search:', error);
267
+ const code = isSkillEmbeddingError(error) ? error.code : 'failed';
268
+ console.error(`Failed to generate embedding for skill search: ${code}`);
269
+
270
+ if (code === 'throttled') {
271
+ throw new TRPCError({
272
+ code: 'TOO_MANY_REQUESTS',
273
+ message: 'Skill search is rate limited. Please try again shortly.',
274
+ });
275
+ }
276
+
277
+ if (code === 'unavailable' || code === 'timeout' || code === 'disabled') {
278
+ throw new TRPCError({
279
+ code: 'SERVICE_UNAVAILABLE',
280
+ message: 'Skill search is temporarily unavailable',
281
+ });
282
+ }
283
+
255
284
  throw new TRPCError({
256
285
  code: 'INTERNAL_SERVER_ERROR',
257
286
  message: 'Failed to generate embedding for skill search',
258
287
  });
259
288
  }
260
289
 
261
- const results = await searchSkillsByEmbedding(embedding, limit, frameworkId);
290
+ // Legacy Google index unless SKILL_EMBEDDING_BEDROCK_READ_ENABLED (LC-2206); the query
291
+ // embedding above was produced for the same index.
292
+ const results = await searchSkillsByEmbedding(
293
+ embedding,
294
+ limit,
295
+ frameworkId,
296
+ getSkillEmbeddingSearchIndex()
297
+ );
262
298
 
263
299
  return {
264
300
  records: results.map(({ skill, score }) => ({
@@ -0,0 +1,82 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { DEFAULT_SKILL_FRAMEWORKS } from './skill-frameworks.fixtures';
4
+
5
+ // Review r4339891853: a failing Nova pass must not suppress the legacy Google pass (and the
6
+ // reverse), and each failure must still be reported. Bedrock and Google are mocked here.
7
+ const embeddings = vi.hoisted(() => ({
8
+ upsertNova: vi.fn(),
9
+ upsertLegacy: vi.fn(),
10
+ }));
11
+
12
+ class FakeSkillEmbeddingError extends Error {
13
+ constructor(readonly code: string) {
14
+ super(code);
15
+ }
16
+ }
17
+
18
+ vi.mock('@helpers/skill-embedding.helpers', () => ({
19
+ upsertNovaSkillEmbeddings: embeddings.upsertNova,
20
+ upsertLegacySkillEmbeddings: embeddings.upsertLegacy,
21
+ isSkillEmbeddingEnabled: () => true,
22
+ isLegacySkillEmbeddingEnabled: () => true,
23
+ isSkillEmbeddingError: (error: unknown) => error instanceof FakeSkillEmbeddingError,
24
+ getSkillEmbeddingGeneration: () => ({ vectorProperty: 'embedding_nova2_v1_0_d1024' }),
25
+ }));
26
+
27
+ /** Query runner double: every framework has one skill missing both vectors. */
28
+ const run = vi.fn(async (query: string, params?: Record<string, unknown>) => {
29
+ if (query.includes('IS NULL') && query.includes('[:CONTAINS]->(s:Skill)')) {
30
+ const frameworkId = String(params?.frameworkId);
31
+ return {
32
+ records: [
33
+ {
34
+ get: (key: string) =>
35
+ key === 's'
36
+ ? { properties: { id: `${frameworkId}-skill`, statement: 'Python' } }
37
+ : null,
38
+ },
39
+ ],
40
+ };
41
+ }
42
+ return { records: [] };
43
+ });
44
+
45
+ describe('seedSkillFrameworkFixtures embedding passes', () => {
46
+ beforeEach(() => {
47
+ run.mockClear();
48
+ embeddings.upsertNova.mockReset();
49
+ embeddings.upsertLegacy.mockReset().mockResolvedValue(undefined);
50
+ });
51
+
52
+ it('still writes legacy embeddings for every framework when Nova is denied, and reports it', async () => {
53
+ embeddings.upsertNova.mockRejectedValue(new FakeSkillEmbeddingError('access_denied'));
54
+ const log = { log: vi.fn(), warn: vi.fn() };
55
+ const { seedSkillFrameworkFixtures } = await import('./seedSkillFrameworks');
56
+
57
+ await seedSkillFrameworkFixtures(run, { log });
58
+
59
+ expect(embeddings.upsertLegacy).toHaveBeenCalledTimes(DEFAULT_SKILL_FRAMEWORKS.length);
60
+ // Nova stops after its first failure instead of retrying every framework.
61
+ expect(embeddings.upsertNova).toHaveBeenCalledTimes(1);
62
+ const warnings = log.warn.mock.calls.map(call => String(call[0])).join('\n');
63
+ expect(warnings).toContain('Nova');
64
+ expect(warnings).toContain('Bedrock denied the embedding request');
65
+ expect(warnings).not.toMatch(/legacy google embedding backfill was skipped/i);
66
+ });
67
+
68
+ it('still writes Nova embeddings when the legacy Google pass fails, and reports it', async () => {
69
+ embeddings.upsertNova.mockResolvedValue(undefined);
70
+ embeddings.upsertLegacy.mockRejectedValue(new Error('google down'));
71
+ const log = { log: vi.fn(), warn: vi.fn() };
72
+ const { seedSkillFrameworkFixtures } = await import('./seedSkillFrameworks');
73
+
74
+ await seedSkillFrameworkFixtures(run, { log });
75
+
76
+ expect(embeddings.upsertNova).toHaveBeenCalledTimes(DEFAULT_SKILL_FRAMEWORKS.length);
77
+ expect(embeddings.upsertLegacy).toHaveBeenCalledTimes(1);
78
+ const warnings = log.warn.mock.calls.map(call => String(call[0])).join('\n');
79
+ expect(warnings).toMatch(/legacy google embedding backfill was skipped/i);
80
+ expect(warnings).not.toMatch(/nova embedding backfill was skipped/i);
81
+ });
82
+ });
@@ -24,6 +24,12 @@ vi.mock('neogma', () => ({
24
24
  }));
25
25
  vi.mock('@helpers/skill-embedding.helpers', () => ({
26
26
  upsertSkillEmbeddings: mockUpsertSkillEmbeddings,
27
+ upsertNovaSkillEmbeddings: mockUpsertSkillEmbeddings,
28
+ upsertLegacySkillEmbeddings: mockUpsertSkillEmbeddings,
29
+ isSkillEmbeddingEnabled: () => false,
30
+ isLegacySkillEmbeddingEnabled: () => false,
31
+ isSkillEmbeddingError: () => false,
32
+ getSkillEmbeddingGeneration: vi.fn(),
27
33
  }));
28
34
 
29
35
  describe('seedSkillFrameworks staging env resolution', () => {
@@ -10,9 +10,20 @@ import {
10
10
  type SeedSkillNode,
11
11
  } from './skill-frameworks.fixtures';
12
12
  import {
13
- upsertSkillEmbeddings,
13
+ getSkillEmbeddingGeneration,
14
+ isLegacySkillEmbeddingEnabled,
15
+ isSkillEmbeddingEnabled,
16
+ isSkillEmbeddingError,
17
+ upsertLegacySkillEmbeddings,
18
+ upsertNovaSkillEmbeddings,
19
+ type UpdateLegacySkillEmbeddingFn,
14
20
  type UpdateSkillEmbeddingFn,
15
21
  } from '@helpers/skill-embedding.helpers';
22
+ import {
23
+ LEGACY_GOOGLE_SKILL_EMBEDDING_PROPERTY,
24
+ buildSkillEmbeddingUpdateParams,
25
+ buildSkillEmbeddingUpdateQuery,
26
+ } from '../config/skillEmbedding';
16
27
  import { syncProfileDisplayNameLower } from '../accesslayer/profile/search-fields';
17
28
 
18
29
  // Inlined from @helpers/profile.helpers to avoid importing the access/model layer
@@ -365,6 +376,20 @@ export const addSkillFrameworkAdmin = async (
365
376
  };
366
377
 
367
378
  const buildSeedSkillEmbeddingUpdater = (run: QueryRunnerLike['run']): UpdateSkillEmbeddingFn => {
379
+ return async update => {
380
+ const result = await run(
381
+ buildSkillEmbeddingUpdateQuery(update.generation, Boolean(update.frameworkId)),
382
+ buildSkillEmbeddingUpdateParams(update)
383
+ );
384
+
385
+ return Number(result.records[0]?.get('count') ?? 0) > 0;
386
+ };
387
+ };
388
+
389
+ /** LC-2206 legacy write (main's Cypher), kept while search can still read the legacy index. */
390
+ const buildSeedLegacySkillEmbeddingUpdater = (
391
+ run: QueryRunnerLike['run']
392
+ ): UpdateLegacySkillEmbeddingFn => {
368
393
  return async (skillId, embedding, frameworkId) => {
369
394
  if (frameworkId) {
370
395
  const result = await run(
@@ -390,7 +415,8 @@ const buildSeedSkillEmbeddingUpdater = (run: QueryRunnerLike['run']): UpdateSkil
390
415
 
391
416
  const getSeedSkillsMissingEmbedding = async (
392
417
  run: QueryRunnerLike['run'],
393
- frameworkId: string
418
+ frameworkId: string,
419
+ vectorProperty: string
394
420
  ): Promise<
395
421
  Array<{
396
422
  id: string;
@@ -400,9 +426,10 @@ const getSeedSkillsMissingEmbedding = async (
400
426
  code?: string;
401
427
  }>
402
428
  > => {
429
+ // Property names come from resolveSkillEmbeddingGeneration or the legacy constant ([a-z0-9_] only).
403
430
  const result = await run(
404
431
  `MATCH (f:SkillFramework {id: $frameworkId})-[:CONTAINS]->(s:Skill)
405
- WHERE s.embedding IS NULL
432
+ WHERE s.${vectorProperty} IS NULL
406
433
  OPTIONAL MATCH (s)-[:IS_CHILD_OF]->(parent:Skill)
407
434
  RETURN s AS s, parent.id AS parentId
408
435
  ORDER BY s.id`,
@@ -423,19 +450,18 @@ const getSeedSkillsMissingEmbedding = async (
423
450
  });
424
451
  };
425
452
 
426
- const describeEmbeddingBackfillFailure = (error: unknown): string => {
427
- const message = error instanceof Error ? error.message : String(error);
428
- const normalized = message.toLowerCase();
453
+ export const describeEmbeddingBackfillFailure = (error: unknown): string => {
454
+ const code = isSkillEmbeddingError(error) ? error.code : undefined;
429
455
 
430
- if (normalized.includes('quota exceeded') || normalized.includes('resource_exhausted')) {
431
- return '\nEmbedding quota was reached. \nThe frameworks were seeded, but semantic search will stay limited until the Google embedding quota resets or you switch to a paid key.\n';
456
+ if (code === 'throttled') {
457
+ return 'Bedrock embedding requests were throttled. The frameworks were seeded, but some semantic search embeddings were skipped for now; the startup backfill will fill them in.';
432
458
  }
433
459
 
434
- if (normalized.includes('rate limits') || normalized.includes('too many requests')) {
435
- return 'Google embedding requests were rate-limited. The frameworks were seeded, but some semantic search embeddings were skipped for now.';
460
+ if (code === 'access_denied') {
461
+ return 'Bedrock denied the embedding request. Check model access for Nova 2 Multimodal Embeddings and the bedrock:InvokeModel grant for your AWS credentials.';
436
462
  }
437
463
 
438
- return `${message}\nUse \`bun run skill-frameworks add-admin\` to add admins.`;
464
+ return `Skill embedding failed (${code ?? 'unknown'}).\nUse \`bun run skill-frameworks add-admin\` to add admins.`;
439
465
  };
440
466
 
441
467
  const upsertSkillNode = async (
@@ -547,7 +573,12 @@ export const seedSkillFrameworkFixtures = async (
547
573
  await ensureSeedProfile(run, ownerProfileId);
548
574
 
549
575
  const seededFrameworkIds: string[] = [];
550
- let embeddingBackfillFailureSummary: string | null = null;
576
+ // Each provider tracks its own failure, so a Nova failure never skips the legacy Google
577
+ // pass that semantic search may still be reading (and vice versa).
578
+ const embeddingFailures: { nova: string | null; legacy: string | null } = {
579
+ nova: null,
580
+ legacy: null,
581
+ };
551
582
 
552
583
  for (const framework of fixtures) {
553
584
  const now = new Date().toISOString();
@@ -592,17 +623,42 @@ export const seedSkillFrameworkFixtures = async (
592
623
  await upsertSkillNode(run, framework, rootSkill);
593
624
  }
594
625
 
595
- if (!embeddingBackfillFailureSummary) {
596
- const missingEmbeddingSkills = await getSeedSkillsMissingEmbedding(run, framework.id);
626
+ // LC-2206: Nova vectors whenever Nova is enabled (independent of the read flag), plus
627
+ // the legacy Google vectors main writes while a Google key is configured.
628
+ if (!embeddingFailures.nova && isSkillEmbeddingEnabled()) {
629
+ const missingEmbeddingSkills = await getSeedSkillsMissingEmbedding(
630
+ run,
631
+ framework.id,
632
+ getSkillEmbeddingGeneration().vectorProperty
633
+ );
597
634
 
598
635
  if (missingEmbeddingSkills.length > 0) {
599
636
  try {
600
- await upsertSkillEmbeddings(
637
+ await upsertNovaSkillEmbeddings(
601
638
  missingEmbeddingSkills,
602
639
  buildSeedSkillEmbeddingUpdater(run)
603
640
  );
604
641
  } catch (error) {
605
- embeddingBackfillFailureSummary = describeEmbeddingBackfillFailure(error);
642
+ embeddingFailures.nova = describeEmbeddingBackfillFailure(error);
643
+ }
644
+ }
645
+ }
646
+
647
+ if (!embeddingFailures.legacy && isLegacySkillEmbeddingEnabled()) {
648
+ const missingLegacySkills = await getSeedSkillsMissingEmbedding(
649
+ run,
650
+ framework.id,
651
+ LEGACY_GOOGLE_SKILL_EMBEDDING_PROPERTY
652
+ );
653
+
654
+ if (missingLegacySkills.length > 0) {
655
+ try {
656
+ await upsertLegacySkillEmbeddings(
657
+ missingLegacySkills,
658
+ buildSeedLegacySkillEmbeddingUpdater(run)
659
+ );
660
+ } catch (error) {
661
+ embeddingFailures.legacy = describeEmbeddingBackfillFailure(error);
606
662
  }
607
663
  }
608
664
  }
@@ -610,10 +666,19 @@ export const seedSkillFrameworkFixtures = async (
610
666
  seededFrameworkIds.push(framework.id);
611
667
  }
612
668
 
613
- if (embeddingBackfillFailureSummary) {
669
+ if (embeddingFailures.nova) {
614
670
  options.log?.warn?.(
615
- `Embedding backfill was skipped after the first framework because Google rejected the request: ${embeddingBackfillFailureSummary}`
671
+ `Nova embedding backfill was skipped for the remaining frameworks: ${embeddingFailures.nova}`
616
672
  );
673
+ }
674
+
675
+ if (embeddingFailures.legacy) {
676
+ options.log?.warn?.(
677
+ `Legacy Google embedding backfill was skipped for the remaining frameworks: ${embeddingFailures.legacy}`
678
+ );
679
+ }
680
+
681
+ if (embeddingFailures.nova || embeddingFailures.legacy) {
617
682
  options.log?.warn?.(
618
683
  'The frameworks were still seeded. Use `bun run skill-frameworks add-admin` to add admins. Semantic search will keep using whichever embeddings already exist.'
619
684
  );
@@ -0,0 +1,17 @@
1
+ import type { CampaignStatus } from '@learncard/types';
2
+
3
+ export type CampaignRecord = {
4
+ id: string;
5
+ name: string;
6
+ status: CampaignStatus;
7
+ definition: string;
8
+ startsAt?: string;
9
+ endsAt?: string;
10
+ excludeMinors: boolean;
11
+ selfEnroll: boolean;
12
+ createdByProfileId: string;
13
+ createdAt: string;
14
+ updatedAt: string;
15
+ };
16
+
17
+ export type CampaignAudienceQuery = { match: string; params: Record<string, unknown> };
@@ -12,5 +12,11 @@ export const FlatSkillValidator = SkillValidator.extend({
12
12
  export type FlatSkillType = z.infer<typeof FlatSkillValidator>;
13
13
 
14
14
  export type SkillDbType = FlatSkillType & {
15
+ /**
16
+ * Legacy gemini-embedding-001 (768-d) vector. Still written on skill changes and read by
17
+ * semantic search until SKILL_EMBEDDING_BEDROCK_READ_ENABLED is on (LC-2206); dropped by the
18
+ * post-rollback-window cleanup. Nova 2 vectors live in generation-specific properties
19
+ * (see config/skillEmbedding.ts).
20
+ */
15
21
  embedding?: number[];
16
22
  };
@@ -0,0 +1,110 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import { neogma } from '@instance';
3
+ import { Boost } from '@models';
4
+ import { sendAppCredentialWithActivity } from '@helpers/app-credential-delivery.helpers';
5
+ import { getBoostUri } from '@helpers/boost.helpers';
6
+ import type { CredentialIssuer } from '../src/types/issuer';
7
+
8
+ const domain = 'localhost%3A3000';
9
+ const issuer: CredentialIssuer = {
10
+ type: 'appStoreListing',
11
+ listing: { listing_id: 'race-listing', display_name: 'Race Test App' },
12
+ ownerProfile: { profileId: 'race-owner' },
13
+ } as CredentialIssuer;
14
+ const credential = {
15
+ protected: 'header',
16
+ iv: 'iv',
17
+ ciphertext: 'ciphertext',
18
+ tag: 'tag',
19
+ recipients: [],
20
+ };
21
+
22
+ // The integration and CI inbox configs both supply a fresh Testcontainers database.
23
+ // No environment-configured development or deployed database is used.
24
+ describe('app credential delivery graph (real Neo4j)', () => {
25
+ beforeEach(async () => {
26
+ await neogma.queryRunner.run('MATCH (n) DETACH DELETE n');
27
+ await neogma.queryRunner.run(`
28
+ CREATE (owner:Profile {profileId: 'race-owner'}),
29
+ (learner:Profile {profileId: 'race-learner'}),
30
+ (listing:AppStoreListing {listing_id: 'race-listing'}),
31
+ (boost:Boost {id: 'race-boost'})
32
+ CREATE (owner)-[:PUBLISHED]->(listing), (listing)-[:HAS_BOOST]->(boost)
33
+ `);
34
+ });
35
+
36
+ const issue = async (sequence: number): Promise<string> => {
37
+ const boost = await Boost.findOne({ where: { id: 'race-boost' } });
38
+ if (!boost) throw new Error('Missing fixture boost');
39
+ return sendAppCredentialWithActivity(
40
+ {
41
+ actorProfileId: 'race-owner',
42
+ recipientType: 'profile',
43
+ recipientIdentifier: 'race-learner',
44
+ recipientProfileId: 'race-learner',
45
+ boostUri: getBoostUri(boost.id, domain),
46
+ integrationId: 'race-integration',
47
+ listingId: 'race-listing',
48
+ source: 'appEvent',
49
+ metadata: { sequence },
50
+ },
51
+ {
52
+ from: issuer,
53
+ to: { profileId: 'race-learner' },
54
+ boost,
55
+ credential,
56
+ domain,
57
+ skipNotification: true,
58
+ listingId: 'race-listing',
59
+ integrationId: 'race-integration',
60
+ metadata: { sequence },
61
+ }
62
+ );
63
+ };
64
+
65
+ const assertGraph = async (expected: number): Promise<void> => {
66
+ const { records } = await neogma.queryRunner.run(`
67
+ MATCH (c:Credential)
68
+ RETURN c.credential AS payload,
69
+ [(p:Profile)-[r:CREDENTIAL_SENT]->(c) | properties(r)] AS profileEdges,
70
+ [(l:AppStoreListing)-[r:CREDENTIAL_SENT]->(c) | properties(r)] AS listingEdges,
71
+ [(c)-[:INSTANCE_OF]->(b:Boost) | b.id] AS boosts
72
+ `);
73
+ expect(records).toHaveLength(expected);
74
+ for (const row of records) {
75
+ expect(JSON.parse(row.get('payload'))).toEqual(credential);
76
+ expect(row.get('profileEdges')).toHaveLength(1);
77
+ expect(row.get('listingEdges')).toHaveLength(1);
78
+ expect(row.get('boosts')).toEqual(['race-boost']);
79
+ const [edge] = row.get('listingEdges');
80
+ expect(edge.to).toBe('race-learner');
81
+ expect(edge.integrationId).toBe('race-integration');
82
+ expect(edge.activityId).toBe(row.get('profileEdges')[0].activityId);
83
+ const result = await neogma.queryRunner.run(
84
+ `
85
+ MATCH (:AppStoreListing {listing_id: 'race-listing'})-[:PERFORMED_BY_LISTING]->(a:CredentialActivity {activityId: $activityId})
86
+ MATCH (:Profile {profileId: 'race-owner'})-[:PERFORMED]->(a)
87
+ MATCH (a)-[:TO_RECIPIENT]->(:Profile {profileId: 'race-learner'})
88
+ MATCH (a)-[:FOR_BOOST]->(:Boost {id: 'race-boost'})
89
+ RETURN a.metadata AS metadata
90
+ `,
91
+ { activityId: edge.activityId }
92
+ );
93
+ expect(result.records).toHaveLength(1);
94
+ expect(JSON.parse(result.records[0].get('metadata')).sequence).toBe(
95
+ Number(edge['metadata.sequence'])
96
+ );
97
+ }
98
+ };
99
+
100
+ it('repeated sends create exactly one linked activity and each required delivery edge', async () => {
101
+ for (let i = 0; i < 30; i++) await issue(i);
102
+ await assertGraph(30);
103
+ });
104
+
105
+ it('independent simultaneous sends keep their own activity and attribution', async () => {
106
+ const uris = await Promise.all(Array.from({ length: 8 }, (_, i) => issue(i)));
107
+ expect(new Set(uris).size).toBe(8);
108
+ await assertGraph(8);
109
+ });
110
+ });