@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.
- package/.env.example +14 -0
- package/CHANGELOG.md +41 -0
- package/config/config.dev.json +5 -0
- package/config/config.example.json +5 -0
- package/config/config.production.json +5 -0
- package/config/config.scouts.dev.json +5 -0
- package/config/config.scouts.production.json +5 -0
- package/dist/accesslayer/campaign/audience.d.ts +7 -0
- package/dist/accesslayer/campaign/audience.d.ts.map +1 -0
- package/dist/accesslayer/campaign/create.d.ts +3 -0
- package/dist/accesslayer/campaign/create.d.ts.map +1 -0
- package/dist/accesslayer/campaign/delete.d.ts +2 -0
- package/dist/accesslayer/campaign/delete.d.ts.map +1 -0
- package/dist/accesslayer/campaign/read.d.ts +6 -0
- package/dist/accesslayer/campaign/read.d.ts.map +1 -0
- package/dist/accesslayer/campaign/shared.d.ts +16 -0
- package/dist/accesslayer/campaign/shared.d.ts.map +1 -0
- package/dist/accesslayer/campaign/state.d.ts +4 -0
- package/dist/accesslayer/campaign/state.d.ts.map +1 -0
- package/dist/accesslayer/campaign/update.d.ts +3 -0
- package/dist/accesslayer/campaign/update.d.ts.map +1 -0
- package/dist/accesslayer/credential-share-previews.d.ts +5 -0
- package/dist/accesslayer/credential-share-previews.d.ts.map +1 -0
- package/dist/accesslayer/profile/delete.d.ts.map +1 -1
- package/dist/accesslayer/skill/read.d.ts +12 -2
- package/dist/accesslayer/skill/read.d.ts.map +1 -1
- package/dist/accesslayer/skill/update.d.ts +11 -1
- package/dist/accesslayer/skill/update.d.ts.map +1 -1
- package/dist/app.d.ts +4 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/cache/app-store.caches.d.ts +2 -1
- package/dist/cache/app-store.caches.d.ts.map +1 -1
- package/dist/config/environment.d.ts +268 -178
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/config/skillEmbedding.d.ts +70 -0
- package/dist/config/skillEmbedding.d.ts.map +1 -0
- package/dist/config/skillEmbedding.neo4j.test.d.ts +2 -0
- package/dist/config/skillEmbedding.neo4j.test.d.ts.map +1 -0
- package/dist/config/stageConfig.d.ts +20 -0
- package/dist/config/stageConfig.d.ts.map +1 -1
- package/dist/constants/campaigns.d.ts +3 -0
- package/dist/constants/campaigns.d.ts.map +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/helpers/bedrock-embedding.helpers.d.ts +40 -0
- package/dist/helpers/bedrock-embedding.helpers.d.ts.map +1 -0
- package/dist/helpers/bedrock-embedding.helpers.test.d.ts +2 -0
- package/dist/helpers/bedrock-embedding.helpers.test.d.ts.map +1 -0
- package/dist/helpers/bedrock-embedding.smoke.test.d.ts +2 -0
- package/dist/helpers/bedrock-embedding.smoke.test.d.ts.map +1 -0
- package/dist/helpers/skill-embedding.helpers.d.ts +65 -7
- package/dist/helpers/skill-embedding.helpers.d.ts.map +1 -1
- package/dist/helpers/skill-embedding.helpers.test.d.ts +2 -0
- package/dist/helpers/skill-embedding.helpers.test.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/models/Campaign.d.ts +10 -0
- package/dist/models/Campaign.d.ts.map +1 -0
- package/dist/models/CredentialSharePreview.d.ts +14 -0
- package/dist/models/CredentialSharePreview.d.ts.map +1 -0
- package/dist/models/Integration.d.ts +1 -1
- package/dist/models/Profile.d.ts.map +1 -1
- package/dist/models/campaign-constraints.d.ts +2 -0
- package/dist/models/campaign-constraints.d.ts.map +1 -0
- package/dist/models/index.d.ts +2 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/app-store.d.ts +1 -1
- package/dist/routes/boosts.d.ts +24 -4
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/campaigns.d.ts +1411 -0
- package/dist/routes/campaigns.d.ts.map +1 -0
- package/dist/routes/contracts.d.ts +19 -6
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credential-share-previews.d.ts +39 -0
- package/dist/routes/credential-share-previews.d.ts.map +1 -0
- package/dist/routes/credentials.d.ts +4 -1
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/inbox.d.ts +5 -5
- package/dist/routes/index.d.ts +4 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/integrations.d.ts +3 -3
- package/dist/routes/profile-manager.d.ts +4 -0
- package/dist/routes/profile-manager.d.ts.map +1 -1
- package/dist/routes/profiles.d.ts +44 -0
- package/dist/routes/profiles.d.ts.map +1 -1
- package/dist/routes/skill-frameworks.d.ts +1 -0
- package/dist/routes/skill-frameworks.d.ts.map +1 -1
- package/dist/routes/skills-semantic-search.test.d.ts +2 -0
- package/dist/routes/skills-semantic-search.test.d.ts.map +1 -0
- package/dist/routes/skills.d.ts.map +1 -1
- package/dist/routes/test.d.ts +3 -1
- package/dist/routes/test.d.ts.map +1 -1
- package/dist/seed/seedSkillFrameworks.d.ts +1 -0
- package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
- package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts +2 -0
- package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts.map +1 -0
- package/dist/types/app-manifest.d.ts +2 -2
- package/dist/types/campaign.d.ts +19 -0
- package/dist/types/campaign.d.ts.map +1 -0
- package/dist/types/consentflowcontract.d.ts +1 -1
- package/dist/types/integration.d.ts +3 -3
- package/dist/types/profile.d.ts +9 -0
- package/dist/types/profile.d.ts.map +1 -1
- package/dist/types/skill.d.ts.map +1 -1
- package/package.json +20 -17
- package/project.json +14 -0
- package/scripts/skill-embeddings-backfill.ts +161 -0
- package/serverless-local.yml +4 -0
- package/serverless.function-env.cjs +3 -0
- package/serverless.local.yml +4 -0
- package/serverless.yml +12 -1
- package/src/accesslayer/campaign/README.md +174 -0
- package/src/accesslayer/campaign/audience.ts +152 -0
- package/src/accesslayer/campaign/create.ts +47 -0
- package/src/accesslayer/campaign/delete.ts +18 -0
- package/src/accesslayer/campaign/read.ts +125 -0
- package/src/accesslayer/campaign/shared.ts +92 -0
- package/src/accesslayer/campaign/state.ts +80 -0
- package/src/accesslayer/campaign/update.ts +50 -0
- package/src/accesslayer/credential-share-previews.ts +76 -0
- package/src/accesslayer/profile/delete.ts +6 -0
- package/src/accesslayer/skill/read.ts +56 -16
- package/src/accesslayer/skill/update.ts +43 -1
- package/src/app.ts +9 -0
- package/src/config/environment.test.ts +208 -0
- package/src/config/environment.ts +166 -11
- package/src/config/skillEmbedding.neo4j.test.ts +138 -0
- package/src/config/skillEmbedding.ts +202 -0
- package/src/constants/campaigns.ts +9 -0
- package/src/helpers/bedrock-embedding.helpers.test.ts +208 -0
- package/src/helpers/bedrock-embedding.helpers.ts +243 -0
- package/src/helpers/bedrock-embedding.smoke.test.ts +53 -0
- package/src/helpers/skill-embedding.helpers.test.ts +1044 -0
- package/src/helpers/skill-embedding.helpers.ts +611 -123
- package/src/models/Campaign.ts +32 -0
- package/src/models/CredentialSharePreview.ts +30 -0
- package/src/models/Profile.ts +1 -0
- package/src/models/campaign-constraints.ts +22 -0
- package/src/models/index.ts +29 -1
- package/src/routes/campaigns.ts +179 -0
- package/src/routes/credential-share-previews.ts +74 -0
- package/src/routes/profiles.ts +7 -0
- package/src/routes/skills-semantic-search.test.ts +168 -0
- package/src/routes/skills.ts +42 -6
- package/src/seed/seedSkillFrameworks.embeddings.test.ts +82 -0
- package/src/seed/seedSkillFrameworks.test.ts +6 -0
- package/src/seed/seedSkillFrameworks.ts +83 -18
- package/src/types/campaign.ts +17 -0
- package/src/types/skill.ts +6 -0
- package/test/campaigns.spec.ts +789 -0
- package/test/credential-share-previews.spec.ts +97 -0
- package/test/verification/campaigns.ts +49 -0
- package/vitest.campaigns.config.ts +21 -0
- package/vitest.config.ts +10 -0
- 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 {
|
|
8
|
-
import {
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
54
|
+
// ---------------------------------------------------------------------------------------------
|
|
55
|
+
// Configuration
|
|
56
|
+
// ---------------------------------------------------------------------------------------------
|
|
27
57
|
|
|
28
|
-
|
|
29
|
-
values: z.array(z.number()),
|
|
30
|
-
});
|
|
58
|
+
let cachedGeneration: SkillEmbeddingGeneration | undefined;
|
|
31
59
|
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
113
|
+
// ---------------------------------------------------------------------------------------------
|
|
114
|
+
// Legacy Google gemini-embedding-001 (768-d): kept only for the LC-2206 transition
|
|
115
|
+
// ---------------------------------------------------------------------------------------------
|
|
51
116
|
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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:
|
|
84
|
-
contents:
|
|
85
|
-
config: { outputDimensionality:
|
|
141
|
+
model: getLegacyGoogleModel(),
|
|
142
|
+
contents: texts,
|
|
143
|
+
config: { outputDimensionality: LEGACY_GOOGLE_SKILL_EMBEDDING_DIMENSION },
|
|
86
144
|
});
|
|
87
145
|
|
|
88
|
-
const parsed = await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
return
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
654
|
+
try {
|
|
655
|
+
if ((await redis.set(key, token, 'EX', ttl, 'NX')) !== 'OK') return null;
|
|
188
656
|
} catch (error) {
|
|
189
|
-
console.error(
|
|
190
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
682
|
+
let backfillStarted = false;
|
|
203
683
|
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
227
|
-
|
|
709
|
+
console.info('Skill embedding backfill finished', stats);
|
|
710
|
+
if (stats.stoppedReason) backfillStarted = false;
|
|
711
|
+
return stats;
|
|
228
712
|
} catch (error) {
|
|
229
|
-
|
|
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
|
};
|