@learncard/network-brain-service 4.0.3 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +14 -0
- package/CHANGELOG.md +76 -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/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/credential-share-previews.d.ts +5 -0
- package/dist/accesslayer/credential-share-previews.d.ts.map +1 -0
- package/dist/accesslayer/inbox-batch/schema.d.ts +4 -0
- package/dist/accesslayer/inbox-batch/schema.d.ts.map +1 -0
- package/dist/accesslayer/inbox-batch/schema.test.d.ts +2 -0
- package/dist/accesslayer/inbox-batch/schema.test.d.ts.map +1 -0
- package/dist/accesslayer/inbox-batch/store.d.ts +1 -1
- package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -1
- 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/boost.helpers.d.ts +1 -0
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- 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/routes/workflows.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/esbuildPlugins.cjs +9 -6
- 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/consentflowcontract/relationships/create.ts +3 -1
- package/src/accesslayer/consentflowcontract/relationships/update.ts +5 -1
- package/src/accesslayer/credential-share-previews.ts +76 -0
- package/src/accesslayer/inbox-batch/schema.test.ts +91 -0
- package/src/accesslayer/inbox-batch/schema.ts +54 -0
- package/src/accesslayer/inbox-batch/store.ts +2 -38
- 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/boost-storage.helpers.test.ts +155 -1
- package/src/helpers/boost.helpers.ts +52 -10
- 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/routes/workflows.ts +8 -1
- 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/consentflow.spec.ts +101 -0
- package/test/credential-share-previews.spec.ts +97 -0
- package/test/inbox-batch.spec.ts +189 -1
- package/test/verification/campaigns.ts +49 -0
- package/vitest.campaigns.config.ts +21 -0
- package/vitest.config.ts +11 -0
- package/vitest.integration.config.ts +1 -0
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import type { SkillEmbeddingCandidate } from '@accesslayer/skill/read';
|
|
5
|
+
import type { SkillEmbeddingUpdate } from '@accesslayer/skill/update';
|
|
6
|
+
|
|
7
|
+
const store = vi.hoisted(() => new Map<string, string>());
|
|
8
|
+
|
|
9
|
+
vi.mock('@cache', () => ({
|
|
10
|
+
default: {
|
|
11
|
+
get: vi.fn(async (key: string) => store.get(key)),
|
|
12
|
+
set: vi.fn(async (key: string, value: string) => {
|
|
13
|
+
store.set(key, value);
|
|
14
|
+
return 'OK';
|
|
15
|
+
}),
|
|
16
|
+
node: {},
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const accessLayer = vi.hoisted(() => ({
|
|
21
|
+
getSkillEmbeddingCandidates: vi.fn(),
|
|
22
|
+
updateSkillEmbedding: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('@accesslayer/skill/read', () => ({
|
|
26
|
+
getSkillEmbeddingCandidates: accessLayer.getSkillEmbeddingCandidates,
|
|
27
|
+
}));
|
|
28
|
+
vi.mock('@accesslayer/skill/update', () => ({
|
|
29
|
+
updateSkillEmbedding: accessLayer.updateSkillEmbedding,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const DIMENSION = 1024;
|
|
33
|
+
const MODEL_ID = 'amazon.nova-2-multimodal-embeddings-v1:0';
|
|
34
|
+
|
|
35
|
+
const unitVector = (seed: number): number[] => {
|
|
36
|
+
const vector = Array.from({ length: DIMENSION }, (_, index) => Math.sin(seed + index));
|
|
37
|
+
const norm = Math.hypot(...vector);
|
|
38
|
+
return vector.map(value => value / norm);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const novaBody = (vector: number[]) =>
|
|
42
|
+
new TextEncoder().encode(
|
|
43
|
+
JSON.stringify({ embeddings: [{ embeddingType: 'TEXT', embedding: vector }] })
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
type Helpers = typeof import('./skill-embedding.helpers');
|
|
47
|
+
|
|
48
|
+
const loadHelpers = async (env: Record<string, string> = {}): Promise<Helpers> => {
|
|
49
|
+
vi.resetModules();
|
|
50
|
+
process.env.SKILL_EMBEDDING_BEDROCK_ENABLED = 'true';
|
|
51
|
+
for (const [key, value] of Object.entries(env)) process.env[key] = value;
|
|
52
|
+
return import('./skill-embedding.helpers');
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const ENV_KEYS = [
|
|
56
|
+
'SKILL_EMBEDDING_BEDROCK_ENABLED',
|
|
57
|
+
'SKILL_EMBEDDING_BEDROCK_READ_ENABLED',
|
|
58
|
+
'SKILL_EMBEDDING_GOOGLE_API_KEY',
|
|
59
|
+
'SKILL_EMBEDDING_GOOGLE_MODEL',
|
|
60
|
+
'SKILL_EMBEDDING_BEDROCK_MODEL_ID',
|
|
61
|
+
'SKILL_EMBEDDING_DIMENSION',
|
|
62
|
+
'SKILL_EMBEDDING_CONCURRENCY',
|
|
63
|
+
'SKILL_EMBEDDING_BACKFILL_ON_STARTUP',
|
|
64
|
+
'SKILL_EMBEDDING_BACKFILL_LOCK_TTL_SECONDS',
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const purposeOf = (body: string) => JSON.parse(body).singleEmbeddingParams.embeddingPurpose;
|
|
68
|
+
|
|
69
|
+
describe('skill embedding helpers (Nova 2, mocked Bedrock)', () => {
|
|
70
|
+
let invoke: ReturnType<typeof vi.fn>;
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
store.clear();
|
|
74
|
+
accessLayer.getSkillEmbeddingCandidates.mockReset();
|
|
75
|
+
accessLayer.updateSkillEmbedding.mockReset().mockResolvedValue(true);
|
|
76
|
+
invoke = vi.fn(async () => novaBody(unitVector(1)));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
for (const key of ENV_KEYS) delete process.env[key];
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Nova query path (read flag on).
|
|
84
|
+
describe('query embeddings and cache', () => {
|
|
85
|
+
const NOVA_READ = { SKILL_EMBEDDING_BEDROCK_READ_ENABLED: 'true' };
|
|
86
|
+
|
|
87
|
+
it('puts the model ID and dimension in the Redis key, never the legacy Google key', async () => {
|
|
88
|
+
const helpers = await loadHelpers(NOVA_READ);
|
|
89
|
+
const key = helpers.getQueryEmbeddingCacheKey('data analysis');
|
|
90
|
+
const digest = createHash('sha256').update('data analysis').digest('hex');
|
|
91
|
+
|
|
92
|
+
expect(key).toBe(
|
|
93
|
+
`skill-embedding:query:${MODEL_ID}:${DIMENSION}:index-retrieval-v1:${digest}`
|
|
94
|
+
);
|
|
95
|
+
expect(key).not.toBe(`skill-embedding:query:gemini-embedding-001:${digest}`);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('never reuses a vector cached under the old Google key', async () => {
|
|
99
|
+
const helpers = await loadHelpers(NOVA_READ);
|
|
100
|
+
helpers.setSkillEmbeddingInvokeForTests(invoke as never);
|
|
101
|
+
const digest = createHash('sha256').update('data analysis').digest('hex');
|
|
102
|
+
store.set(
|
|
103
|
+
`skill-embedding:query:gemini-embedding-001:${digest}`,
|
|
104
|
+
JSON.stringify({ values: Array(768).fill(0.1) })
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const vector = await helpers.generateQueryEmbedding('data analysis');
|
|
108
|
+
|
|
109
|
+
expect(vector).toHaveLength(DIMENSION);
|
|
110
|
+
expect(invoke).toHaveBeenCalledTimes(1);
|
|
111
|
+
expect(purposeOf(invoke.mock.calls[0]![0].body)).toBe('TEXT_RETRIEVAL');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('serves repeat queries from the cache and ignores wrong-size cached vectors', async () => {
|
|
115
|
+
const helpers = await loadHelpers(NOVA_READ);
|
|
116
|
+
helpers.setSkillEmbeddingInvokeForTests(invoke as never);
|
|
117
|
+
|
|
118
|
+
await helpers.generateQueryEmbedding('teamwork');
|
|
119
|
+
await helpers.generateQueryEmbedding('teamwork');
|
|
120
|
+
expect(invoke).toHaveBeenCalledTimes(1);
|
|
121
|
+
|
|
122
|
+
store.set(
|
|
123
|
+
helpers.getQueryEmbeddingCacheKey('teamwork'),
|
|
124
|
+
JSON.stringify({ values: [1, 2, 3] })
|
|
125
|
+
);
|
|
126
|
+
expect(await helpers.getCachedQueryEmbedding('teamwork')).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('fails closed with a sanitised error when embeddings are disabled', async () => {
|
|
130
|
+
// The read flag cannot be on with Nova disabled (startup rejects it), so exercise the
|
|
131
|
+
// shared Nova embed call directly.
|
|
132
|
+
const helpers = await loadHelpers({ SKILL_EMBEDDING_BEDROCK_ENABLED: 'false' });
|
|
133
|
+
helpers.setSkillEmbeddingInvokeForTests(invoke as never);
|
|
134
|
+
|
|
135
|
+
await expect(helpers.generateIndexEmbeddings(['teamwork'])).rejects.toMatchObject({
|
|
136
|
+
code: 'disabled',
|
|
137
|
+
});
|
|
138
|
+
expect(invoke).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('index embeddings', () => {
|
|
143
|
+
it('embeds skills with GENERIC_INDEX and stores the vector with its generation key', async () => {
|
|
144
|
+
const helpers = await loadHelpers();
|
|
145
|
+
helpers.setSkillEmbeddingInvokeForTests(invoke as never);
|
|
146
|
+
const updates: SkillEmbeddingUpdate[] = [];
|
|
147
|
+
|
|
148
|
+
await helpers.upsertSkillEmbeddings(
|
|
149
|
+
[
|
|
150
|
+
{
|
|
151
|
+
id: 's1',
|
|
152
|
+
frameworkId: 'fw',
|
|
153
|
+
statement: 'Python',
|
|
154
|
+
description: 'Writes Python',
|
|
155
|
+
},
|
|
156
|
+
{ id: 's2', frameworkId: 'fw', statement: ' ' },
|
|
157
|
+
],
|
|
158
|
+
async update => {
|
|
159
|
+
updates.push(update);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(invoke).toHaveBeenCalledTimes(1);
|
|
165
|
+
expect(purposeOf(invoke.mock.calls[0]![0].body)).toBe('GENERIC_INDEX');
|
|
166
|
+
expect(updates).toHaveLength(1);
|
|
167
|
+
const [update] = updates;
|
|
168
|
+
const generation = helpers.getSkillEmbeddingGeneration();
|
|
169
|
+
expect(update).toMatchObject({ skillId: 's1', frameworkId: 'fw', generation });
|
|
170
|
+
expect(update!.embedding).toHaveLength(DIMENSION);
|
|
171
|
+
expect(update!.embeddingKey).toBe(
|
|
172
|
+
helpers.computeSkillEmbeddingKey(generation, 'Python\nWrites Python')
|
|
173
|
+
);
|
|
174
|
+
expect(generation).toMatchObject({
|
|
175
|
+
vectorProperty: 'embedding_nova2_v1_0_d1024',
|
|
176
|
+
keyProperty: 'embedding_nova2_v1_0_d1024_key',
|
|
177
|
+
indexName: 'skill_embedding_nova2_v1_0_d1024_idx',
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('is a no-op while embeddings are disabled so skill CRUD keeps working', async () => {
|
|
182
|
+
const helpers = await loadHelpers({ SKILL_EMBEDDING_BEDROCK_ENABLED: 'false' });
|
|
183
|
+
const update = vi.fn();
|
|
184
|
+
|
|
185
|
+
await helpers.upsertSkillEmbeddings([{ id: 's1', statement: 'Python' }], update);
|
|
186
|
+
|
|
187
|
+
expect(update).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('keys change when the dimension changes, forcing a re-embed', async () => {
|
|
191
|
+
const a = await loadHelpers();
|
|
192
|
+
const keyA = a.computeSkillEmbeddingKey(a.getSkillEmbeddingGeneration(), 'Python');
|
|
193
|
+
const b = await loadHelpers({ SKILL_EMBEDDING_DIMENSION: '384' });
|
|
194
|
+
const generationB = b.getSkillEmbeddingGeneration();
|
|
195
|
+
|
|
196
|
+
expect(b.computeSkillEmbeddingKey(generationB, 'Python')).not.toBe(keyA);
|
|
197
|
+
expect(generationB.vectorProperty).toBe('embedding_nova2_v1_0_d384');
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('read routing (SKILL_EMBEDDING_BEDROCK_READ_ENABLED)', () => {
|
|
202
|
+
const LEGACY_DIMENSION = 768;
|
|
203
|
+
const legacyVector = () => Array(LEGACY_DIMENSION).fill(0.05);
|
|
204
|
+
const GOOGLE = { SKILL_EMBEDDING_GOOGLE_API_KEY: 'legacy-google-key' };
|
|
205
|
+
|
|
206
|
+
const withFakes = (helpers: Helpers) => {
|
|
207
|
+
const legacyEmbed = vi.fn(async (texts: string[]) => texts.map(() => legacyVector()));
|
|
208
|
+
helpers.setSkillEmbeddingInvokeForTests(invoke as never);
|
|
209
|
+
helpers.setLegacySkillEmbeddingForTests(legacyEmbed);
|
|
210
|
+
return legacyEmbed;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
it("defaults off: legacy index, Google query embeddings, main's cache key", async () => {
|
|
214
|
+
const helpers = await loadHelpers(GOOGLE);
|
|
215
|
+
const legacyEmbed = withFakes(helpers);
|
|
216
|
+
const digest = createHash('sha256').update('data analysis').digest('hex');
|
|
217
|
+
|
|
218
|
+
expect(helpers.isSkillEmbeddingNovaReadEnabled()).toBe(false);
|
|
219
|
+
expect(helpers.getSkillEmbeddingSearchIndex()).toEqual({
|
|
220
|
+
source: 'legacy',
|
|
221
|
+
indexName: 'skill_embedding_vector_idx',
|
|
222
|
+
dimension: LEGACY_DIMENSION,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const vector = await helpers.generateQueryEmbedding(' data analysis ');
|
|
226
|
+
await helpers.generateQueryEmbedding('data analysis');
|
|
227
|
+
|
|
228
|
+
expect(vector).toHaveLength(LEGACY_DIMENSION);
|
|
229
|
+
expect(legacyEmbed).toHaveBeenCalledTimes(1);
|
|
230
|
+
expect(legacyEmbed).toHaveBeenCalledWith(['data analysis']);
|
|
231
|
+
expect(invoke).not.toHaveBeenCalled();
|
|
232
|
+
expect(helpers.getSearchQueryEmbeddingCacheKey('data analysis')).toBe(
|
|
233
|
+
`skill-embedding:query:gemini-embedding-001:${digest}`
|
|
234
|
+
);
|
|
235
|
+
expect([...store.keys()]).toEqual([
|
|
236
|
+
`skill-embedding:query:gemini-embedding-001:${digest}`,
|
|
237
|
+
]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('on: Nova index with TEXT_RETRIEVAL query embeddings and the Nova cache key', async () => {
|
|
241
|
+
const helpers = await loadHelpers({
|
|
242
|
+
...GOOGLE,
|
|
243
|
+
SKILL_EMBEDDING_BEDROCK_READ_ENABLED: 'true',
|
|
244
|
+
});
|
|
245
|
+
const legacyEmbed = withFakes(helpers);
|
|
246
|
+
|
|
247
|
+
expect(helpers.getSkillEmbeddingSearchIndex()).toEqual({
|
|
248
|
+
source: 'nova',
|
|
249
|
+
indexName: 'skill_embedding_nova2_v1_0_d1024_idx',
|
|
250
|
+
dimension: DIMENSION,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const vector = await helpers.generateQueryEmbedding('data analysis');
|
|
254
|
+
|
|
255
|
+
expect(vector).toHaveLength(DIMENSION);
|
|
256
|
+
expect(legacyEmbed).not.toHaveBeenCalled();
|
|
257
|
+
expect(purposeOf(invoke.mock.calls[0]![0].body)).toBe('TEXT_RETRIEVAL');
|
|
258
|
+
expect(helpers.getSearchQueryEmbeddingCacheKey('data analysis')).toBe(
|
|
259
|
+
helpers.getQueryEmbeddingCacheKey('data analysis')
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("flag-off and flag-on cache keys never collide, and neither reads the other's vector", async () => {
|
|
264
|
+
const off = await loadHelpers(GOOGLE);
|
|
265
|
+
const legacyKey = off.getSearchQueryEmbeddingCacheKey('teamwork');
|
|
266
|
+
const on = await loadHelpers({
|
|
267
|
+
...GOOGLE,
|
|
268
|
+
SKILL_EMBEDDING_BEDROCK_READ_ENABLED: 'true',
|
|
269
|
+
});
|
|
270
|
+
const novaKey = on.getSearchQueryEmbeddingCacheKey('teamwork');
|
|
271
|
+
|
|
272
|
+
expect(legacyKey).not.toBe(novaKey);
|
|
273
|
+
expect(novaKey).toContain(`:${DIMENSION}:`);
|
|
274
|
+
|
|
275
|
+
// Even if a wrong-size vector sat under either key, it is never returned.
|
|
276
|
+
store.set(legacyKey, JSON.stringify({ values: unitVector(3) }));
|
|
277
|
+
store.set(novaKey, JSON.stringify({ values: legacyVector() }));
|
|
278
|
+
expect(await off.getCachedQueryEmbedding('teamwork')).toBeNull();
|
|
279
|
+
expect(await on.getCachedQueryEmbedding('teamwork')).toBeNull();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('flipping the flag back off (rollback) returns to the legacy path', async () => {
|
|
283
|
+
const on = await loadHelpers({
|
|
284
|
+
...GOOGLE,
|
|
285
|
+
SKILL_EMBEDDING_BEDROCK_READ_ENABLED: 'true',
|
|
286
|
+
});
|
|
287
|
+
expect(on.getSkillEmbeddingSearchIndex().source).toBe('nova');
|
|
288
|
+
|
|
289
|
+
const off = await loadHelpers({
|
|
290
|
+
...GOOGLE,
|
|
291
|
+
SKILL_EMBEDDING_BEDROCK_READ_ENABLED: 'false',
|
|
292
|
+
});
|
|
293
|
+
const legacyEmbed = withFakes(off);
|
|
294
|
+
expect(off.getSkillEmbeddingSearchIndex().source).toBe('legacy');
|
|
295
|
+
expect(await off.generateQueryEmbedding('teamwork')).toHaveLength(LEGACY_DIMENSION);
|
|
296
|
+
expect(legacyEmbed).toHaveBeenCalledTimes(1);
|
|
297
|
+
expect(invoke).not.toHaveBeenCalled();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('flag off without a Google key fails closed and never calls Bedrock', async () => {
|
|
301
|
+
const helpers = await loadHelpers();
|
|
302
|
+
helpers.setSkillEmbeddingInvokeForTests(invoke as never);
|
|
303
|
+
|
|
304
|
+
await expect(helpers.generateQueryEmbedding('teamwork')).rejects.toMatchObject({
|
|
305
|
+
code: 'disabled',
|
|
306
|
+
});
|
|
307
|
+
expect(invoke).not.toHaveBeenCalled();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe('write path (independent of the read flag)', () => {
|
|
312
|
+
const GOOGLE = { SKILL_EMBEDDING_GOOGLE_API_KEY: 'legacy-google-key' };
|
|
313
|
+
const skills = [
|
|
314
|
+
{ id: 's1', frameworkId: 'fw', statement: 'Python', description: 'Writes Python' },
|
|
315
|
+
{ id: 's2', statement: 'SQL' },
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
it.each(['false', 'true'])(
|
|
319
|
+
'read flag %s: writes Nova (GENERIC_INDEX) and legacy Google vectors for every skill',
|
|
320
|
+
async readFlag => {
|
|
321
|
+
const helpers = await loadHelpers({
|
|
322
|
+
...GOOGLE,
|
|
323
|
+
SKILL_EMBEDDING_BEDROCK_READ_ENABLED: readFlag,
|
|
324
|
+
});
|
|
325
|
+
const legacyEmbed = vi.fn(async (texts: string[]) =>
|
|
326
|
+
texts.map(() => Array(768).fill(0.05))
|
|
327
|
+
);
|
|
328
|
+
helpers.setSkillEmbeddingInvokeForTests(invoke as never);
|
|
329
|
+
helpers.setLegacySkillEmbeddingForTests(legacyEmbed);
|
|
330
|
+
const novaUpdate = vi.fn(async (_update: SkillEmbeddingUpdate) => true);
|
|
331
|
+
const legacyUpdate = vi.fn(
|
|
332
|
+
async (_skillId: string, _embedding: number[], _frameworkId?: string) => true
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
await helpers.upsertSkillEmbeddings(skills, novaUpdate, legacyUpdate);
|
|
336
|
+
|
|
337
|
+
expect(invoke).toHaveBeenCalledTimes(2);
|
|
338
|
+
for (const call of invoke.mock.calls) {
|
|
339
|
+
expect(purposeOf(call[0].body)).toBe('GENERIC_INDEX');
|
|
340
|
+
}
|
|
341
|
+
expect(novaUpdate.mock.calls.map(([update]) => update.skillId)).toEqual([
|
|
342
|
+
's1',
|
|
343
|
+
's2',
|
|
344
|
+
]);
|
|
345
|
+
expect(novaUpdate.mock.calls[0]![0]).toMatchObject({
|
|
346
|
+
frameworkId: 'fw',
|
|
347
|
+
generation: { vectorProperty: 'embedding_nova2_v1_0_d1024' },
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Main's legacy request: one batched Google call with main's text.
|
|
351
|
+
expect(legacyEmbed).toHaveBeenCalledWith(['Python\nWrites Python', 'SQL']);
|
|
352
|
+
expect(legacyUpdate.mock.calls.map(call => [call[0], call[2]])).toEqual([
|
|
353
|
+
['s1', 'fw'],
|
|
354
|
+
['s2', undefined],
|
|
355
|
+
]);
|
|
356
|
+
expect(legacyUpdate.mock.calls[0]![1]).toHaveLength(768);
|
|
357
|
+
}
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
it('without a Google key, only Nova is written (legacy is a quiet no-op)', async () => {
|
|
361
|
+
const helpers = await loadHelpers();
|
|
362
|
+
helpers.setSkillEmbeddingInvokeForTests(invoke as never);
|
|
363
|
+
const novaUpdate = vi.fn(async () => true);
|
|
364
|
+
const legacyUpdate = vi.fn(async () => true);
|
|
365
|
+
|
|
366
|
+
await helpers.upsertSkillEmbeddings(skills, novaUpdate, legacyUpdate);
|
|
367
|
+
|
|
368
|
+
expect(novaUpdate).toHaveBeenCalledTimes(2);
|
|
369
|
+
expect(legacyUpdate).not.toHaveBeenCalled();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('a Nova failure never skips the legacy write (and vice versa); the error still surfaces', async () => {
|
|
373
|
+
const helpers = await loadHelpers(GOOGLE);
|
|
374
|
+
helpers.setSkillEmbeddingInvokeForTests(
|
|
375
|
+
vi.fn(async () => {
|
|
376
|
+
throw Object.assign(new Error('denied'), { name: 'AccessDeniedException' });
|
|
377
|
+
}) as never
|
|
378
|
+
);
|
|
379
|
+
helpers.setLegacySkillEmbeddingForTests(async texts =>
|
|
380
|
+
texts.map(() => Array(768).fill(0.05))
|
|
381
|
+
);
|
|
382
|
+
const legacyUpdate = vi.fn(async () => true);
|
|
383
|
+
|
|
384
|
+
await expect(
|
|
385
|
+
helpers.upsertSkillEmbeddings(
|
|
386
|
+
skills,
|
|
387
|
+
vi.fn(async () => true),
|
|
388
|
+
legacyUpdate
|
|
389
|
+
)
|
|
390
|
+
).rejects.toBeDefined();
|
|
391
|
+
expect(legacyUpdate).toHaveBeenCalledTimes(2);
|
|
392
|
+
|
|
393
|
+
const second = await loadHelpers(GOOGLE);
|
|
394
|
+
second.setSkillEmbeddingInvokeForTests(invoke as never);
|
|
395
|
+
second.setLegacySkillEmbeddingForTests(async () => {
|
|
396
|
+
throw new Error('google down');
|
|
397
|
+
});
|
|
398
|
+
const novaUpdate = vi.fn(async () => true);
|
|
399
|
+
|
|
400
|
+
await expect(
|
|
401
|
+
second.upsertSkillEmbeddings(
|
|
402
|
+
skills,
|
|
403
|
+
novaUpdate,
|
|
404
|
+
vi.fn(async () => true)
|
|
405
|
+
)
|
|
406
|
+
).rejects.toThrow('google down');
|
|
407
|
+
expect(novaUpdate).toHaveBeenCalledTimes(2);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('with Nova disabled, legacy writes still happen exactly as on main', async () => {
|
|
411
|
+
const helpers = await loadHelpers({
|
|
412
|
+
...GOOGLE,
|
|
413
|
+
SKILL_EMBEDDING_BEDROCK_ENABLED: 'false',
|
|
414
|
+
});
|
|
415
|
+
helpers.setSkillEmbeddingInvokeForTests(invoke as never);
|
|
416
|
+
helpers.setLegacySkillEmbeddingForTests(async texts =>
|
|
417
|
+
texts.map(() => Array(768).fill(0.05))
|
|
418
|
+
);
|
|
419
|
+
const novaUpdate = vi.fn(async () => true);
|
|
420
|
+
const legacyUpdate = vi.fn(async () => true);
|
|
421
|
+
|
|
422
|
+
await helpers.upsertSkillEmbeddings(skills, novaUpdate, legacyUpdate);
|
|
423
|
+
|
|
424
|
+
expect(invoke).not.toHaveBeenCalled();
|
|
425
|
+
expect(novaUpdate).not.toHaveBeenCalled();
|
|
426
|
+
expect(legacyUpdate).toHaveBeenCalledTimes(2);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe('backfill', () => {
|
|
431
|
+
const candidate = (
|
|
432
|
+
helpers: Helpers,
|
|
433
|
+
id: string,
|
|
434
|
+
state: 'missing' | 'current' | 'stale',
|
|
435
|
+
statement = `Skill ${id}`
|
|
436
|
+
): SkillEmbeddingCandidate => ({
|
|
437
|
+
id,
|
|
438
|
+
frameworkId: 'fw',
|
|
439
|
+
statement,
|
|
440
|
+
description: null,
|
|
441
|
+
code: null,
|
|
442
|
+
hasEmbedding: state !== 'missing',
|
|
443
|
+
embeddingKey:
|
|
444
|
+
state === 'current'
|
|
445
|
+
? helpers.computeSkillEmbeddingKey(
|
|
446
|
+
helpers.getSkillEmbeddingGeneration(),
|
|
447
|
+
statement
|
|
448
|
+
)
|
|
449
|
+
: state === 'stale'
|
|
450
|
+
? 'old-key'
|
|
451
|
+
: null,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const pages = (...records: SkillEmbeddingCandidate[][]) => {
|
|
455
|
+
const fn = vi.fn();
|
|
456
|
+
records.forEach((page, index) => {
|
|
457
|
+
const last = page[page.length - 1];
|
|
458
|
+
fn.mockResolvedValueOnce({
|
|
459
|
+
records: page,
|
|
460
|
+
hasMore: index < records.length - 1,
|
|
461
|
+
cursor: index < records.length - 1 && last ? last.id : null,
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
fn.mockResolvedValue({ records: [], hasMore: false, cursor: null });
|
|
465
|
+
return fn;
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
it('skips skills already embedded with the current generation and re-embeds missing or stale ones', async () => {
|
|
469
|
+
const helpers = await loadHelpers();
|
|
470
|
+
const embedIndex = vi.fn(async () => unitVector(2));
|
|
471
|
+
const update = vi.fn(async () => true);
|
|
472
|
+
|
|
473
|
+
const stats = await helpers.runSkillEmbeddingBackfill(
|
|
474
|
+
{ pageSize: 2 },
|
|
475
|
+
{
|
|
476
|
+
getCandidates: pages(
|
|
477
|
+
[candidate(helpers, 'a', 'current'), candidate(helpers, 'b', 'missing')],
|
|
478
|
+
[candidate(helpers, 'c', 'stale')]
|
|
479
|
+
),
|
|
480
|
+
embedIndex,
|
|
481
|
+
update,
|
|
482
|
+
keepAlive: async () => true,
|
|
483
|
+
sleep: async () => undefined,
|
|
484
|
+
}
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
expect(stats).toMatchObject({
|
|
488
|
+
scanned: 3,
|
|
489
|
+
alreadyCurrent: 1,
|
|
490
|
+
embedded: 2,
|
|
491
|
+
failed: 0,
|
|
492
|
+
cursor: 'c',
|
|
493
|
+
});
|
|
494
|
+
expect(stats.stoppedReason).toBeUndefined();
|
|
495
|
+
expect(
|
|
496
|
+
update.mock.calls
|
|
497
|
+
.map(call => (call as unknown[])[0] as SkillEmbeddingUpdate)
|
|
498
|
+
.map(u => u.skillId)
|
|
499
|
+
).toEqual(['b', 'c']);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('is idempotent: a second run over the same data embeds nothing', async () => {
|
|
503
|
+
const helpers = await loadHelpers();
|
|
504
|
+
const embedIndex = vi.fn(async () => unitVector(3));
|
|
505
|
+
|
|
506
|
+
const stats = await helpers.runSkillEmbeddingBackfill(
|
|
507
|
+
{},
|
|
508
|
+
{
|
|
509
|
+
getCandidates: pages([
|
|
510
|
+
candidate(helpers, 'a', 'current'),
|
|
511
|
+
candidate(helpers, 'b', 'current'),
|
|
512
|
+
]),
|
|
513
|
+
embedIndex,
|
|
514
|
+
update: vi.fn(),
|
|
515
|
+
keepAlive: async () => true,
|
|
516
|
+
sleep: async () => undefined,
|
|
517
|
+
}
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
expect(stats).toMatchObject({ embedded: 0, alreadyCurrent: 2 });
|
|
521
|
+
expect(embedIndex).not.toHaveBeenCalled();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('dry run counts work without calling Bedrock or writing', async () => {
|
|
525
|
+
const helpers = await loadHelpers();
|
|
526
|
+
const embedIndex = vi.fn();
|
|
527
|
+
const update = vi.fn();
|
|
528
|
+
|
|
529
|
+
const stats = await helpers.runSkillEmbeddingBackfill(
|
|
530
|
+
{ dryRun: true },
|
|
531
|
+
{
|
|
532
|
+
getCandidates: pages([
|
|
533
|
+
candidate(helpers, 'a', 'missing'),
|
|
534
|
+
candidate(helpers, 'b', 'stale'),
|
|
535
|
+
]),
|
|
536
|
+
embedIndex,
|
|
537
|
+
update,
|
|
538
|
+
keepAlive: async () => true,
|
|
539
|
+
sleep: async () => undefined,
|
|
540
|
+
}
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
expect(stats.embedded).toBe(2);
|
|
544
|
+
expect(embedIndex).not.toHaveBeenCalled();
|
|
545
|
+
expect(update).not.toHaveBeenCalled();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('retries throttling with backoff, then stops with a resumable cursor', async () => {
|
|
549
|
+
const helpers = await loadHelpers();
|
|
550
|
+
const throttled = new helpers.SkillEmbeddingError('throttled', 429);
|
|
551
|
+
const embedIndex = vi
|
|
552
|
+
.fn()
|
|
553
|
+
.mockResolvedValueOnce(unitVector(4))
|
|
554
|
+
.mockRejectedValue(throttled);
|
|
555
|
+
const sleep = vi.fn(async () => undefined);
|
|
556
|
+
|
|
557
|
+
const stats = await helpers.runSkillEmbeddingBackfill(
|
|
558
|
+
{ maxRetries: 2 },
|
|
559
|
+
{
|
|
560
|
+
getCandidates: pages([
|
|
561
|
+
candidate(helpers, 'a', 'missing'),
|
|
562
|
+
candidate(helpers, 'b', 'missing'),
|
|
563
|
+
]),
|
|
564
|
+
embedIndex,
|
|
565
|
+
update: vi.fn(async () => true),
|
|
566
|
+
keepAlive: async () => true,
|
|
567
|
+
sleep,
|
|
568
|
+
// Silence expected error output.
|
|
569
|
+
}
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
expect(stats).toMatchObject({ embedded: 1, stoppedReason: 'throttled', cursor: 'a' });
|
|
573
|
+
expect(sleep).toHaveBeenCalledTimes(2);
|
|
574
|
+
expect(embedIndex).toHaveBeenCalledTimes(4);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('skips a skill Bedrock rejects as invalid and continues', async () => {
|
|
578
|
+
const helpers = await loadHelpers();
|
|
579
|
+
const embedIndex = vi
|
|
580
|
+
.fn()
|
|
581
|
+
.mockRejectedValueOnce(new helpers.SkillEmbeddingError('invalid_input', 400))
|
|
582
|
+
.mockResolvedValue(unitVector(5));
|
|
583
|
+
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
584
|
+
|
|
585
|
+
const stats = await helpers.runSkillEmbeddingBackfill(
|
|
586
|
+
{ log },
|
|
587
|
+
{
|
|
588
|
+
getCandidates: pages([
|
|
589
|
+
candidate(helpers, 'a', 'missing'),
|
|
590
|
+
candidate(helpers, 'b', 'missing'),
|
|
591
|
+
]),
|
|
592
|
+
embedIndex,
|
|
593
|
+
update: vi.fn(async () => true),
|
|
594
|
+
keepAlive: async () => true,
|
|
595
|
+
sleep: async () => undefined,
|
|
596
|
+
}
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
expect(stats).toMatchObject({ embedded: 1, failed: 1, cursor: 'b' });
|
|
600
|
+
expect(log.warn).toHaveBeenCalledWith('Skill embedding skipped for a: invalid_input');
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('stops when the distributed lock is lost', async () => {
|
|
604
|
+
const helpers = await loadHelpers();
|
|
605
|
+
const getCandidates = pages([candidate(helpers, 'a', 'missing')]);
|
|
606
|
+
|
|
607
|
+
const stats = await helpers.runSkillEmbeddingBackfill(
|
|
608
|
+
{},
|
|
609
|
+
{
|
|
610
|
+
getCandidates,
|
|
611
|
+
embedIndex: vi.fn(),
|
|
612
|
+
update: vi.fn(),
|
|
613
|
+
keepAlive: async () => false,
|
|
614
|
+
sleep: async () => undefined,
|
|
615
|
+
}
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
expect(stats.stoppedReason).toBe('lock_lost');
|
|
619
|
+
expect(getCandidates).not.toHaveBeenCalled();
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it('resumes from a cursor and honours maxEmbeddings', async () => {
|
|
623
|
+
const helpers = await loadHelpers();
|
|
624
|
+
const getCandidates = pages([
|
|
625
|
+
candidate(helpers, 'c', 'missing'),
|
|
626
|
+
candidate(helpers, 'd', 'missing'),
|
|
627
|
+
]);
|
|
628
|
+
|
|
629
|
+
const stats = await helpers.runSkillEmbeddingBackfill(
|
|
630
|
+
{ cursor: 'b', maxEmbeddings: 1 },
|
|
631
|
+
{
|
|
632
|
+
getCandidates,
|
|
633
|
+
embedIndex: vi.fn(async () => unitVector(6)),
|
|
634
|
+
update: vi.fn(async () => true),
|
|
635
|
+
keepAlive: async () => true,
|
|
636
|
+
sleep: async () => undefined,
|
|
637
|
+
}
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
expect(getCandidates.mock.calls[0]![2]).toBe('b');
|
|
641
|
+
expect(stats).toMatchObject({
|
|
642
|
+
embedded: 1,
|
|
643
|
+
stoppedReason: 'max_embeddings',
|
|
644
|
+
cursor: 'c',
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('counts superseded embeddings toward maxEmbeddings so distinct skills stay bounded', async () => {
|
|
649
|
+
const helpers = await loadHelpers();
|
|
650
|
+
const firstPage = Array.from({ length: 100 }, (_, i) =>
|
|
651
|
+
candidate(helpers, `s${String(i).padStart(3, '0')}`, i % 2 ? 'stale' : 'missing')
|
|
652
|
+
);
|
|
653
|
+
const getCandidates = pages(firstPage, [candidate(helpers, 'z', 'missing')]);
|
|
654
|
+
const embedIndex = vi.fn(async () => unitVector(7));
|
|
655
|
+
// Source changed or skill deleted after the page was read: CAS write matches nothing.
|
|
656
|
+
const update = vi.fn(async () => false);
|
|
657
|
+
|
|
658
|
+
const stats = await helpers.runSkillEmbeddingBackfill(
|
|
659
|
+
{ maxEmbeddings: 1, pageSize: 100 },
|
|
660
|
+
{
|
|
661
|
+
getCandidates,
|
|
662
|
+
embedIndex,
|
|
663
|
+
update,
|
|
664
|
+
keepAlive: async () => true,
|
|
665
|
+
sleep: async () => undefined,
|
|
666
|
+
}
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
expect(embedIndex).toHaveBeenCalledTimes(1);
|
|
670
|
+
expect(update).toHaveBeenCalledTimes(1);
|
|
671
|
+
expect(getCandidates).toHaveBeenCalledTimes(1);
|
|
672
|
+
expect(stats).toMatchObject({
|
|
673
|
+
embedded: 0,
|
|
674
|
+
superseded: 1,
|
|
675
|
+
stoppedReason: 'max_embeddings',
|
|
676
|
+
cursor: 's000',
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
describe('backfill lock', () => {
|
|
682
|
+
const fakeRedis = () => {
|
|
683
|
+
const data = new Map<string, string>();
|
|
684
|
+
return {
|
|
685
|
+
data,
|
|
686
|
+
set: vi.fn(
|
|
687
|
+
async (key: string, value: string, _ex: string, _ttl: number, nx: string) => {
|
|
688
|
+
if (nx === 'NX' && data.has(key)) return null;
|
|
689
|
+
data.set(key, value);
|
|
690
|
+
return 'OK';
|
|
691
|
+
}
|
|
692
|
+
),
|
|
693
|
+
eval: vi.fn(async (script: string, _n: number, key: string, token: string) => {
|
|
694
|
+
if (data.get(key) !== token) return 0;
|
|
695
|
+
if (script.includes('DEL')) data.delete(key);
|
|
696
|
+
return 1;
|
|
697
|
+
}),
|
|
698
|
+
};
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
it('allows one holder per generation and only the holder can renew or release', async () => {
|
|
702
|
+
const helpers = await loadHelpers();
|
|
703
|
+
const redis = fakeRedis();
|
|
704
|
+
|
|
705
|
+
const first = await helpers.acquireSkillEmbeddingBackfillLock(redis);
|
|
706
|
+
const second = await helpers.acquireSkillEmbeddingBackfillLock(redis);
|
|
707
|
+
|
|
708
|
+
expect(first).not.toBeNull();
|
|
709
|
+
expect(second).toBeNull();
|
|
710
|
+
expect([...redis.data.keys()]).toEqual([
|
|
711
|
+
'skill-embeddings:backfill:lock:embedding_nova2_v1_0_d1024',
|
|
712
|
+
]);
|
|
713
|
+
expect(await first!.keepAlive()).toBe(true);
|
|
714
|
+
|
|
715
|
+
// Simulate expiry and takeover by another process.
|
|
716
|
+
redis.data.set('skill-embeddings:backfill:lock:embedding_nova2_v1_0_d1024', 'other');
|
|
717
|
+
expect(await first!.keepAlive()).toBe(false);
|
|
718
|
+
await first!.release();
|
|
719
|
+
expect(
|
|
720
|
+
redis.data.get('skill-embeddings:backfill:lock:embedding_nova2_v1_0_d1024')
|
|
721
|
+
).toBe('other');
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// Review r4339891845: a vector computed from old text must never replace a newer one.
|
|
726
|
+
describe('source compare-and-set (backfill vs live writes)', () => {
|
|
727
|
+
type FakeSkillNode = {
|
|
728
|
+
statement: string;
|
|
729
|
+
description: string | null;
|
|
730
|
+
vector?: number[];
|
|
731
|
+
key?: string;
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* In-memory stand-in for buildSkillEmbeddingUpdateQuery: writes only when the node's
|
|
736
|
+
* statement/description still equal the source the vector was computed from. An update
|
|
737
|
+
* with no source snapshot (the pre-fix shape) is written unconditionally, exactly as the
|
|
738
|
+
* old identity-only MATCH ... SET did. The real Cypher is covered by
|
|
739
|
+
* skillEmbedding.neo4j.test.ts against a local Neo4j.
|
|
740
|
+
*/
|
|
741
|
+
const fakeGraph = (nodes: Record<string, FakeSkillNode>) => {
|
|
742
|
+
const writes: string[] = [];
|
|
743
|
+
const update = vi.fn(async (u: SkillEmbeddingUpdate) => {
|
|
744
|
+
const node = nodes[u.skillId];
|
|
745
|
+
if (!node) return false;
|
|
746
|
+
const source = (u as { source?: { statement: string; description: string } })
|
|
747
|
+
.source;
|
|
748
|
+
if (
|
|
749
|
+
source &&
|
|
750
|
+
(source.statement !== node.statement ||
|
|
751
|
+
source.description !== (node.description ?? ''))
|
|
752
|
+
) {
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
node.vector = u.embedding;
|
|
756
|
+
node.key = u.embeddingKey;
|
|
757
|
+
writes.push(`${u.skillId}:${u.embeddingKey.slice(0, 8)}`);
|
|
758
|
+
return true;
|
|
759
|
+
});
|
|
760
|
+
return { nodes, update, writes };
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
const candidateOf = (id: string, node: FakeSkillNode): SkillEmbeddingCandidate => ({
|
|
764
|
+
id,
|
|
765
|
+
frameworkId: 'fw',
|
|
766
|
+
statement: node.statement,
|
|
767
|
+
description: node.description,
|
|
768
|
+
code: null,
|
|
769
|
+
hasEmbedding: Boolean(node.vector),
|
|
770
|
+
embeddingKey: node.key ?? null,
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it('a live edit that lands while the backfill is embedding the old text wins', async () => {
|
|
774
|
+
const helpers = await loadHelpers();
|
|
775
|
+
const generation = helpers.getSkillEmbeddingGeneration();
|
|
776
|
+
const graph = fakeGraph({ s1: { statement: 'Old text', description: null } });
|
|
777
|
+
const oldVector = unitVector(10);
|
|
778
|
+
// Mocked Bedrock for the live write; nothing here reaches AWS.
|
|
779
|
+
helpers.setSkillEmbeddingInvokeForTests((async () =>
|
|
780
|
+
novaBody(unitVector(11))) as never);
|
|
781
|
+
|
|
782
|
+
const stats = await helpers.runSkillEmbeddingBackfill(
|
|
783
|
+
{},
|
|
784
|
+
{
|
|
785
|
+
getCandidates: vi
|
|
786
|
+
.fn()
|
|
787
|
+
.mockResolvedValueOnce({
|
|
788
|
+
// Backfill reads the old text...
|
|
789
|
+
records: [candidateOf('s1', graph.nodes.s1!)],
|
|
790
|
+
hasMore: false,
|
|
791
|
+
cursor: null,
|
|
792
|
+
})
|
|
793
|
+
.mockResolvedValue({ records: [], hasMore: false, cursor: null }),
|
|
794
|
+
embedIndex: vi.fn(async () => {
|
|
795
|
+
// ...and while Bedrock embeds it, a skill update commits new text and
|
|
796
|
+
// the live write path stores the new vector first.
|
|
797
|
+
graph.nodes.s1!.statement = 'New text';
|
|
798
|
+
await helpers.upsertNovaSkillEmbeddings(
|
|
799
|
+
[{ id: 's1', frameworkId: 'fw', statement: 'New text' }],
|
|
800
|
+
graph.update
|
|
801
|
+
);
|
|
802
|
+
return oldVector;
|
|
803
|
+
}),
|
|
804
|
+
update: graph.update,
|
|
805
|
+
keepAlive: async () => true,
|
|
806
|
+
sleep: async () => undefined,
|
|
807
|
+
}
|
|
808
|
+
);
|
|
809
|
+
|
|
810
|
+
expect(graph.nodes.s1!.vector).not.toEqual(oldVector);
|
|
811
|
+
expect(graph.nodes.s1!.key).toBe(
|
|
812
|
+
helpers.computeSkillEmbeddingKey(generation, 'New text')
|
|
813
|
+
);
|
|
814
|
+
expect(stats).toMatchObject({ embedded: 0, superseded: 1, cursor: 's1' });
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it('two live writes that finish out of order keep the newest text', async () => {
|
|
818
|
+
const helpers = await loadHelpers();
|
|
819
|
+
const generation = helpers.getSkillEmbeddingGeneration();
|
|
820
|
+
const graph = fakeGraph({ s1: { statement: 'First', description: 'd' } });
|
|
821
|
+
|
|
822
|
+
// Edit A stores "First"; edit B then commits "Second" and stores its vector before
|
|
823
|
+
// A's slower Bedrock call returns.
|
|
824
|
+
let releaseA: () => void = () => undefined;
|
|
825
|
+
const aGate = new Promise<void>(resolve => {
|
|
826
|
+
releaseA = resolve;
|
|
827
|
+
});
|
|
828
|
+
helpers.setSkillEmbeddingInvokeForTests((async (request: { body: string }) => {
|
|
829
|
+
const text = JSON.parse(request.body).singleEmbeddingParams.text.value as string;
|
|
830
|
+
if (text.startsWith('First')) await aGate;
|
|
831
|
+
return novaBody(unitVector(text.startsWith('First') ? 20 : 21));
|
|
832
|
+
}) as never);
|
|
833
|
+
|
|
834
|
+
const writeA = helpers.upsertNovaSkillEmbeddings(
|
|
835
|
+
[{ id: 's1', frameworkId: 'fw', statement: 'First', description: 'd' }],
|
|
836
|
+
graph.update
|
|
837
|
+
);
|
|
838
|
+
graph.nodes.s1!.statement = 'Second';
|
|
839
|
+
await helpers.upsertNovaSkillEmbeddings(
|
|
840
|
+
[{ id: 's1', frameworkId: 'fw', statement: 'Second', description: 'd' }],
|
|
841
|
+
graph.update
|
|
842
|
+
);
|
|
843
|
+
releaseA();
|
|
844
|
+
await writeA;
|
|
845
|
+
|
|
846
|
+
expect(graph.nodes.s1!.key).toBe(
|
|
847
|
+
helpers.computeSkillEmbeddingKey(generation, 'Second\nd')
|
|
848
|
+
);
|
|
849
|
+
expect(graph.nodes.s1!.vector![0]).toBeCloseTo(unitVector(21)[0]!, 9);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
it('sends the raw source snapshot with every Nova write', async () => {
|
|
853
|
+
const helpers = await loadHelpers();
|
|
854
|
+
helpers.setSkillEmbeddingInvokeForTests(invoke as never);
|
|
855
|
+
const update = vi.fn(async (_update: SkillEmbeddingUpdate) => true);
|
|
856
|
+
|
|
857
|
+
await helpers.upsertNovaSkillEmbeddings(
|
|
858
|
+
[{ id: 's1', frameworkId: 'fw', statement: ' Python ', description: null }],
|
|
859
|
+
update
|
|
860
|
+
);
|
|
861
|
+
|
|
862
|
+
expect(update.mock.calls[0]![0]).toMatchObject({
|
|
863
|
+
source: { statement: ' Python ', description: '' },
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// Review r4339891861: the lease must stay held (and be checked) while a page is processed.
|
|
869
|
+
describe('backfill lease during a page (fake clock)', () => {
|
|
870
|
+
const LOCK_KEY = 'skill-embeddings:backfill:lock:embedding_nova2_v1_0_d1024';
|
|
871
|
+
|
|
872
|
+
/** Redis double with real TTL semantics driven by a fake clock (milliseconds). */
|
|
873
|
+
const clockedRedis = () => {
|
|
874
|
+
const clock = { now: 0 };
|
|
875
|
+
const data = new Map<string, { value: string; expiresAt: number }>();
|
|
876
|
+
const live = (key: string) => {
|
|
877
|
+
const entry = data.get(key);
|
|
878
|
+
if (entry && entry.expiresAt <= clock.now) data.delete(key);
|
|
879
|
+
return data.get(key);
|
|
880
|
+
};
|
|
881
|
+
return {
|
|
882
|
+
clock,
|
|
883
|
+
holder: () => live(LOCK_KEY)?.value,
|
|
884
|
+
set: vi.fn(
|
|
885
|
+
async (key: string, value: string, _ex: string, ttl: number, nx: string) => {
|
|
886
|
+
if (nx === 'NX' && live(key)) return null;
|
|
887
|
+
data.set(key, { value, expiresAt: clock.now + ttl * 1000 });
|
|
888
|
+
return 'OK';
|
|
889
|
+
}
|
|
890
|
+
),
|
|
891
|
+
eval: vi.fn(
|
|
892
|
+
async (
|
|
893
|
+
script: string,
|
|
894
|
+
_n: number,
|
|
895
|
+
key: string,
|
|
896
|
+
token: string,
|
|
897
|
+
ttl?: string
|
|
898
|
+
) => {
|
|
899
|
+
const entry = live(key);
|
|
900
|
+
if (!entry || entry.value !== token) return 0;
|
|
901
|
+
if (script.includes('DEL')) data.delete(key);
|
|
902
|
+
else entry.expiresAt = clock.now + Number(ttl) * 1000;
|
|
903
|
+
return 1;
|
|
904
|
+
}
|
|
905
|
+
),
|
|
906
|
+
};
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
const missing = (id: string): SkillEmbeddingCandidate => ({
|
|
910
|
+
id,
|
|
911
|
+
frameworkId: 'fw',
|
|
912
|
+
statement: `Skill ${id}`,
|
|
913
|
+
description: null,
|
|
914
|
+
code: null,
|
|
915
|
+
hasEmbedding: false,
|
|
916
|
+
embeddingKey: null,
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
const onePage = (count: number) => {
|
|
920
|
+
const records = Array.from({ length: count }, (_, i) =>
|
|
921
|
+
missing(`s${String(i).padStart(3, '0')}`)
|
|
922
|
+
);
|
|
923
|
+
return vi
|
|
924
|
+
.fn()
|
|
925
|
+
.mockResolvedValueOnce({ records, hasMore: false, cursor: null })
|
|
926
|
+
.mockResolvedValue({ records: [], hasMore: false, cursor: null });
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
it('keeps the 900s lease through a 100-skill page of 10s embeddings (1,000s)', async () => {
|
|
930
|
+
const helpers = await loadHelpers();
|
|
931
|
+
const redis = clockedRedis();
|
|
932
|
+
const lock = await helpers.acquireSkillEmbeddingBackfillLock(redis);
|
|
933
|
+
expect(lock).not.toBeNull();
|
|
934
|
+
const ourToken = redis.holder();
|
|
935
|
+
|
|
936
|
+
let rivalAcquired = false;
|
|
937
|
+
const writesWithoutLease: string[] = [];
|
|
938
|
+
|
|
939
|
+
const stats = await helpers.runSkillEmbeddingBackfill(
|
|
940
|
+
{ pageSize: 100 },
|
|
941
|
+
{
|
|
942
|
+
getCandidates: onePage(100),
|
|
943
|
+
embedIndex: vi.fn(async () => {
|
|
944
|
+
redis.clock.now += 10_000;
|
|
945
|
+
// Another deploy tries to start its own backfill on every tick.
|
|
946
|
+
if (await helpers.acquireSkillEmbeddingBackfillLock(redis)) {
|
|
947
|
+
rivalAcquired = true;
|
|
948
|
+
}
|
|
949
|
+
return unitVector(30);
|
|
950
|
+
}),
|
|
951
|
+
update: vi.fn(async (u: SkillEmbeddingUpdate) => {
|
|
952
|
+
if (redis.holder() !== ourToken) writesWithoutLease.push(u.skillId);
|
|
953
|
+
return true;
|
|
954
|
+
}),
|
|
955
|
+
keepAlive: lock!.keepAlive,
|
|
956
|
+
sleep: async ms => {
|
|
957
|
+
redis.clock.now += ms;
|
|
958
|
+
},
|
|
959
|
+
}
|
|
960
|
+
);
|
|
961
|
+
|
|
962
|
+
expect(redis.clock.now).toBe(1_000_000);
|
|
963
|
+
expect(rivalAcquired).toBe(false);
|
|
964
|
+
expect(writesWithoutLease).toEqual([]);
|
|
965
|
+
expect(stats).toMatchObject({ embedded: 100, cursor: 's099' });
|
|
966
|
+
expect(stats.stoppedReason).toBeUndefined();
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it('renews across retry waits within one skill', async () => {
|
|
970
|
+
const helpers = await loadHelpers({ SKILL_EMBEDDING_BACKFILL_LOCK_TTL_SECONDS: '60' });
|
|
971
|
+
const redis = clockedRedis();
|
|
972
|
+
const lock = await helpers.acquireSkillEmbeddingBackfillLock(redis);
|
|
973
|
+
const ourToken = redis.holder();
|
|
974
|
+
const throttled = new helpers.SkillEmbeddingError('throttled', 429);
|
|
975
|
+
const writesWithoutLease: string[] = [];
|
|
976
|
+
|
|
977
|
+
const stats = await helpers.runSkillEmbeddingBackfill(
|
|
978
|
+
{ maxRetries: 5 },
|
|
979
|
+
{
|
|
980
|
+
getCandidates: onePage(1),
|
|
981
|
+
embedIndex: vi
|
|
982
|
+
.fn()
|
|
983
|
+
.mockImplementationOnce(async () => {
|
|
984
|
+
redis.clock.now += 15_000;
|
|
985
|
+
throw throttled;
|
|
986
|
+
})
|
|
987
|
+
.mockImplementationOnce(async () => {
|
|
988
|
+
redis.clock.now += 15_000;
|
|
989
|
+
throw throttled;
|
|
990
|
+
})
|
|
991
|
+
.mockImplementation(async () => {
|
|
992
|
+
redis.clock.now += 15_000;
|
|
993
|
+
return unitVector(31);
|
|
994
|
+
}),
|
|
995
|
+
update: vi.fn(async (u: SkillEmbeddingUpdate) => {
|
|
996
|
+
if (redis.holder() !== ourToken) writesWithoutLease.push(u.skillId);
|
|
997
|
+
return true;
|
|
998
|
+
}),
|
|
999
|
+
keepAlive: lock!.keepAlive,
|
|
1000
|
+
// Each backoff wait is 40s; three 15s calls plus two waits = 125s > 60s TTL.
|
|
1001
|
+
sleep: async () => {
|
|
1002
|
+
redis.clock.now += 40_000;
|
|
1003
|
+
},
|
|
1004
|
+
}
|
|
1005
|
+
);
|
|
1006
|
+
|
|
1007
|
+
expect(redis.clock.now).toBe(125_000);
|
|
1008
|
+
expect(writesWithoutLease).toEqual([]);
|
|
1009
|
+
expect(redis.holder()).toBe(ourToken);
|
|
1010
|
+
expect(stats).toMatchObject({ embedded: 1 });
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
it('aborts without writing once the lease is lost mid-page', async () => {
|
|
1014
|
+
const helpers = await loadHelpers({ SKILL_EMBEDDING_BACKFILL_LOCK_TTL_SECONDS: '60' });
|
|
1015
|
+
const redis = clockedRedis();
|
|
1016
|
+
const lock = await helpers.acquireSkillEmbeddingBackfillLock(redis);
|
|
1017
|
+
const update = vi.fn(async () => true);
|
|
1018
|
+
const embedIndex = vi.fn(async () => {
|
|
1019
|
+
// The third embedding hangs past the lease (e.g. a stalled event loop).
|
|
1020
|
+
redis.clock.now += embedIndex.mock.calls.length === 3 ? 120_000 : 10_000;
|
|
1021
|
+
return unitVector(32);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
const stats = await helpers.runSkillEmbeddingBackfill(
|
|
1025
|
+
{},
|
|
1026
|
+
{
|
|
1027
|
+
getCandidates: onePage(5),
|
|
1028
|
+
embedIndex,
|
|
1029
|
+
update,
|
|
1030
|
+
keepAlive: lock!.keepAlive,
|
|
1031
|
+
sleep: async () => undefined,
|
|
1032
|
+
}
|
|
1033
|
+
);
|
|
1034
|
+
|
|
1035
|
+
expect(stats).toMatchObject({
|
|
1036
|
+
embedded: 2,
|
|
1037
|
+
stoppedReason: 'lock_lost',
|
|
1038
|
+
cursor: 's001',
|
|
1039
|
+
});
|
|
1040
|
+
expect(update.mock.calls.map(call => (call as unknown[])[0])).toHaveLength(2);
|
|
1041
|
+
expect(embedIndex).toHaveBeenCalledTimes(3);
|
|
1042
|
+
});
|
|
1043
|
+
});
|
|
1044
|
+
});
|