@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
@@ -1,11 +1,38 @@
1
1
  import { environment } from '@environment';
2
2
  import { z } from 'zod';
3
- import { createHash } from 'crypto';
3
+ import { createHash, randomUUID } from 'crypto';
4
4
  import { GoogleGenAI } from '@google/genai';
5
5
  import cache from '@cache';
6
6
 
7
- import { getSkillsMissingEmbedding } from '@accesslayer/skill/read';
8
- import { updateSkillEmbedding } from '@accesslayer/skill/update';
7
+ import { getSkillEmbeddingCandidates } from '@accesslayer/skill/read';
8
+ import type { SkillEmbeddingCandidate } from '@accesslayer/skill/read';
9
+ import { updateLegacySkillEmbedding, updateSkillEmbedding } from '@accesslayer/skill/update';
10
+ import type { SkillEmbeddingUpdate } from '@accesslayer/skill/update';
11
+
12
+ import {
13
+ LEGACY_GOOGLE_SKILL_EMBEDDING_DIMENSION,
14
+ LEGACY_GOOGLE_SKILL_EMBEDDING_MODEL,
15
+ LEGACY_GOOGLE_SKILL_EMBEDDING_SEARCH_INDEX,
16
+ NOVA2_MAX_TEXT_CHARS,
17
+ SKILL_EMBEDDING_SCHEME_VERSION,
18
+ resolveSkillEmbeddingGeneration,
19
+ toSkillEmbeddingSearchIndex,
20
+ toSkillEmbeddingSource,
21
+ } from '../config/skillEmbedding';
22
+ import type { SkillEmbeddingGeneration, SkillEmbeddingSearchIndex } from '../config/skillEmbedding';
23
+ import {
24
+ SkillEmbeddingError,
25
+ createDefaultBedrockInvoke,
26
+ embedTextWithBedrock,
27
+ isSkillEmbeddingError,
28
+ } from './bedrock-embedding.helpers';
29
+ import type {
30
+ BedrockEmbeddingConfig,
31
+ BedrockInvoke,
32
+ EmbeddingPurpose,
33
+ } from './bedrock-embedding.helpers';
34
+
35
+ export { SkillEmbeddingError, isSkillEmbeddingError };
9
36
 
