@learncard/network-brain-service 4.0.4 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +14 -0
- package/AGENTS.md +16 -0
- package/CHANGELOG.md +54 -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/app-credential-delivery.helpers.d.ts +4 -0
- package/dist/helpers/app-credential-delivery.helpers.d.ts.map +1 -0
- package/dist/helpers/app-credential-delivery.helpers.test.d.ts +2 -0
- package/dist/helpers/app-credential-delivery.helpers.test.d.ts.map +1 -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.map +1 -1
- package/dist/helpers/sentry.helpers.d.ts.map +1 -1
- package/dist/helpers/sentry.helpers.test.d.ts +2 -0
- package/dist/helpers/sentry.helpers.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/app-store.d.ts.map +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 +21 -18
- package/project.json +15 -1
- 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/activity.helpers.ts +3 -3
- package/src/helpers/app-credential-delivery.helpers.test.ts +56 -0
- package/src/helpers/app-credential-delivery.helpers.ts +11 -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 +63 -0
- package/src/helpers/boost.helpers.ts +23 -18
- package/src/helpers/sentry.helpers.test.ts +56 -0
- package/src/helpers/sentry.helpers.ts +4 -3
- 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/app-store.ts +9 -16
- 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/app-credential-delivery.neo4j.spec.ts +110 -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/tsconfig.json +13 -13
- package/vitest.campaigns.config.ts +21 -0
- package/vitest.config.ts +12 -0
- package/vitest.inbox-e2e.config.ts +1 -0
- package/vitest.integration.config.ts +1 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { BedrockRuntimeClient, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime';
|
|
2
|
+
|
|
3
|
+
import { NOVA2_MAX_TEXT_CHARS } from '../config/skillEmbedding';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* LC-2206 Bedrock adapter for Amazon Nova 2 Multimodal Embeddings.
|
|
7
|
+
*
|
|
8
|
+
* Style follows lca-api's Bedrock image adapter (LC-2205): one InvokeModel per request,
|
|
9
|
+
* SigV4 with the execution role's rotating credentials (never a long-term
|
|
10
|
+
* AWS_BEARER_TOKEN_BEDROCK key), no SDK retries, a hard timeout, and sanitised errors that
|
|
11
|
+
* never carry provider bodies, input text, credentials or vectors.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** `index` embeds stored skills (GENERIC_INDEX); `query` embeds search text (TEXT_RETRIEVAL). */
|
|
15
|
+
export type EmbeddingPurpose = 'index' | 'query';
|
|
16
|
+
|
|
17
|
+
export type BedrockEmbeddingConfig = {
|
|
18
|
+
modelId: string;
|
|
19
|
+
region: string;
|
|
20
|
+
dimension: number;
|
|
21
|
+
timeoutMs: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type SkillEmbeddingErrorCode =
|
|
25
|
+
| 'disabled'
|
|
26
|
+
| 'invalid_input'
|
|
27
|
+
| 'throttled'
|
|
28
|
+
| 'unavailable'
|
|
29
|
+
| 'access_denied'
|
|
30
|
+
| 'timeout'
|
|
31
|
+
| 'invalid_response'
|
|
32
|
+
| 'failed';
|
|
33
|
+
|
|
34
|
+
export class SkillEmbeddingError extends Error {
|
|
35
|
+
readonly code: SkillEmbeddingErrorCode;
|
|
36
|
+
|
|
37
|
+
readonly retryable: boolean;
|
|
38
|
+
|
|
39
|
+
readonly httpStatus?: number;
|
|
40
|
+
|
|
41
|
+
constructor(code: SkillEmbeddingErrorCode, httpStatus?: number) {
|
|
42
|
+
super(`Skill embedding failed (${code})`);
|
|
43
|
+
this.name = 'SkillEmbeddingError';
|
|
44
|
+
this.code = code;
|
|
45
|
+
this.retryable = code === 'throttled' || code === 'unavailable' || code === 'timeout';
|
|
46
|
+
this.httpStatus = httpStatus;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const isSkillEmbeddingError = (error: unknown): error is SkillEmbeddingError =>
|
|
51
|
+
error instanceof SkillEmbeddingError;
|
|
52
|
+
|
|
53
|
+
/** Raw InvokeModel transport; injectable so unit tests never reach AWS. */
|
|
54
|
+
export type BedrockInvoke = (
|
|
55
|
+
request: { modelId: string; body: string },
|
|
56
|
+
options: { abortSignal: AbortSignal }
|
|
57
|
+
) => Promise<Uint8Array>;
|
|
58
|
+
|
|
59
|
+
export const buildNovaEmbeddingRequest = (
|
|
60
|
+
text: string,
|
|
61
|
+
purpose: EmbeddingPurpose,
|
|
62
|
+
dimension: number
|
|
63
|
+
) => ({
|
|
64
|
+
schemaVersion: 'nova-multimodal-embed-v1',
|
|
65
|
+
taskType: 'SINGLE_EMBEDDING',
|
|
66
|
+
singleEmbeddingParams: {
|
|
67
|
+
embeddingPurpose: purpose === 'index' ? 'GENERIC_INDEX' : 'TEXT_RETRIEVAL',
|
|
68
|
+
embeddingDimension: dimension,
|
|
69
|
+
// Fail instead of silently embedding a truncated text; callers bound input length.
|
|
70
|
+
text: { truncationMode: 'NONE', value: text },
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/** L2-normalise so cosine and dot-product scores agree (AI Passport does the same). */
|
|
75
|
+
export const normalizeEmbedding = (vector: number[]): number[] => {
|
|
76
|
+
const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
|
|
77
|
+
if (!Number.isFinite(norm) || norm === 0) throw new SkillEmbeddingError('invalid_response');
|
|
78
|
+
return vector.map(value => value / norm);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Validate the documented Nova InvokeModel envelope: exactly one finite TEXT vector. */
|
|
82
|
+
export const parseNovaEmbeddingResponse = (body: Uint8Array, dimension: number): number[] => {
|
|
83
|
+
let parsed: unknown;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(new TextDecoder().decode(body));
|
|
87
|
+
} catch {
|
|
88
|
+
throw new SkillEmbeddingError('invalid_response');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const rows = (parsed as { embeddings?: unknown } | null)?.embeddings;
|
|
92
|
+
if (!Array.isArray(rows) || rows.length !== 1)
|
|
93
|
+
throw new SkillEmbeddingError('invalid_response');
|
|
94
|
+
|
|
95
|
+
const row = rows[0] as {
|
|
96
|
+
embeddingType?: unknown;
|
|
97
|
+
embedding?: unknown;
|
|
98
|
+
truncatedCharLength?: unknown;
|
|
99
|
+
} | null;
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
!row ||
|
|
103
|
+
row.embeddingType !== 'TEXT' ||
|
|
104
|
+
row.truncatedCharLength !== undefined ||
|
|
105
|
+
!Array.isArray(row.embedding) ||
|
|
106
|
+
row.embedding.length !== dimension ||
|
|
107
|
+
!row.embedding.every(value => typeof value === 'number' && Number.isFinite(value))
|
|
108
|
+
) {
|
|
109
|
+
throw new SkillEmbeddingError('invalid_response');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return normalizeEmbedding(row.embedding as number[]);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const providerStatus = (error: unknown): number | undefined => {
|
|
116
|
+
if (!error || typeof error !== 'object') return undefined;
|
|
117
|
+
const { $metadata: metadata } = error as { $metadata?: { httpStatusCode?: unknown } };
|
|
118
|
+
return typeof metadata?.httpStatusCode === 'number' ? metadata.httpStatusCode : undefined;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const providerName = (error: unknown): string | undefined => {
|
|
122
|
+
if (!error || typeof error !== 'object') return undefined;
|
|
123
|
+
const { name } = error as { name?: unknown };
|
|
124
|
+
return typeof name === 'string' ? name : undefined;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Map an SDK failure to a code. The provider message and body are deliberately dropped. */
|
|
128
|
+
export const sanitizeBedrockError = (error: unknown): SkillEmbeddingError => {
|
|
129
|
+
if (error instanceof SkillEmbeddingError) return error;
|
|
130
|
+
|
|
131
|
+
const status = providerStatus(error);
|
|
132
|
+
const name = providerName(error);
|
|
133
|
+
|
|
134
|
+
if (name === 'AbortError' || name === 'TimeoutError') return new SkillEmbeddingError('timeout');
|
|
135
|
+
if (
|
|
136
|
+
status === 429 ||
|
|
137
|
+
name === 'ThrottlingException' ||
|
|
138
|
+
name === 'ServiceQuotaExceededException'
|
|
139
|
+
) {
|
|
140
|
+
return new SkillEmbeddingError('throttled', 429);
|
|
141
|
+
}
|
|
142
|
+
if (status === 403 || name === 'AccessDeniedException') {
|
|
143
|
+
return new SkillEmbeddingError('access_denied', 403);
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
status === 503 ||
|
|
147
|
+
status === 504 ||
|
|
148
|
+
name === 'ServiceUnavailableException' ||
|
|
149
|
+
name === 'ModelNotReadyException' ||
|
|
150
|
+
name === 'ModelTimeoutException'
|
|
151
|
+
) {
|
|
152
|
+
return new SkillEmbeddingError('unavailable', status);
|
|
153
|
+
}
|
|
154
|
+
if (status === 400 || name === 'ValidationException') {
|
|
155
|
+
return new SkillEmbeddingError('invalid_input', 400);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return new SkillEmbeddingError('failed', status);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const clients = new Map<string, BedrockRuntimeClient>();
|
|
162
|
+
|
|
163
|
+
const getClient = (config: BedrockEmbeddingConfig): BedrockRuntimeClient => {
|
|
164
|
+
const key = `${config.region}:${config.timeoutMs}`;
|
|
165
|
+
let client = clients.get(key);
|
|
166
|
+
|
|
167
|
+
if (!client) {
|
|
168
|
+
client = new BedrockRuntimeClient({
|
|
169
|
+
region: config.region,
|
|
170
|
+
// One paid attempt per call; the backfill decides whether to retry later.
|
|
171
|
+
maxAttempts: 1,
|
|
172
|
+
// Execution-role SigV4 only, matching lca-api's credential policy.
|
|
173
|
+
authSchemePreference: ['sigv4'],
|
|
174
|
+
requestHandler: { requestTimeout: config.timeoutMs, connectionTimeout: 3_000 },
|
|
175
|
+
});
|
|
176
|
+
clients.set(key, client);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return client;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** Test hook: drop cached SDK clients. */
|
|
183
|
+
export const resetBedrockEmbeddingClients = (): void => {
|
|
184
|
+
for (const client of clients.values()) client.destroy();
|
|
185
|
+
clients.clear();
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const createDefaultBedrockInvoke =
|
|
189
|
+
(config: BedrockEmbeddingConfig): BedrockInvoke =>
|
|
190
|
+
async (request, options) => {
|
|
191
|
+
const response = await getClient(config).send(
|
|
192
|
+
new InvokeModelCommand({
|
|
193
|
+
modelId: request.modelId,
|
|
194
|
+
contentType: 'application/json',
|
|
195
|
+
accept: 'application/json',
|
|
196
|
+
body: request.body,
|
|
197
|
+
}),
|
|
198
|
+
{ abortSignal: options.abortSignal }
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
return response.body;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** Embed one text with Nova 2. Exactly one InvokeModel call; throws SkillEmbeddingError. */
|
|
205
|
+
export const embedTextWithBedrock = async (
|
|
206
|
+
text: string,
|
|
207
|
+
purpose: EmbeddingPurpose,
|
|
208
|
+
config: BedrockEmbeddingConfig,
|
|
209
|
+
invoke: BedrockInvoke = createDefaultBedrockInvoke(config)
|
|
210
|
+
): Promise<number[]> => {
|
|
211
|
+
const value = text.trim();
|
|
212
|
+
if (!value || value.length > NOVA2_MAX_TEXT_CHARS)
|
|
213
|
+
throw new SkillEmbeddingError('invalid_input');
|
|
214
|
+
|
|
215
|
+
const controller = new AbortController();
|
|
216
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const body = await Promise.race([
|
|
220
|
+
invoke(
|
|
221
|
+
{
|
|
222
|
+
modelId: config.modelId,
|
|
223
|
+
body: JSON.stringify(
|
|
224
|
+
buildNovaEmbeddingRequest(value, purpose, config.dimension)
|
|
225
|
+
),
|
|
226
|
+
},
|
|
227
|
+
{ abortSignal: controller.signal }
|
|
228
|
+
),
|
|
229
|
+
new Promise<never>((_, reject) => {
|
|
230
|
+
timer = setTimeout(() => {
|
|
231
|
+
controller.abort();
|
|
232
|
+
reject(new SkillEmbeddingError('timeout'));
|
|
233
|
+
}, config.timeoutMs);
|
|
234
|
+
}),
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
return parseNovaEmbeddingResponse(body, config.dimension);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
throw sanitizeBedrockError(error);
|
|
240
|
+
} finally {
|
|
241
|
+
clearTimeout(timer);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LC-2206 opt-in smoke test against REAL Amazon Bedrock (paid: three InvokeModel calls).
|
|
3
|
+
* Skipped unless SKILL_EMBEDDING_BEDROCK_SMOKE=true. Never enable it in CI.
|
|
4
|
+
*
|
|
5
|
+
* SKILL_EMBEDDING_BEDROCK_SMOKE=true AWS_PROFILE=<profile> \
|
|
6
|
+
* bunx vitest run --config vitest.config.ts src/helpers/bedrock-embedding.smoke.test.ts
|
|
7
|
+
*
|
|
8
|
+
* Uses the normal AWS credential chain (SigV4) in us-east-1 and the same request shape,
|
|
9
|
+
* purposes, dimension and normalisation as production.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_SKILL_EMBEDDING_DIMENSION,
|
|
15
|
+
DEFAULT_SKILL_EMBEDDING_REGION,
|
|
16
|
+
NOVA2_EMBEDDING_MODEL_ID,
|
|
17
|
+
} from '../config/skillEmbedding';
|
|
18
|
+
import { embedTextWithBedrock, type BedrockEmbeddingConfig } from './bedrock-embedding.helpers';
|
|
19
|
+
|
|
20
|
+
const enabled = process.env.SKILL_EMBEDDING_BEDROCK_SMOKE === 'true';
|
|
21
|
+
|
|
22
|
+
const config: BedrockEmbeddingConfig = {
|
|
23
|
+
modelId: process.env.SKILL_EMBEDDING_BEDROCK_MODEL_ID || NOVA2_EMBEDDING_MODEL_ID,
|
|
24
|
+
region: process.env.SKILL_EMBEDDING_BEDROCK_REGION || DEFAULT_SKILL_EMBEDDING_REGION,
|
|
25
|
+
dimension: Number(process.env.SKILL_EMBEDDING_DIMENSION || DEFAULT_SKILL_EMBEDDING_DIMENSION),
|
|
26
|
+
timeoutMs: 15_000,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const dot = (a: number[], b: number[]) =>
|
|
30
|
+
a.reduce((sum, value, index) => sum + value * (b[index] ?? 0), 0);
|
|
31
|
+
|
|
32
|
+
describe.skipIf(!enabled)('Bedrock Nova 2 embeddings smoke (real AWS, opt-in)', () => {
|
|
33
|
+
it('returns unit vectors where a related skill outranks an unrelated one', async () => {
|
|
34
|
+
const query = await embedTextWithBedrock('python data analysis', 'query', config);
|
|
35
|
+
const related = await embedTextWithBedrock(
|
|
36
|
+
'Data Analysis\nAnalyse datasets with Python and statistics',
|
|
37
|
+
'index',
|
|
38
|
+
config
|
|
39
|
+
);
|
|
40
|
+
const unrelated = await embedTextWithBedrock(
|
|
41
|
+
'Customer service\nHelp customers resolve issues politely',
|
|
42
|
+
'index',
|
|
43
|
+
config
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
for (const vector of [query, related, unrelated]) {
|
|
47
|
+
expect(vector).toHaveLength(config.dimension);
|
|
48
|
+
expect(Math.hypot(...vector)).toBeCloseTo(1, 6);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
expect(dot(query, related)).toBeGreaterThan(dot(query, unrelated));
|
|
52
|
+
}, 60_000);
|
|
53
|
+
});
|
|
@@ -107,6 +107,14 @@ const options = {
|
|
|
107
107
|
integrationId: 'integration',
|
|
108
108
|
} as unknown as Parameters<typeof sendBoost>[0];
|
|
109
109
|
|
|
110
|
+
const deferred = () => {
|
|
111
|
+
let resolve!: () => void;
|
|
112
|
+
const promise = new Promise<void>(complete => {
|
|
113
|
+
resolve = complete;
|
|
114
|
+
});
|
|
115
|
+
return { promise, resolve };
|
|
116
|
+
};
|
|
117
|
+
|
|
110
118
|
describe('boost payload storage', () => {
|
|
111
119
|
beforeEach(() => {
|
|
112
120
|
vi.clearAllMocks();
|
|
@@ -167,6 +175,61 @@ describe('boost payload storage', () => {
|
|
|
167
175
|
}
|
|
168
176
|
);
|
|
169
177
|
|
|
178
|
+
it('does not overlap relationship writes that touch the same credential', async () => {
|
|
179
|
+
const first = deferred();
|
|
180
|
+
const sent = deferred();
|
|
181
|
+
const listing = deferred();
|
|
182
|
+
const contract = deferred();
|
|
183
|
+
mocks.instanceOf.mockReturnValueOnce(first.promise);
|
|
184
|
+
mocks.sent.mockReturnValueOnce(sent.promise);
|
|
185
|
+
mocks.listing.mockReturnValueOnce(listing.promise);
|
|
186
|
+
mocks.contract.mockReturnValueOnce(contract.promise);
|
|
187
|
+
const result = sendBoost(options);
|
|
188
|
+
await vi.waitFor(() => expect(mocks.instanceOf).toHaveBeenCalledOnce());
|
|
189
|
+
expect(mocks.sent).not.toHaveBeenCalled();
|
|
190
|
+
first.resolve();
|
|
191
|
+
await vi.waitFor(() => expect(mocks.sent).toHaveBeenCalledOnce());
|
|
192
|
+
expect(mocks.listing).not.toHaveBeenCalled();
|
|
193
|
+
sent.resolve();
|
|
194
|
+
await vi.waitFor(() => expect(mocks.listing).toHaveBeenCalledOnce());
|
|
195
|
+
expect(mocks.contract).not.toHaveBeenCalled();
|
|
196
|
+
listing.resolve();
|
|
197
|
+
await vi.waitFor(() => expect(mocks.contract).toHaveBeenCalledOnce());
|
|
198
|
+
expect(mocks.accept).not.toHaveBeenCalled();
|
|
199
|
+
expect(mocks.notify).not.toHaveBeenCalled();
|
|
200
|
+
contract.resolve();
|
|
201
|
+
await expect(result).resolves.toBe('lc:network:network.example/credential:stored');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it.each(['instanceOf', 'sent', 'listing', 'contract'] as const)(
|
|
205
|
+
'stops after a failed %s relationship without acceptance or notification',
|
|
206
|
+
async failed => {
|
|
207
|
+
const error = new Error('graph write failed');
|
|
208
|
+
mocks[failed].mockRejectedValueOnce(error);
|
|
209
|
+
await expect(sendBoost(options)).rejects.toBe(error);
|
|
210
|
+
const order = ['instanceOf', 'sent', 'listing', 'contract'] as const;
|
|
211
|
+
for (const later of order.slice(order.indexOf(failed) + 1)) {
|
|
212
|
+
expect(mocks[later]).not.toHaveBeenCalled();
|
|
213
|
+
}
|
|
214
|
+
expect(mocks.accept).not.toHaveBeenCalled();
|
|
215
|
+
expect(mocks.notify).not.toHaveBeenCalled();
|
|
216
|
+
}
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
it.each(['listing', 'another-listing', undefined])(
|
|
220
|
+
'adds explicit listing attribution only when the listing issuer does not already cover %s',
|
|
221
|
+
async listingId => {
|
|
222
|
+
const from = {
|
|
223
|
+
type: 'appStoreListing',
|
|
224
|
+
listing: { listing_id: 'listing' },
|
|
225
|
+
ownerProfile: { profileId: 'org' },
|
|
226
|
+
} as Parameters<typeof sendBoost>[0]['from'];
|
|
227
|
+
await sendBoost({ ...options, from, listingId });
|
|
228
|
+
expect(mocks.sent).toHaveBeenCalledOnce();
|
|
229
|
+
expect(mocks.listing).toHaveBeenCalledTimes(listingId === 'another-listing' ? 1 : 0);
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
|
|
170
233
|
it('does not inspect template-derived fields or add a network signature', async () => {
|
|
171
234
|
const credential = { ...vc, display: { backgroundColor: '#bad' } };
|
|
172
235
|
await sendBoost({ ...options, credential });
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
getIssuerDisplayName,
|
|
20
20
|
getIssuerOwnerProfile,
|
|
21
21
|
getIssuerProfileId,
|
|
22
|
+
isAppStoreListingIssuer,
|
|
22
23
|
} from '../types/issuer';
|
|
23
24
|
import { trace, traceDb } from '@tracing';
|
|
24
25
|
import { injectObv3AlignmentsIntoCredentialForBoost } from '@services/skills-provider/inject';
|
|
@@ -182,38 +183,42 @@ export const sendBoost = async ({
|
|
|
182
183
|
storeCredential(credential)
|
|
183
184
|
);
|
|
184
185
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
// These writes share Credential/Profile/Listing nodes. Separate concurrent
|
|
187
|
+
// auto-commit transactions can deadlock on Neo4j relationship-chain locks.
|
|
188
|
+
await traceDb('createRelationships', async () => {
|
|
189
|
+
await createBoostInstanceOfRelationship(credentialInstance, boost);
|
|
190
|
+
await createSentCredentialRelationship(
|
|
188
191
|
from,
|
|
189
192
|
to,
|
|
190
193
|
credentialInstance,
|
|
191
194
|
metadata,
|
|
192
195
|
activityId,
|
|
193
196
|
integrationId
|
|
194
|
-
)
|
|
195
|
-
];
|
|
197
|
+
);
|
|
196
198
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
199
|
+
// Listing issuers already create this edge inside createSentCredentialRelationship.
|
|
200
|
+
// Keep explicit attribution for profile/integration issuers or another listing.
|
|
201
|
+
if (
|
|
202
|
+
listingId &&
|
|
203
|
+
(!isAppStoreListingIssuer(from) || from.listing.listing_id !== listingId)
|
|
204
|
+
) {
|
|
205
|
+
await createListingSentCredentialRelationship(
|
|
200
206
|
listingId,
|
|
201
207
|
to,
|
|
202
208
|
credentialInstance,
|
|
203
209
|
metadata,
|
|
204
210
|
activityId,
|
|
205
211
|
integrationId
|
|
206
|
-
)
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (contractTerms) {
|
|
211
|
-
tasks.push(
|
|
212
|
-
createCredentialIssuedViaContractRelationship(credentialInstance, contractTerms)
|
|
213
|
-
);
|
|
214
|
-
}
|
|
212
|
+
);
|
|
213
|
+
}
|
|
215
214
|
|
|
216
|
-
|
|
215
|
+
if (contractTerms) {
|
|
216
|
+
await createCredentialIssuedViaContractRelationship(
|
|
217
|
+
credentialInstance,
|
|
218
|
+
contractTerms
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
217
222
|
|
|
218
223
|
if (autoAcceptCredential) {
|
|
219
224
|
await acceptCredential(to, getCredentialUri(credentialInstance.id, domain), {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
|
|
4
|
+
const sentry = vi.hoisted(() => ({
|
|
5
|
+
captureException: vi.fn(),
|
|
6
|
+
setHttpStatus: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock('@sentry/serverless', () => ({
|
|
10
|
+
captureException: sentry.captureException,
|
|
11
|
+
getActiveTransaction: () => ({ setHttpStatus: sentry.setHttpStatus }),
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('@environment', () => ({ environment: { SENTRY_ENV: 'test' } }));
|
|
14
|
+
|
|
15
|
+
import { handleTrpcError, sentryBeforeSend, shouldReportError } from './sentry.helpers';
|
|
16
|
+
|
|
17
|
+
const event: Parameters<typeof sentryBeforeSend>[0] = { event_id: 'event', type: undefined };
|
|
18
|
+
|
|
19
|
+
describe('sentry helpers', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
sentry.captureException.mockClear();
|
|
22
|
+
sentry.setHttpStatus.mockClear();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('does not capture a repeated contract consent conflict', () => {
|
|
26
|
+
const error = new TRPCError({
|
|
27
|
+
code: 'CONFLICT',
|
|
28
|
+
message: "You've already consented to this contract!",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
handleTrpcError({ error, ctx: {}, path: 'contracts.consentToContract' });
|
|
32
|
+
|
|
33
|
+
expect(sentry.captureException).not.toHaveBeenCalled();
|
|
34
|
+
expect(sentry.setHttpStatus).toHaveBeenCalledWith(409);
|
|
35
|
+
expect(sentryBeforeSend(event, { originalException: error })).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it.each(['BAD_REQUEST', 'NOT_FOUND', 'FORBIDDEN', 'CONFLICT'])(
|
|
39
|
+
'treats %s as an expected client error',
|
|
40
|
+
code => {
|
|
41
|
+
expect(shouldReportError(code)).toBe(false);
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
it('still captures unexpected server errors', () => {
|
|
46
|
+
const error = new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'boom' });
|
|
47
|
+
|
|
48
|
+
handleTrpcError({ error, ctx: {}, path: 'contracts.consentToContract' });
|
|
49
|
+
|
|
50
|
+
expect(sentry.captureException).toHaveBeenCalledWith(
|
|
51
|
+
error,
|
|
52
|
+
expect.objectContaining({ level: 'error' })
|
|
53
|
+
);
|
|
54
|
+
expect(sentryBeforeSend(event, { originalException: error })).toBe(event);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -5,9 +5,10 @@ import type { TRPCError } from '@trpc/server';
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Error codes that are expected/non-critical and should not be sent to Sentry.
|
|
8
|
-
* These typically represent user errors
|
|
8
|
+
* These typically represent user errors, expected authentication failures, or
|
|
9
|
+
* client-visible state conflicts such as re-consenting to the same contract.
|
|
9
10
|
*/
|
|
10
|
-
const IGNORED_ERROR_CODES = ['BAD_REQUEST', 'NOT_FOUND', 'FORBIDDEN'];
|
|
11
|
+
const IGNORED_ERROR_CODES = ['BAD_REQUEST', 'NOT_FOUND', 'FORBIDDEN', 'CONFLICT'];
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Determines if an error should be reported to Sentry based on its code.
|
|
@@ -38,7 +39,7 @@ export const handleTrpcError = ({
|
|
|
38
39
|
!(ctx as { user?: { isChallengeValid?: boolean } })?.user?.isChallengeValid;
|
|
39
40
|
|
|
40
41
|
// Only capture unexpected/critical errors
|
|
41
|
-
// Filter out expected errors (
|
|
42
|
+
// Filter out expected errors (see IGNORED_ERROR_CODES) and invalid challenge attempts
|
|
42
43
|
if (shouldReportError(error.code) && !isInvalidChallenge) {
|
|
43
44
|
Sentry.captureException(error, {
|
|
44
45
|
extra: { ctx, path },
|