10
37
  export type SkillEmbeddingTarget = {
11
38
  id: string;
@@ -15,132 +42,324 @@ export type SkillEmbeddingTarget = {
15
42
  code?: string | null;
16
43
  };
17
44
 
18
- export type UpdateSkillEmbeddingFn = (
45
+ export type UpdateSkillEmbeddingFn = (update: SkillEmbeddingUpdate) => Promise<boolean>;
46
+
47
+ /** Writes the legacy Google `s.embedding` vector (main's pre-LC-2206 signature). */
48
+ export type UpdateLegacySkillEmbeddingFn = (
19
49
  skillId: string,
20
50
  embedding: number[],
21
51
  frameworkId?: string
22
52
  ) => Promise<boolean>;
23
53
 
24
- const googleApiKey = environment.SKILL_EMBEDDING_GOOGLE_API_KEY;
25
- const googleModel = environment.SKILL_EMBEDDING_GOOGLE_MODEL ?? 'gemini-embedding-001';
26
- const googleClient = googleApiKey ? new GoogleGenAI({ apiKey: googleApiKey }) : null;
54
+ // ---------------------------------------------------------------------------------------------
55
+ // Configuration
56
+ // ---------------------------------------------------------------------------------------------
27
57
 
28
- const embeddingResponseValidator = z.object({
29
- values: z.array(z.number()),
30
- });
58
+ let cachedGeneration: SkillEmbeddingGeneration | undefined;
31
59
 
32
- const embeddingsResponseValidator = z.array(embeddingResponseValidator);
60
+ /** The (model, dimension, scheme) generation this process reads and writes. */
61
+ export const getSkillEmbeddingGeneration = (): SkillEmbeddingGeneration => {
62
+ cachedGeneration ??= resolveSkillEmbeddingGeneration({
63
+ modelId: environment.SKILL_EMBEDDING_BEDROCK_MODEL_ID,
64
+ dimension: environment.SKILL_EMBEDDING_DIMENSION,
65
+ region: environment.SKILL_EMBEDDING_BEDROCK_REGION,
66
+ });
33
67
 
34
- const getEmbeddingCacheTtlSeconds = (): number => {
35
- const raw = Number(environment.SKILL_EMBEDDING_CACHE_TTL_SECONDS ?? 60 * 60 * 24);
36
- return Math.min(Math.max(raw, 60), 60 * 60 * 24 * 7);
68
+ return cachedGeneration;
37
69
  };
38
70
 
39
- const getEmbeddingCacheKey = (text: string): string => {
40
- const normalizedText = text.trim();
41
- const digest = createHash('sha256').update(normalizedText).digest('hex');
71
+ /** Nova writes (new/updated skills, seed, backfill). Independent of the read flag. */
72
+ export const isSkillEmbeddingEnabled = (): boolean => environment.SKILL_EMBEDDING_BEDROCK_ENABLED;
73
+
74
+ /**
75
+ * LC-2206 read switch. Off (default): semantic search queries the legacy Google 768-d index
76
+ * with Google query embeddings, exactly as before LC-2206. On: it queries the Nova index with
77
+ * Nova TEXT_RETRIEVAL query embeddings. Flipping it back off is the rollback.
78
+ */
79
+ export const isSkillEmbeddingNovaReadEnabled = (): boolean =>
80
+ environment.SKILL_EMBEDDING_BEDROCK_READ_ENABLED;
81
+
82
+ /** The vector index semantic search queries in this process. */
83
+ export const getSkillEmbeddingSearchIndex = (): SkillEmbeddingSearchIndex =>
84
+ isSkillEmbeddingNovaReadEnabled()
85
+ ? toSkillEmbeddingSearchIndex(getSkillEmbeddingGeneration())
86
+ : LEGACY_GOOGLE_SKILL_EMBEDDING_SEARCH_INDEX;
87
+
88
+ /** Legacy Google writes and legacy reads run only while the Google key is configured (as on main). */
89
+ export const isLegacySkillEmbeddingEnabled = (): boolean =>
90
+ Boolean(environment.SKILL_EMBEDDING_GOOGLE_API_KEY);
91
+
92
+ const getLegacyGoogleModel = (): string =>
93
+ environment.SKILL_EMBEDDING_GOOGLE_MODEL ?? LEGACY_GOOGLE_SKILL_EMBEDDING_MODEL;
94
+
95
+ const getBedrockConfig = (): BedrockEmbeddingConfig => {
96
+ const generation = getSkillEmbeddingGeneration();
97
+
98
+ return {
99
+ modelId: generation.modelId,
100
+ region: generation.region,
101
+ dimension: generation.dimension,
102
+ timeoutMs: environment.SKILL_EMBEDDING_BEDROCK_TIMEOUT_MS,
103
+ };
104
+ };
42
105
 
43
- return `skill-embedding:query:${googleModel}:${digest}`;
106
+ let invokeOverride: BedrockInvoke | undefined;
107
+
108
+ /** Unit tests inject a fake InvokeModel transport; production always uses the SDK client. */
109
+ export const setSkillEmbeddingInvokeForTests = (invoke: BedrockInvoke | undefined): void => {
110
+ invokeOverride = invoke;
44
111
  };
45
112
 
46
- export const getCachedEmbeddingForText = async (text: string): Promise<number[] | null> => {
47
- const normalizedText = text.trim();
48
- if (!normalizedText) {
49
- return null;
50
- }
113
+ // ---------------------------------------------------------------------------------------------
114
+ // Legacy Google gemini-embedding-001 (768-d): kept only for the LC-2206 transition
115
+ // ---------------------------------------------------------------------------------------------
51
116
 
52
- try {
53
- const cachedEmbedding = await cache.get(getEmbeddingCacheKey(normalizedText));
54
- if (!cachedEmbedding) {
55
- return null;
56
- }
117
+ export type LegacyGoogleEmbed = (texts: string[]) => Promise<number[][]>;
57
118
 
58
- const parsed = embeddingResponseValidator.parse(JSON.parse(cachedEmbedding));
59
- return parsed.values;
60
- } catch (error) {
61
- console.warn('Failed to read cached skill embedding', error);
62
- return null;
63
- }
64
- };
119
+ let legacyEmbedOverride: LegacyGoogleEmbed | undefined;
65
120
 
66
- const getEmbeddingBatchSize = (): number => {
67
- const raw = Number(
68
- environment.SKILL_EMBEDDING_BATCH_SIZE ??
69
- environment.SKILL_EMBEDDING_BACKFILL_BATCH_SIZE ??
70
- 25
71
- );
72
- return Math.min(Math.max(raw, 1), 100);
121
+ /** Unit tests inject a fake Google transport; production always uses @google/genai. */
122
+ export const setLegacySkillEmbeddingForTests = (
123
+ embedTexts: LegacyGoogleEmbed | undefined
124
+ ): void => {
125
+ legacyEmbedOverride = embedTexts;
73
126
  };
74
127
 
75
- export const generateEmbeddingsForTexts = async (texts: string[]): Promise<number[][]> => {
76
- if (!googleClient) throw new Error('SKILL_EMBEDDING_GOOGLE_API_KEY is not configured');
128
+ const legacyEmbeddingsResponseValidator = z.array(z.object({ values: z.array(z.number()) }));
129
+
130
+ let googleClient: GoogleGenAI | undefined;
131
+
132
+ /** Same request main sends: gemini-embedding-001, no task type, 768 output dimensions. */
133
+ const embedWithLegacyGoogle: LegacyGoogleEmbed = async texts => {
134
+ if (!isLegacySkillEmbeddingEnabled()) throw new SkillEmbeddingError('disabled');
77
135
  if (texts.length === 0) return [];
136
+ if (legacyEmbedOverride) return legacyEmbedOverride(texts);
78
137
 
79
- const normalizedTexts = texts.map(text => text.trim()).filter(Boolean);
80
- if (normalizedTexts.length === 0) return [];
138
+ googleClient ??= new GoogleGenAI({ apiKey: environment.SKILL_EMBEDDING_GOOGLE_API_KEY });
81
139
 
82
140
  const response = await googleClient.models.embedContent({
83
- model: googleModel,
84
- contents: normalizedTexts,
85
- config: { outputDimensionality: 768 },
141
+ model: getLegacyGoogleModel(),
142
+ contents: texts,
143
+ config: { outputDimensionality: LEGACY_GOOGLE_SKILL_EMBEDDING_DIMENSION },
86
144
  });
87
145
 
88
- const parsed = await embeddingsResponseValidator.parseAsync(response.embeddings);
146
+ const parsed = await legacyEmbeddingsResponseValidator.parseAsync(response.embeddings);
89
147
  return parsed.map(embedding => embedding.values);
90
148
  };
91
149
 
150
+ const embed = async (text: string, purpose: EmbeddingPurpose): Promise<number[]> => {
151
+ if (!isSkillEmbeddingEnabled()) throw new SkillEmbeddingError('disabled');
152
+
153
+ const config = getBedrockConfig();
154
+ return embedTextWithBedrock(
155
+ text,
156
+ purpose,
157
+ config,
158
+ invokeOverride ?? createDefaultBedrockInvoke(config)
159
+ );
160
+ };
161
+
162
+ // ---------------------------------------------------------------------------------------------
163
+ // Text and keys
164
+ // ---------------------------------------------------------------------------------------------
165
+
166
+ /** Text embedded for a stored skill: statement and description, clipped to Nova's limit. */
92
167
  export const buildSkillEmbeddingText = (skill: SkillEmbeddingTarget): string => {
93
168
  const parts = [skill.statement, skill.description]
94
169
  .map(part => (typeof part === 'string' ? part.trim() : ''))
95
170
  .filter(Boolean);
96
171
 
97
- return parts.join('\n');
172
+ return parts.join('\n').slice(0, NOVA2_MAX_TEXT_CHARS).trim();
173
+ };
174
+
175
+ /** Main's legacy text (no Nova clipping), so legacy vectors match what main writes. */
176
+ export const buildLegacySkillEmbeddingText = (skill: SkillEmbeddingTarget): string =>
177
+ [skill.statement, skill.description]
178
+ .map(part => (typeof part === 'string' ? part.trim() : ''))
179
+ .filter(Boolean)
180
+ .join('\n');
181
+
182
+ /** Stored with each vector; equal keys mean "already embedded with this generation and text". */
183
+ export const computeSkillEmbeddingKey = (
184
+ generation: SkillEmbeddingGeneration,
185
+ text: string
186
+ ): string => createHash('sha256').update(`${generation.identity}\n${text}`).digest('hex');
187
+
188
+ export const skillNeedsEmbedding = (
189
+ candidate: SkillEmbeddingCandidate,
190
+ generation: SkillEmbeddingGeneration
191
+ ): boolean => {
192
+ const text = buildSkillEmbeddingText(candidate);
193
+ if (!text) return false;
194
+
195
+ return (
196
+ !candidate.hasEmbedding ||
197
+ candidate.embeddingKey !== computeSkillEmbeddingKey(generation, text)
198
+ );
199
+ };
200
+
201
+ // ---------------------------------------------------------------------------------------------
202
+ // Query embeddings with a model/dimension-scoped Redis cache, routed by the read flag
203
+ // ---------------------------------------------------------------------------------------------
204
+
205
+ const getEmbeddingCacheTtlSeconds = (): number => {
206
+ const raw = Number(environment.SKILL_EMBEDDING_CACHE_TTL_SECONDS ?? 60 * 60 * 24);
207
+ return Math.min(Math.max(raw, 60), 60 * 60 * 24 * 7);
208
+ };
209
+
210
+ const digestQueryText = (text: string): string =>
211
+ createHash('sha256').update(text.trim()).digest('hex');
212
+
213
+ /**
214
+ * Nova query cache key. The model ID, dimension and scheme are part of the key, so it can
215
+ * never collide with the legacy Google key below (or a future Nova generation's).
216
+ */
217
+ export const getQueryEmbeddingCacheKey = (text: string): string => {
218
+ const generation = getSkillEmbeddingGeneration();
219
+
220
+ return `skill-embedding:query:${generation.modelId}:${generation.dimension}:${SKILL_EMBEDDING_SCHEME_VERSION}:${digestQueryText(text)}`;
98
221
  };
99
222
 
100
- export const generateEmbeddingForText = async (text: string): Promise<number[]> => {
223
+ /**
224
+ * Legacy Google query cache key, byte-for-byte main's format, so entries main already cached
225
+ * (same model, same 768-d vectors) stay valid. Cached vectors are also length-checked on read.
226
+ */
227
+ export const getLegacyQueryEmbeddingCacheKey = (text: string): string =>
228
+ `skill-embedding:query:${getLegacyGoogleModel()}:${digestQueryText(text)}`;
229
+
230
+ type QueryEmbeddingRoute = {
231
+ index: SkillEmbeddingSearchIndex;
232
+ cacheKey: (text: string) => string;
233
+ embed: (text: string) => Promise<number[]>;
234
+ };
235
+
236
+ const embedLegacyQuery = async (text: string): Promise<number[]> => {
237
+ const [embedding] = await embedWithLegacyGoogle([text]);
238
+ if (!embedding) throw new SkillEmbeddingError('invalid_response');
239
+ return embedding;
240
+ };
241
+
242
+ const getQueryEmbeddingRoute = (): QueryEmbeddingRoute => {
243
+ const index = getSkillEmbeddingSearchIndex();
244
+
245
+ return index.source === 'nova'
246
+ ? { index, cacheKey: getQueryEmbeddingCacheKey, embed: text => embed(text, 'query') }
247
+ : { index, cacheKey: getLegacyQueryEmbeddingCacheKey, embed: embedLegacyQuery };
248
+ };
249
+
250
+ /** Cache key semantic search uses for `text` under the current read flag. */
251
+ export const getSearchQueryEmbeddingCacheKey = (text: string): string =>
252
+ getQueryEmbeddingRoute().cacheKey(text);
253
+
254
+ const cachedEmbeddingValidator = z.object({ values: z.array(z.number()) });
255
+
256
+ export const getCachedQueryEmbedding = async (text: string): Promise<number[] | null> => {
101
257
  const normalizedText = text.trim();
102
- if (!normalizedText) {
103
- return [];
104
- }
258
+ if (!normalizedText) return null;
259
+
260
+ try {
261
+ const route = getQueryEmbeddingRoute();
262
+ const cached = await cache.get(route.cacheKey(normalizedText));
263
+ if (!cached) return null;
105
264
 
106
- const cachedEmbedding = await getCachedEmbeddingForText(normalizedText);
107
- if (cachedEmbedding) {
108
- return cachedEmbedding;
265
+ const parsed = cachedEmbeddingValidator.parse(JSON.parse(cached));
266
+ // Belt and braces: never return a vector of the wrong size to the vector index.
267
+ if (parsed.values.length !== route.index.dimension) return null;
268
+
269
+ return parsed.values;
270
+ } catch (error) {
271
+ console.warn('Failed to read cached skill embedding', describeEmbeddingError(error));
272
+ return null;
109
273
  }
274
+ };
275
+
276
+ /**
277
+ * Query embedding for semantic search: Nova TEXT_RETRIEVAL when the read flag is on, otherwise
278
+ * Google gemini-embedding-001 (768-d) as on main. Pair it with getSkillEmbeddingSearchIndex().
279
+ */
280
+ export const generateQueryEmbedding = async (text: string): Promise<number[]> => {
281
+ const normalizedText = text.trim();
282
+ if (!normalizedText) return [];
110
283
 
111
- const embeddings = await generateEmbeddingsForTexts([normalizedText]);
112
- const embedding = embeddings[0];
113
- if (!embedding) throw new Error('No embedding returned from Google API');
284
+ const cached = await getCachedQueryEmbedding(normalizedText);
285
+ if (cached) return cached;
286
+
287
+ const route = getQueryEmbeddingRoute();
288
+ const embedding = await route.embed(normalizedText);
114
289
 
115
290
  try {
116
- const cacheKey = getEmbeddingCacheKey(normalizedText);
117
291
  await cache.set(
118
- cacheKey,
292
+ route.cacheKey(normalizedText),
119
293
  JSON.stringify({ values: embedding }),
120
294
  getEmbeddingCacheTtlSeconds()
121
295
  );
122
296
  } catch (error) {
123
- console.warn('Failed to cache skill embedding', error);
297
+ console.warn('Failed to cache skill embedding', describeEmbeddingError(error));
124
298
  }
125
299
 
126
300
  return embedding;
127
301
  };
128
302
 
129
- export const upsertSkillEmbedding = async (
130
- skill: SkillEmbeddingTarget,
131
- updateSkillEmbeddingFn: UpdateSkillEmbeddingFn = updateSkillEmbedding
132
- ): Promise<void> => {
133
- const text = buildSkillEmbeddingText(skill);
134
- if (!text) return;
303
+ // ---------------------------------------------------------------------------------------------
304
+ // Index embeddings (GENERIC_INDEX) for stored skills
305
+ // ---------------------------------------------------------------------------------------------
135
306
 
136
- const embedding = await generateEmbeddingForText(text);
137
- await updateSkillEmbeddingFn(skill.id, embedding, skill.frameworkId);
307
+ const getEmbeddingBatchSize = (): number => {
308
+ const raw = Number(
309
+ environment.SKILL_EMBEDDING_BATCH_SIZE ??
310
+ environment.SKILL_EMBEDDING_BACKFILL_BATCH_SIZE ??
311
+ 25
312
+ );
313
+ return Math.min(Math.max(raw, 1), 100);
138
314
  };
139
315
 
140
- export const upsertSkillEmbeddings = async (
316
+ /** Run `worker` over `items` with at most `limit` in flight, preserving order. */
317
+ const mapWithConcurrency = async <T, R>(
318
+ items: T[],
319
+ limit: number,
320
+ worker: (item: T) => Promise<R>
321
+ ): Promise<R[]> => {
322
+ const results = new Array<R>(items.length);
323
+ let next = 0;
324
+
325
+ const run = async () => {
326
+ while (next < items.length) {
327
+ const index = next;
328
+ next += 1;
329
+ results[index] = await worker(items[index] as T);
330
+ }
331
+ };
332
+
333
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, run));
334
+ return results;
335
+ };
336
+
337
+ /** Nova 2 synchronous InvokeModel embeds one text per call; batching is client-side only. */
338
+ export const generateIndexEmbeddings = async (texts: string[]): Promise<number[][]> =>
339
+ mapWithConcurrency(texts, environment.SKILL_EMBEDDING_CONCURRENCY, text =>
340
+ embed(text, 'index')
341
+ );
342
+
343
+ const defaultUpdateSkillEmbedding: UpdateSkillEmbeddingFn = update => updateSkillEmbedding(update);
344
+
345
+ const defaultUpdateLegacySkillEmbedding: UpdateLegacySkillEmbeddingFn = (
346
+ skillId,
347
+ embedding,
348
+ frameworkId
349
+ ) => updateLegacySkillEmbedding(skillId, embedding, frameworkId);
350
+
351
+ /**
352
+ * Embed and store skills with the current Nova generation. Runs whenever Nova embeddings are
353
+ * enabled, regardless of the read flag, so skills created during the backfill window already
354
+ * have Nova vectors. No-op while disabled, so skill CRUD keeps working locally without AWS.
355
+ */
356
+ export const upsertNovaSkillEmbeddings = async (
141
357
  skills: SkillEmbeddingTarget[],
142
- updateSkillEmbeddingFn: UpdateSkillEmbeddingFn = updateSkillEmbedding
358
+ updateSkillEmbeddingFn: UpdateSkillEmbeddingFn = defaultUpdateSkillEmbedding
143
359
  ): Promise<void> => {
360
+ if (!isSkillEmbeddingEnabled()) return;
361
+
362
+ const generation = getSkillEmbeddingGeneration();
144
363
  const candidates = skills
145
364
  .map(skill => ({ skill, text: buildSkillEmbeddingText(skill) }))
146
365
  .filter(item => Boolean(item.text));
@@ -151,81 +370,350 @@ export const upsertSkillEmbeddings = async (
151
370
 
152
371
  for (let i = 0; i < candidates.length; i += batchSize) {
153
372
  const batch = candidates.slice(i, i + batchSize);
154
- const embeddings = await generateEmbeddingsForTexts(batch.map(item => item.text));
373
+ const embeddings = await generateIndexEmbeddings(batch.map(item => item.text));
155
374
 
156
375
  for (let j = 0; j < batch.length; j += 1) {
157
376
  const item = batch[j];
158
377
  const embedding = embeddings[j];
159
378
  if (!item || !embedding) continue;
160
379
 
161
- await updateSkillEmbeddingFn(item.skill.id, embedding, item.skill.frameworkId);
380
+ await updateSkillEmbeddingFn({
381
+ skillId: item.skill.id,
382
+ frameworkId: item.skill.frameworkId,
383
+ embedding,
384
+ embeddingKey: computeSkillEmbeddingKey(generation, item.text),
385
+ generation,
386
+ source: toSkillEmbeddingSource(item.skill),
387
+ });
162
388
  }
163
389
  }
164
390
  };
165
391
 
166
- let backfillStarted = false;
392
+ /**
393
+ * Keep the legacy Google `s.embedding` current exactly as main does (batched embedContent,
394
+ * 768-d), while semantic search can still read it. No-op without a Google key, as on main.
395
+ */
396
+ export const upsertLegacySkillEmbeddings = async (
397
+ skills: SkillEmbeddingTarget[],
398
+ updateLegacySkillEmbeddingFn: UpdateLegacySkillEmbeddingFn = defaultUpdateLegacySkillEmbedding
399
+ ): Promise<void> => {
400
+ if (!isLegacySkillEmbeddingEnabled()) return;
401
+
402
+ const candidates = skills
403
+ .map(skill => ({ skill, text: buildLegacySkillEmbeddingText(skill) }))
404
+ .filter(item => Boolean(item.text));
405
+
406
+ if (candidates.length === 0) return;
407
+
408
+ const batchSize = getEmbeddingBatchSize();
409
+
410
+ for (let i = 0; i < candidates.length; i += batchSize) {
411
+ const batch = candidates.slice(i, i + batchSize);
412
+ const embeddings = await embedWithLegacyGoogle(batch.map(item => item.text));
167
413
 
168
- const SKILL_EMBEDDING_BACKFILL_LOCK_KEY = 'skill-embeddings:backfill:lock';
414
+ for (let j = 0; j < batch.length; j += 1) {
415
+ const item = batch[j];
416
+ const embedding = embeddings[j];
417
+ if (!item || !embedding) continue;
418
+
419
+ await updateLegacySkillEmbeddingFn(item.skill.id, embedding, item.skill.frameworkId);
420
+ }
421
+ }
422
+ };
423
+
424
+ /**
425
+ * Skill create/update/sync write path: Nova and legacy Google writes run independently of
426
+ * each other and of the read flag. A failure in one never skips the other; the first error
427
+ * is rethrown afterwards so callers still log it.
428
+ */
429
+ export const upsertSkillEmbeddings = async (
430
+ skills: SkillEmbeddingTarget[],
431
+ updateSkillEmbeddingFn: UpdateSkillEmbeddingFn = defaultUpdateSkillEmbedding,
432
+ updateLegacySkillEmbeddingFn: UpdateLegacySkillEmbeddingFn = defaultUpdateLegacySkillEmbedding
433
+ ): Promise<void> => {
434
+ const results = await Promise.allSettled([
435
+ upsertNovaSkillEmbeddings(skills, updateSkillEmbeddingFn),
436
+ upsertLegacySkillEmbeddings(skills, updateLegacySkillEmbeddingFn),
437
+ ]);
438
+
439
+ const failure = results.find(
440
+ (result): result is PromiseRejectedResult => result.status === 'rejected'
441
+ );
442
+ if (failure) throw failure.reason;
443
+ };
444
+
445
+ export const upsertSkillEmbedding = async (
446
+ skill: SkillEmbeddingTarget,
447
+ updateSkillEmbeddingFn: UpdateSkillEmbeddingFn = defaultUpdateSkillEmbedding,
448
+ updateLegacySkillEmbeddingFn: UpdateLegacySkillEmbeddingFn = defaultUpdateLegacySkillEmbedding
449
+ ): Promise<void> =>
450
+ upsertSkillEmbeddings([skill], updateSkillEmbeddingFn, updateLegacySkillEmbeddingFn);
451
+
452
+ /** Log-safe description: our sanitised code, never a provider message or input text. */
453
+ export const describeEmbeddingError = (error: unknown): string => {
454
+ if (isSkillEmbeddingError(error)) return error.message;
455
+ if (error instanceof Error) return error.name;
456
+ return 'Unknown error';
457
+ };
458
+
459
+ // ---------------------------------------------------------------------------------------------
460
+ // Backfill: idempotent, resumable, lock-protected re-embedding of every skill
461
+ // ---------------------------------------------------------------------------------------------
462
+
463
+ export type SkillEmbeddingBackfillStats = {
464
+ scanned: number;
465
+ alreadyCurrent: number;
466
+ embedded: number;
467
+ /** Embedded, but the skill's text changed (or it was deleted) first, so nothing was written. */
468
+ superseded: number;
469
+ failed: number;
470
+ /** Set when the run stopped early (lock lost, throttled past retries, access denied, ...). */
471
+ stoppedReason?: string;
472
+ /** Resume point (last fully processed skill id) when the run stopped early. */
473
+ cursor: string | null;
474
+ };
475
+
476
+ export type SkillEmbeddingBackfillDeps = {
477
+ getCandidates: typeof getSkillEmbeddingCandidates;
478
+ embedIndex: (text: string) => Promise<number[]>;
479
+ update: UpdateSkillEmbeddingFn;
480
+ /**
481
+ * Renews the distributed lease and returns false when it has been lost; the run then stops.
482
+ * Called before every Bedrock attempt (so retries and long pages keep the lease) and again
483
+ * right before every write (so a run that lost the lease never persists).
484
+ */
485
+ keepAlive: () => Promise<boolean>;
486
+ sleep: (ms: number) => Promise<void>;
487
+ };
488
+
489
+ export type SkillEmbeddingBackfillOptions = {
490
+ pageSize?: number;
491
+ /** Count work only; never calls Bedrock or writes. */
492
+ dryRun?: boolean;
493
+ /** Stop after embedding this many skills, written or superseded (bounded runs). */
494
+ maxEmbeddings?: number;
495
+ /** Resume after this skill id. */
496
+ cursor?: string | null;
497
+ maxRetries?: number;
498
+ log?: Pick<Console, 'info' | 'warn' | 'error'>;
499
+ };
500
+
501
+ const defaultBackfillDeps = (): SkillEmbeddingBackfillDeps => ({
502
+ getCandidates: getSkillEmbeddingCandidates,
503
+ embedIndex: text => embed(text, 'index'),
504
+ update: defaultUpdateSkillEmbedding,
505
+ keepAlive: async () => true,
506
+ sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
507
+ });
508
+
509
+ export const runSkillEmbeddingBackfill = async (
510
+ options: SkillEmbeddingBackfillOptions = {},
511
+ overrides: Partial<SkillEmbeddingBackfillDeps> = {}
512
+ ): Promise<SkillEmbeddingBackfillStats> => {
513
+ const deps: SkillEmbeddingBackfillDeps = { ...defaultBackfillDeps(), ...overrides };
514
+ const generation = getSkillEmbeddingGeneration();
515
+ const pageSize = Math.min(Math.max(options.pageSize ?? 100, 1), 500);
516
+ const maxRetries = Math.max(options.maxRetries ?? 5, 0);
517
+ const log = options.log ?? console;
518
+ const stats: SkillEmbeddingBackfillStats = {
519
+ scanned: 0,
520
+ alreadyCurrent: 0,
521
+ embedded: 0,
522
+ superseded: 0,
523
+ failed: 0,
524
+ cursor: options.cursor ?? null,
525
+ };
526
+
527
+ let cursor: string | null = options.cursor ?? null;
528
+
529
+ const stop = (reason: string) => {
530
+ stats.stoppedReason = reason;
531
+ return stats;
532
+ };
533
+
534
+ while (true) {
535
+ if (!(await deps.keepAlive())) return stop('lock_lost');
536
+
537
+ const page = await deps.getCandidates(generation, pageSize, cursor);
538
+ if (page.records.length === 0) break;
539
+
540
+ for (const candidate of page.records) {
541
+ stats.scanned += 1;
542
+
543
+ if (!skillNeedsEmbedding(candidate, generation)) {
544
+ stats.alreadyCurrent += 1;
545
+ stats.cursor = candidate.id;
546
+ continue;
547
+ }
548
+
549
+ // Superseded skills were still embedded (and paid for), so they count toward the cap.
550
+ if (
551
+ options.maxEmbeddings !== undefined &&
552
+ stats.embedded + stats.superseded >= options.maxEmbeddings
553
+ ) {
554
+ return stop('max_embeddings');
555
+ }
556
+
557
+ if (options.dryRun) {
558
+ stats.embedded += 1;
559
+ stats.cursor = candidate.id;
560
+ continue;
561
+ }
562
+
563
+ const text = buildSkillEmbeddingText(candidate);
564
+ let attempt = 0;
565
+
566
+ while (true) {
567
+ if (!(await deps.keepAlive())) return stop('lock_lost');
568
+
569
+ try {
570
+ const embedding = await deps.embedIndex(text);
571
+ // Ownership check right before persisting: the embed (and any retry wait
572
+ // before it) may have outlived the lease, and another runner may hold it now.
573
+ if (!(await deps.keepAlive())) return stop('lock_lost');
574
+
575
+ const written = await deps.update({
576
+ skillId: candidate.id,
577
+ frameworkId: candidate.frameworkId,
578
+ embedding,
579
+ embeddingKey: computeSkillEmbeddingKey(generation, text),
580
+ generation,
581
+ source: toSkillEmbeddingSource(candidate),
582
+ });
583
+ // Not written: the text changed after this page was read, so the live write
584
+ // path owns the new vector (a later run re-embeds it if that write failed).
585
+ if (written) stats.embedded += 1;
586
+ else stats.superseded += 1;
587
+ break;
588
+ } catch (error) {
589
+ const code = isSkillEmbeddingError(error) ? error.code : 'failed';
590
+
591
+ if (isSkillEmbeddingError(error) && error.retryable && attempt < maxRetries) {
592
+ attempt += 1;
593
+ // Exponential backoff with jitter, capped at 30s.
594
+ const delay =
595
+ Math.min(30_000, 500 * 2 ** attempt) * (0.5 + Math.random() / 2);
596
+ await deps.sleep(delay);
597
+ continue;
598
+ }
599
+
600
+ if (code === 'invalid_input' || code === 'invalid_response') {
601
+ // Skill-specific; skip it and keep going. The next run retries it.
602
+ stats.failed += 1;
603
+ log.warn(`Skill embedding skipped for ${candidate.id}: ${code}`);
604
+ break;
605
+ }
606
+
607
+ // Systemic (disabled, access denied, throttled past retries, ...): stop and
608
+ // leave the cursor at the last processed skill so a rerun resumes here.
609
+ log.error(`Skill embedding backfill stopped: ${describeEmbeddingError(error)}`);
610
+ return stop(code);
611
+ }
612
+ }
613
+
614
+ stats.cursor = candidate.id;
615
+ }
616
+
617
+ if (!page.cursor) break;
618
+ cursor = page.cursor;
619
+ }
620
+
621
+ return stats;
622
+ };
623
+
624
+ const SKILL_EMBEDDING_BACKFILL_LOCK_PREFIX = 'skill-embeddings:backfill:lock';
625
+
626
+ const getBackfillLockKey = (): string =>
627
+ `${SKILL_EMBEDDING_BACKFILL_LOCK_PREFIX}:${getSkillEmbeddingGeneration().vectorProperty}`;
169
628
 
170
629
  const getBackfillLockTtlSeconds = (): number => {
171
630
  const raw = Number(environment.SKILL_EMBEDDING_BACKFILL_LOCK_TTL_SECONDS ?? 900);
172
631
  return Math.min(Math.max(raw, 60), 60 * 60 * 24);
173
632
  };
174
633
 
175
- const acquireBackfillLock = async (): Promise<boolean> => {
176
- try {
177
- const redis = cache.redis ?? cache.node;
178
- const lockTtlSeconds = getBackfillLockTtlSeconds();
179
- const result = await redis.set(
180
- SKILL_EMBEDDING_BACKFILL_LOCK_KEY,
181
- new Date().toISOString(),
182
- 'EX',
183
- lockTtlSeconds,
184
- 'NX'
185
- );
634
+ type LockRedis = {
635
+ set: (key: string, value: string, mode: 'EX', ttlSeconds: number, nx: 'NX') => Promise<unknown>;
636
+ eval: (script: string, numKeys: number, ...args: string[]) => Promise<unknown>;
637
+ };
638
+
639
+ const getLockRedis = (): LockRedis => (cache.redis ?? cache.node) as unknown as LockRedis;
640
+
641
+ const RENEW_IF_OWNER =
642
+ 'if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("EXPIRE", KEYS[1], ARGV[2]) end return 0';
643
+ const RELEASE_IF_OWNER =
644
+ 'if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) end return 0';
645
+
646
+ /** Token lease: only the holder can renew or release it, and it expires if the holder dies. */
647
+ export const acquireSkillEmbeddingBackfillLock = async (
648
+ redis: LockRedis = getLockRedis()
649
+ ): Promise<{ keepAlive: () => Promise<boolean>; release: () => Promise<void> } | null> => {
650
+ const key = getBackfillLockKey();
651
+ const token = randomUUID();
652
+ const ttl = getBackfillLockTtlSeconds();
186
653
 
187
- return result === 'OK';
654
+ try {
655
+ if ((await redis.set(key, token, 'EX', ttl, 'NX')) !== 'OK') return null;
188
656
  } catch (error) {
189
- console.error('Unable to acquire skill embedding backfill lock', error);
190
- return false;
657
+ console.error(
658
+ 'Unable to acquire skill embedding backfill lock',
659
+ describeEmbeddingError(error)
660
+ );
661
+ return null;
191
662
  }
192
- };
193
-
194
- export const startSkillEmbeddingBackfill = async (): Promise<void> => {
195
- if (backfillStarted) return;
196
- if (!environment.SKILL_EMBEDDING_BACKFILL_ON_STARTUP) return;
197
- if (!googleApiKey) return;
198
663
 
199
- const lockAcquired = await acquireBackfillLock();
200
- if (!lockAcquired) return;
664
+ return {
665
+ keepAlive: async () => {
666
+ try {
667
+ return Number(await redis.eval(RENEW_IF_OWNER, 1, key, token, String(ttl))) === 1;
668
+ } catch {
669
+ return false;
670
+ }
671
+ },
672
+ release: async () => {
673
+ try {
674
+ await redis.eval(RELEASE_IF_OWNER, 1, key, token);
675
+ } catch {
676
+ // Expires on its own.
677
+ }
678
+ },
679
+ };
680
+ };
201
681
 
202
- console.info('Starting skill embedding backfill');
682
+ let backfillStarted = false;
203
683
 
204
- const pageSize = Number(environment.SKILL_EMBEDDING_BACKFILL_PAGE_SIZE ?? 100);
684
+ /**
685
+ * Startup backfill behind SKILL_EMBEDDING_BACKFILL_ON_STARTUP. One process at a time holds the
686
+ * lock (renewed per page, before each Bedrock attempt and before each write); every run skips skills already embedded with the current
687
+ * generation, so restarts and concurrent deploys resume instead of re-paying for work.
688
+ */
689
+ export const startSkillEmbeddingBackfill = async (): Promise<
690
+ SkillEmbeddingBackfillStats | undefined
691
+ > => {
692
+ if (backfillStarted) return undefined;
693
+ if (!environment.SKILL_EMBEDDING_BACKFILL_ON_STARTUP) return undefined;
694
+ if (!isSkillEmbeddingEnabled()) return undefined;
695
+
696
+ const lock = await acquireSkillEmbeddingBackfillLock();
697
+ if (!lock) return undefined;
698
+
699
+ backfillStarted = true;
700
+ const generation = getSkillEmbeddingGeneration();
701
+ console.info(`Starting skill embedding backfill (${generation.identity})`);
205
702
 
206
703
  try {
207
- let cursor: string | null = null;
208
- while (true) {
209
- const page = await getSkillsMissingEmbedding(pageSize, cursor);
210
- if (page.records.length === 0) break;
211
-
212
- await upsertSkillEmbeddings(
213
- page.records.map(skill => ({
214
- id: skill.id,
215
- frameworkId: skill.frameworkId,
216
- statement: skill.statement,
217
- description: skill.description ?? undefined,
218
- code: skill.code ?? undefined,
219
- }))
220
- );
221
-
222
- if (!page.cursor) break;
223
- cursor = page.cursor;
224
- }
704
+ const stats = await runSkillEmbeddingBackfill(
705
+ { pageSize: Number(environment.SKILL_EMBEDDING_BACKFILL_PAGE_SIZE ?? 100) },
706
+ { keepAlive: lock.keepAlive }
707
+ );
225
708
 
226
- backfillStarted = true;
227
- console.info('Completed skill embedding backfill');
709
+ console.info('Skill embedding backfill finished', stats);
710
+ if (stats.stoppedReason) backfillStarted = false;
711
+ return stats;
228
712
  } catch (error) {
229
- console.error('Skill embedding backfill failed', error);
713
+ backfillStarted = false;
714
+ console.error('Skill embedding backfill failed', describeEmbeddingError(error));
715
+ return undefined;
716
+ } finally {
717
+ await lock.release();
230
718
  }
231
719
  };