@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,32 @@
|
|
|
1
|
+
import { ModelFactory, type ModelRelatedNodesI, type NeogmaInstance } from 'neogma';
|
|
2
|
+
|
|
3
|
+
import { neogma } from '@instance';
|
|
4
|
+
import type { CampaignRecord } from 'types/campaign';
|
|
5
|
+
import type { Profile, ProfileInstance } from './Profile';
|
|
6
|
+
|
|
7
|
+
export type CampaignRelationships = {
|
|
8
|
+
audience: ModelRelatedNodesI<typeof Profile, ProfileInstance>;
|
|
9
|
+
state: ModelRelatedNodesI<typeof Profile, ProfileInstance>;
|
|
10
|
+
};
|
|
11
|
+
export type CampaignInstance = NeogmaInstance<CampaignRecord, CampaignRelationships>;
|
|
12
|
+
|
|
13
|
+
export const Campaign = ModelFactory<CampaignRecord, CampaignRelationships>(
|
|
14
|
+
{
|
|
15
|
+
label: 'Campaign',
|
|
16
|
+
primaryKeyField: 'id',
|
|
17
|
+
schema: {
|
|
18
|
+
id: { type: 'string', required: true },
|
|
19
|
+
name: { type: 'string', required: true },
|
|
20
|
+
status: { type: 'string', required: true },
|
|
21
|
+
definition: { type: 'string', required: true },
|
|
22
|
+
startsAt: { type: 'string', required: false },
|
|
23
|
+
endsAt: { type: 'string', required: false },
|
|
24
|
+
excludeMinors: { type: 'boolean', required: true },
|
|
25
|
+
selfEnroll: { type: 'boolean', required: true },
|
|
26
|
+
createdByProfileId: { type: 'string', required: true },
|
|
27
|
+
createdAt: { type: 'string', required: true },
|
|
28
|
+
updatedAt: { type: 'string', required: true },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
neogma
|
|
32
|
+
);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ModelFactory, NeogmaInstance } from 'neogma';
|
|
2
|
+
import { neogma } from '@instance';
|
|
3
|
+
|
|
4
|
+
type Properties = {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
issuer: string;
|
|
8
|
+
shareUri: string;
|
|
9
|
+
createdAt: string;
|
|
10
|
+
updatedAt: string;
|
|
11
|
+
image?: string;
|
|
12
|
+
};
|
|
13
|
+
export type CredentialSharePreviewInstance = NeogmaInstance<Properties, Record<string, never>>;
|
|
14
|
+
|
|
15
|
+
export const CredentialSharePreview = ModelFactory<Properties, Record<string, never>>(
|
|
16
|
+
{
|
|
17
|
+
label: 'CredentialSharePreview',
|
|
18
|
+
primaryKeyField: 'id',
|
|
19
|
+
schema: {
|
|
20
|
+
id: { type: 'string', required: true },
|
|
21
|
+
name: { type: 'string', required: true },
|
|
22
|
+
issuer: { type: 'string', required: true },
|
|
23
|
+
shareUri: { type: 'string', required: true },
|
|
24
|
+
createdAt: { type: 'string', required: true },
|
|
25
|
+
updatedAt: { type: 'string', required: true },
|
|
26
|
+
image: { type: 'string', required: false },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
neogma
|
|
30
|
+
);
|
package/src/models/Profile.ts
CHANGED
|
@@ -120,6 +120,7 @@ export const Profile: any = ModelFactory<FlatProfileType, ProfileRelationships>(
|
|
|
120
120
|
image: { type: 'string', required: false },
|
|
121
121
|
heroImage: { type: 'string', required: false },
|
|
122
122
|
websiteLink: { type: 'string', required: false },
|
|
123
|
+
linkedInOrganizationId: { type: 'string', required: false },
|
|
123
124
|
isServiceProfile: { type: 'boolean', required: false },
|
|
124
125
|
type: { type: 'string', required: false },
|
|
125
126
|
notificationsWebhook: { type: 'string', required: false },
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { neogma } from '@instance';
|
|
2
|
+
|
|
3
|
+
let readiness: Promise<void> | undefined;
|
|
4
|
+
|
|
5
|
+
/** Await uniqueness before accepting creates, even during concurrent cold starts. */
|
|
6
|
+
export const ensureCampaignConstraints = (): Promise<void> => {
|
|
7
|
+
readiness ??= neogma.queryRunner
|
|
8
|
+
.run(
|
|
9
|
+
'CREATE CONSTRAINT campaign_id_unique IF NOT EXISTS FOR (c:Campaign) REQUIRE c.id IS UNIQUE'
|
|
10
|
+
)
|
|
11
|
+
.then(() => undefined)
|
|
12
|
+
.catch((error: unknown) => {
|
|
13
|
+
if (
|
|
14
|
+
(error as { code?: string })?.code ===
|
|
15
|
+
'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists'
|
|
16
|
+
)
|
|
17
|
+
return;
|
|
18
|
+
readiness = undefined;
|
|
19
|
+
throw error;
|
|
20
|
+
});
|
|
21
|
+
return readiness;
|
|
22
|
+
};
|
package/src/models/index.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { environment } from '@environment';
|
|
2
2
|
import { neogma } from '@instance';
|
|
3
3
|
import { contractEventMaintenanceSchema } from '@helpers/contract-event-maintenance.helpers';
|
|
4
|
+
import {
|
|
5
|
+
LEGACY_GOOGLE_SKILL_EMBEDDING_VECTOR_INDEX_QUERY,
|
|
6
|
+
buildSkillEmbeddingVectorIndexQuery,
|
|
7
|
+
resolveSkillEmbeddingGeneration,
|
|
8
|
+
} from '../config/skillEmbedding';
|
|
4
9
|
|
|
5
10
|
import { Boost } from './Boost';
|
|
6
11
|
import { Profile } from './Profile';
|
|
@@ -17,6 +22,7 @@ import { CredentialActivity } from './CredentialActivity';
|
|
|
17
22
|
import { CredentialRefresh } from './CredentialRefresh';
|
|
18
23
|
import { StatusList } from './StatusList';
|
|
19
24
|
import { ContactMethod } from './ContactMethod';
|
|
25
|
+
import { Campaign } from './Campaign';
|
|
20
26
|
import { ensureCredentialRefreshConstraints } from './credential-refresh-constraints';
|
|
21
27
|
|
|
22
28
|
// Ensure CredentialActivity model is registered by referencing it
|
|
@@ -139,14 +145,22 @@ ContactMethod.addRelationships({
|
|
|
139
145
|
profile: { model: Profile, direction: 'in', name: 'HAS_CONTACT_METHOD' },
|
|
140
146
|
});
|
|
141
147
|
|
|
148
|
+
Campaign.addRelationships({
|
|
149
|
+
audience: { model: Profile, direction: 'in', name: 'IN_CAMPAIGN_AUDIENCE' },
|
|
150
|
+
state: { model: Profile, direction: 'in', name: 'CAMPAIGN_STATE' },
|
|
151
|
+
});
|
|
152
|
+
|
|
142
153
|
const shouldCreateIndices =
|
|
143
154
|
environment.NODE_ENV === 'production' || !environment.NEO4J_SKIP_INDICES;
|
|
144
155
|
|
|
145
156
|
const indexQueries = [
|
|
157
|
+
'CREATE CONSTRAINT campaign_id_unique IF NOT EXISTS FOR (c:Campaign) REQUIRE c.id IS UNIQUE',
|
|
158
|
+
'CREATE INDEX campaign_status_idx IF NOT EXISTS FOR (c:Campaign) ON (c.status)',
|
|
146
159
|
'CREATE CONSTRAINT consent_event_id IF NOT EXISTS FOR (e:ConsentFlowEvent) REQUIRE e.id IS UNIQUE',
|
|
147
160
|
'CREATE CONSTRAINT consent_delivery_id IF NOT EXISTS FOR (d:ConsentFlowEventDelivery) REQUIRE d.id IS UNIQUE',
|
|
148
161
|
...contractEventMaintenanceSchema,
|
|
149
162
|
'CREATE INDEX profileId_idx IF NOT EXISTS FOR (p:Profile) ON (p.profileId)',
|
|
163
|
+
'CREATE CONSTRAINT credential_share_preview_id_unique IF NOT EXISTS FOR (p:CredentialSharePreview) REQUIRE p.id IS UNIQUE',
|
|
150
164
|
'CREATE INDEX profile_did_idx IF NOT EXISTS FOR (p:Profile) ON (p.did)',
|
|
151
165
|
'CREATE TEXT INDEX profileId_text_idx IF NOT EXISTS FOR (n:Profile) ON (n.profileId)',
|
|
152
166
|
'CREATE TEXT INDEX profile_displayname_text_idx IF NOT EXISTS FOR (n:Profile) ON (n.displayName)',
|
|
@@ -180,7 +194,19 @@ const indexQueries = [
|
|
|
180
194
|
'CREATE TEXT INDEX skill_framework_name_text_idx IF NOT EXISTS FOR (f:SkillFramework) ON (f.name)',
|
|
181
195
|
'CREATE INDEX skill_id_idx IF NOT EXISTS FOR (s:Skill) ON (s.id)',
|
|
182
196
|
'CREATE TEXT INDEX skill_statement_text_idx IF NOT EXISTS FOR (s:Skill) ON (s.statement)',
|
|
183
|
-
|
|
197
|
+
// LC-2206: the legacy Google 768-dim index (main's DDL, unchanged) stays because semantic
|
|
198
|
+
// search reads it until SKILL_EMBEDDING_BEDROCK_READ_ENABLED is on, and flipping the flag
|
|
199
|
+
// back off is the rollback. The post-rollback-window cleanup drops it.
|
|
200
|
+
LEGACY_GOOGLE_SKILL_EMBEDDING_VECTOR_INDEX_QUERY,
|
|
201
|
+
// One vector index per Nova embedding generation (1024 dims by default), written regardless
|
|
202
|
+
// of the read flag.
|
|
203
|
+
buildSkillEmbeddingVectorIndexQuery(
|
|
204
|
+
resolveSkillEmbeddingGeneration({
|
|
205
|
+
modelId: environment.SKILL_EMBEDDING_BEDROCK_MODEL_ID,
|
|
206
|
+
dimension: environment.SKILL_EMBEDDING_DIMENSION,
|
|
207
|
+
region: environment.SKILL_EMBEDDING_BEDROCK_REGION,
|
|
208
|
+
})
|
|
209
|
+
),
|
|
184
210
|
'CREATE CONSTRAINT tag_slug_unique IF NOT EXISTS FOR (t:Tag) REQUIRE (t.slug) IS UNIQUE',
|
|
185
211
|
'CREATE TEXT INDEX tag_name_text_idx IF NOT EXISTS FOR (t:Tag) ON (t.name)',
|
|
186
212
|
'CREATE INDEX has_role_id_idx IF NOT EXISTS FOR ()-[r:HAS_ROLE]-() ON (r.roleId)',
|
|
@@ -291,5 +317,7 @@ export * from './AppManifestVersion';
|
|
|
291
317
|
export * from './AppStoreListing';
|
|
292
318
|
export * from './CredentialActivity';
|
|
293
319
|
export * from './CredentialRefresh';
|
|
320
|
+
export * from './CredentialSharePreview';
|
|
294
321
|
export * from './ShareLink';
|
|
295
322
|
export * from './StatusList';
|
|
323
|
+
export * from './Campaign';
|
package/src/routes/app-store.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { TRPCError } from '@trpc/server';
|
|
2
|
-
import { v4 as uuid } from 'uuid';
|
|
3
2
|
import { z } from 'zod';
|
|
4
3
|
import {
|
|
5
4
|
PendingListingUpdateValidator,
|
|
@@ -48,6 +47,7 @@ import {
|
|
|
48
47
|
invalidateConsentContractForListingByScopeHashCache,
|
|
49
48
|
} from '@cache/app-store.caches';
|
|
50
49
|
import { logCredentialSent } from '@helpers/activity.helpers';
|
|
50
|
+
import { sendAppCredentialWithActivity } from '@helpers/app-credential-delivery.helpers';
|
|
51
51
|
import { getCredentialUri } from '@helpers/credential.helpers';
|
|
52
52
|
import { constructUri, resolveUri } from '@helpers/uri.helpers';
|
|
53
53
|
import { inflateObject } from '@helpers/objects.helpers';
|
|
@@ -947,15 +947,10 @@ export const handleSendCredentialEvent = async (
|
|
|
947
947
|
|
|
948
948
|
perf.mark('saIssue');
|
|
949
949
|
|
|
950
|
-
//
|
|
951
|
-
//
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
// Both writes produce the same Neo4j linkage as before; we just fan them out.
|
|
955
|
-
// At staging this saves ~40ms on the serial sum (logActivity 40ms + sendBoost 50ms).
|
|
956
|
-
const activityId = uuid();
|
|
957
|
-
const [, credentialUri] = await Promise.all([
|
|
958
|
-
logCredentialSent({
|
|
950
|
+
// Activity and delivery both write listing relationships; finish one before
|
|
951
|
+
// starting the other so one request cannot deadlock against its own writes.
|
|
952
|
+
const credentialUri = await sendAppCredentialWithActivity(
|
|
953
|
+
{
|
|
959
954
|
actorProfileId: integrationOwner.profileId,
|
|
960
955
|
recipientType: 'profile',
|
|
961
956
|
recipientIdentifier: target.profileId,
|
|
@@ -965,20 +960,18 @@ export const handleSendCredentialEvent = async (
|
|
|
965
960
|
listingId,
|
|
966
961
|
source: 'appEvent',
|
|
967
962
|
metadata: { listingId, templateAlias },
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
sendBoost({
|
|
963
|
+
},
|
|
964
|
+
{
|
|
971
965
|
from: issuer,
|
|
972
966
|
to: target,
|
|
973
967
|
boost,
|
|
974
968
|
credential,
|
|
975
969
|
domain: ctx.domain,
|
|
976
970
|
skipNotification: false,
|
|
977
|
-
activityId,
|
|
978
971
|
integrationId: integration.id,
|
|
979
972
|
listingId,
|
|
980
|
-
}
|
|
981
|
-
|
|
973
|
+
}
|
|
974
|
+
);
|
|
982
975
|
|
|
983
976
|
perf.mark('logActivityAndSendBoost');
|
|
984
977
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import {
|
|
4
|
+
CampaignAudienceInputValidator,
|
|
5
|
+
CampaignEventValidator,
|
|
6
|
+
CampaignIdValidator,
|
|
7
|
+
CampaignStatsValidator,
|
|
8
|
+
CampaignStatusValidator,
|
|
9
|
+
CampaignValidator,
|
|
10
|
+
CreateCampaignInputValidator,
|
|
11
|
+
ListCampaignsInputValidator,
|
|
12
|
+
MyCampaignValidator,
|
|
13
|
+
PageCampaignAudienceInputValidator,
|
|
14
|
+
PaginatedCampaignAudienceValidator,
|
|
15
|
+
PaginatedCampaignsValidator,
|
|
16
|
+
UpdateCampaignInputValidator,
|
|
17
|
+
type Campaign,
|
|
18
|
+
type CampaignStats,
|
|
19
|
+
type MyCampaign,
|
|
20
|
+
type PaginatedCampaignAudience,
|
|
21
|
+
type PaginatedCampaigns,
|
|
22
|
+
} from '@learncard/types';
|
|
23
|
+
|
|
24
|
+
import { t, profileRouteWithoutInputCapture } from '@routes';
|
|
25
|
+
import { isCampaignAdmin } from 'src/constants/campaigns';
|
|
26
|
+
import { enforceRateLimits } from '@helpers/rateLimit.helpers';
|
|
27
|
+
import { trace } from '@tracing';
|
|
28
|
+
import { createCampaign } from '@accesslayer/campaign/create';
|
|
29
|
+
import {
|
|
30
|
+
getCampaign,
|
|
31
|
+
listCampaigns,
|
|
32
|
+
getLiveCampaignsForProfile,
|
|
33
|
+
getCampaignStats,
|
|
34
|
+
} from '@accesslayer/campaign/read';
|
|
35
|
+
import { updateCampaign } from '@accesslayer/campaign/update';
|
|
36
|
+
import { deleteCampaign } from '@accesslayer/campaign/delete';
|
|
37
|
+
import {
|
|
38
|
+
addCampaignAudience,
|
|
39
|
+
previewCampaignAudience,
|
|
40
|
+
pageCampaignAudience,
|
|
41
|
+
} from '@accesslayer/campaign/audience';
|
|
42
|
+
import { enrollInCampaign, recordCampaignEvent } from '@accesslayer/campaign/state';
|
|
43
|
+
|
|
44
|
+
const campaignAdminRoute = profileRouteWithoutInputCapture
|
|
45
|
+
.meta({ requiredScope: 'campaigns:admin' })
|
|
46
|
+
.use(({ ctx, next }) => {
|
|
47
|
+
if (!isCampaignAdmin(ctx.user.profile.profileId))
|
|
48
|
+
throw new TRPCError({
|
|
49
|
+
code: 'FORBIDDEN',
|
|
50
|
+
message: 'Campaign administrator access required',
|
|
51
|
+
});
|
|
52
|
+
return next({ ctx });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const campaignUserRoute = profileRouteWithoutInputCapture.use(async ({ ctx, next }) => {
|
|
56
|
+
await enforceRateLimits([
|
|
57
|
+
{
|
|
58
|
+
key: `campaigns:user:${ctx.user.profile.profileId}`,
|
|
59
|
+
limit: 60,
|
|
60
|
+
windowSeconds: 60,
|
|
61
|
+
description: '60 campaign requests per profile per minute',
|
|
62
|
+
},
|
|
63
|
+
]);
|
|
64
|
+
return next({ ctx });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const idInput = z.object({ campaignId: CampaignIdValidator }).strict();
|
|
68
|
+
const noInput = z.object({}).strict().default({});
|
|
69
|
+
|
|
70
|
+
export const campaignsRouter = t.router({
|
|
71
|
+
isAdmin: campaignUserRoute
|
|
72
|
+
.meta({ requiredScope: 'campaigns:read' })
|
|
73
|
+
.input(noInput)
|
|
74
|
+
.output(z.boolean())
|
|
75
|
+
.query(async ({ ctx }): Promise<boolean> =>
|
|
76
|
+
trace('route', 'campaigns:isAdmin', async () =>
|
|
77
|
+
isCampaignAdmin(ctx.user.profile.profileId)
|
|
78
|
+
)
|
|
79
|
+
),
|
|
80
|
+
create: campaignAdminRoute
|
|
81
|
+
.input(CreateCampaignInputValidator)
|
|
82
|
+
.output(CampaignValidator)
|
|
83
|
+
.mutation(async ({ ctx, input }): Promise<Campaign> =>
|
|
84
|
+
trace('route', 'campaigns:create', () =>
|
|
85
|
+
createCampaign(input, ctx.user.profile.profileId)
|
|
86
|
+
)
|
|
87
|
+
),
|
|
88
|
+
update: campaignAdminRoute
|
|
89
|
+
.input(UpdateCampaignInputValidator)
|
|
90
|
+
.output(CampaignValidator)
|
|
91
|
+
.mutation(async ({ input }): Promise<Campaign> =>
|
|
92
|
+
trace('route', 'campaigns:update', () => updateCampaign(input))
|
|
93
|
+
),
|
|
94
|
+
get: campaignAdminRoute
|
|
95
|
+
.input(idInput)
|
|
96
|
+
.output(CampaignValidator)
|
|
97
|
+
.query(async ({ input }): Promise<Campaign> =>
|
|
98
|
+
trace('route', 'campaigns:get', () => getCampaign(input.campaignId))
|
|
99
|
+
),
|
|
100
|
+
list: campaignAdminRoute
|
|
101
|
+
.input(ListCampaignsInputValidator.prefault({}))
|
|
102
|
+
.output(PaginatedCampaignsValidator)
|
|
103
|
+
.query(async ({ input }): Promise<PaginatedCampaigns> =>
|
|
104
|
+
trace('route', 'campaigns:list', () => listCampaigns(input))
|
|
105
|
+
),
|
|
106
|
+
delete: campaignAdminRoute
|
|
107
|
+
.input(idInput)
|
|
108
|
+
.output(z.boolean())
|
|
109
|
+
.mutation(async ({ input }): Promise<boolean> =>
|
|
110
|
+
trace('route', 'campaigns:delete', () => deleteCampaign(input.campaignId))
|
|
111
|
+
),
|
|
112
|
+
setStatus: campaignAdminRoute
|
|
113
|
+
.input(idInput.extend({ status: CampaignStatusValidator }))
|
|
114
|
+
.output(CampaignValidator)
|
|
115
|
+
.mutation(async ({ input }): Promise<Campaign> =>
|
|
116
|
+
trace('route', 'campaigns:setStatus', () =>
|
|
117
|
+
updateCampaign({ campaignId: input.campaignId, updates: { status: input.status } })
|
|
118
|
+
)
|
|
119
|
+
),
|
|
120
|
+
previewAudience: campaignAdminRoute
|
|
121
|
+
.input(CampaignAudienceInputValidator)
|
|
122
|
+
.output(z.object({ count: z.number() }))
|
|
123
|
+
.query(async ({ input, ctx }): Promise<{ count: number }> =>
|
|
124
|
+
trace('route', 'campaigns:previewAudience', async () => ({
|
|
125
|
+
count: await previewCampaignAudience(input.campaignId, input.source, ctx.domain),
|
|
126
|
+
}))
|
|
127
|
+
),
|
|
128
|
+
addAudience: campaignAdminRoute
|
|
129
|
+
.input(CampaignAudienceInputValidator)
|
|
130
|
+
.output(z.object({ count: z.number() }))
|
|
131
|
+
.mutation(async ({ input, ctx }): Promise<{ count: number }> =>
|
|
132
|
+
trace('route', 'campaigns:addAudience', async () => ({
|
|
133
|
+
count: await addCampaignAudience(input.campaignId, input.source, ctx.domain),
|
|
134
|
+
}))
|
|
135
|
+
),
|
|
136
|
+
pageAudience: campaignAdminRoute
|
|
137
|
+
.input(PageCampaignAudienceInputValidator)
|
|
138
|
+
.output(PaginatedCampaignAudienceValidator)
|
|
139
|
+
.query(async ({ input }): Promise<PaginatedCampaignAudience> =>
|
|
140
|
+
trace('route', 'campaigns:pageAudience', () =>
|
|
141
|
+
pageCampaignAudience(input.campaignId, input.cursor, input.limit)
|
|
142
|
+
)
|
|
143
|
+
),
|
|
144
|
+
getStats: campaignAdminRoute
|
|
145
|
+
.input(idInput)
|
|
146
|
+
.output(CampaignStatsValidator)
|
|
147
|
+
.query(async ({ input }): Promise<CampaignStats> =>
|
|
148
|
+
trace('route', 'campaigns:getStats', () => getCampaignStats(input.campaignId))
|
|
149
|
+
),
|
|
150
|
+
getMyCampaigns: campaignUserRoute
|
|
151
|
+
.meta({ requiredScope: 'campaigns:read' })
|
|
152
|
+
.input(noInput)
|
|
153
|
+
.output(z.array(MyCampaignValidator))
|
|
154
|
+
.query(async ({ ctx }): Promise<MyCampaign[]> =>
|
|
155
|
+
trace('route', 'campaigns:getMyCampaigns', () =>
|
|
156
|
+
getLiveCampaignsForProfile(ctx.user.profile.profileId)
|
|
157
|
+
)
|
|
158
|
+
),
|
|
159
|
+
recordEvent: campaignUserRoute
|
|
160
|
+
.meta({ requiredScope: 'campaigns:write' })
|
|
161
|
+
.input(idInput.extend({ event: CampaignEventValidator }))
|
|
162
|
+
.output(z.boolean())
|
|
163
|
+
.mutation(async ({ input, ctx }): Promise<boolean> =>
|
|
164
|
+
trace('route', 'campaigns:recordEvent', () =>
|
|
165
|
+
recordCampaignEvent(ctx.user.profile.profileId, input.campaignId, input.event)
|
|
166
|
+
)
|
|
167
|
+
),
|
|
168
|
+
enroll: campaignUserRoute
|
|
169
|
+
.meta({ requiredScope: 'campaigns:write' })
|
|
170
|
+
.input(idInput)
|
|
171
|
+
.output(z.boolean())
|
|
172
|
+
.mutation(async ({ input, ctx }): Promise<boolean> =>
|
|
173
|
+
trace('route', 'campaigns:enroll', () =>
|
|
174
|
+
enrollInCampaign(ctx.user.profile.profileId, input.campaignId)
|
|
175
|
+
)
|
|
176
|
+
),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
export type CampaignsRouter = typeof campaignsRouter;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import {
|
|
4
|
+
CredentialSharePreviewValidator,
|
|
5
|
+
PublishCredentialSharePreviewValidator,
|
|
6
|
+
} from '@learncard/types';
|
|
7
|
+
import {
|
|
8
|
+
createCredentialSharePreview,
|
|
9
|
+
deleteCredentialSharePreview,
|
|
10
|
+
getCredentialSharePreview,
|
|
11
|
+
} from '@accesslayer/credential-share-previews';
|
|
12
|
+
import { t, profileRoute, openRoute } from '@routes';
|
|
13
|
+
|
|
14
|
+
const Id = z.string().uuid();
|
|
15
|
+
const previewOutput = CredentialSharePreviewValidator;
|
|
16
|
+
const openapi = (method: 'GET' | 'POST', path: `/${string}`, summary: string) => ({
|
|
17
|
+
method,
|
|
18
|
+
path,
|
|
19
|
+
summary,
|
|
20
|
+
tags: ['Credential Share Previews'],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const credentialSharePreviewsRouter = t.router({
|
|
24
|
+
publish: profileRoute
|
|
25
|
+
.meta({
|
|
26
|
+
openapi: {
|
|
27
|
+
...openapi(
|
|
28
|
+
'POST',
|
|
29
|
+
'/credential-share-previews',
|
|
30
|
+
'Publish credential share preview'
|
|
31
|
+
),
|
|
32
|
+
protect: true,
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
.input(PublishCredentialSharePreviewValidator)
|
|
36
|
+
.output(Id)
|
|
37
|
+
.mutation(async ({ ctx, input }) => {
|
|
38
|
+
const id = await createCredentialSharePreview(ctx.user.profile.profileId, input);
|
|
39
|
+
if (!id) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
40
|
+
return id;
|
|
41
|
+
}),
|
|
42
|
+
delete: profileRoute
|
|
43
|
+
.meta({
|
|
44
|
+
openapi: {
|
|
45
|
+
...openapi(
|
|
46
|
+
'POST',
|
|
47
|
+
'/credential-share-previews/{id}/delete',
|
|
48
|
+
'Delete owned credential share preview'
|
|
49
|
+
),
|
|
50
|
+
protect: true,
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
.input(z.object({ id: Id }).strict())
|
|
54
|
+
.output(z.boolean())
|
|
55
|
+
.mutation(({ ctx, input }) =>
|
|
56
|
+
deleteCredentialSharePreview(ctx.user.profile.profileId, input.id)
|
|
57
|
+
),
|
|
58
|
+
get: openRoute
|
|
59
|
+
.meta({
|
|
60
|
+
openapi: openapi(
|
|
61
|
+
'GET',
|
|
62
|
+
'/credential-share-previews/{id}',
|
|
63
|
+
'Get public credential share preview'
|
|
64
|
+
),
|
|
65
|
+
})
|
|
66
|
+
.input(z.object({ id: Id, shareUri: z.string().min(1).max(4096) }).strict())
|
|
67
|
+
.output(previewOutput)
|
|
68
|
+
.query(async ({ input }) => {
|
|
69
|
+
const preview = await getCredentialSharePreview(input.id, input.shareUri);
|
|
70
|
+
if (!preview) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
71
|
+
return preview;
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
export type CredentialSharePreviewsRouter = typeof credentialSharePreviewsRouter;
|
package/src/routes/profiles.ts
CHANGED
|
@@ -113,6 +113,7 @@ const UpdateProfileInputValidator = z.object({
|
|
|
113
113
|
image: z.string().optional(),
|
|
114
114
|
heroImage: z.string().optional(),
|
|
115
115
|
websiteLink: z.string().optional(),
|
|
116
|
+
linkedInOrganizationId: z.string().regex(/^\d*$/).optional(),
|
|
116
117
|
type: z.string().optional(),
|
|
117
118
|
email: z.string().optional(),
|
|
118
119
|
notificationsWebhook: z.string().optional(),
|
|
@@ -599,6 +600,7 @@ export const profilesRouter = t.router({
|
|
|
599
600
|
image,
|
|
600
601
|
heroImage,
|
|
601
602
|
websiteLink,
|
|
603
|
+
linkedInOrganizationId,
|
|
602
604
|
type,
|
|
603
605
|
email,
|
|
604
606
|
notificationsWebhook,
|
|
@@ -662,6 +664,11 @@ export const profilesRouter = t.router({
|
|
|
662
664
|
if (typeof bio === 'string') actualUpdates.bio = bio;
|
|
663
665
|
if (typeof heroImage === 'string') actualUpdates.heroImage = heroImage;
|
|
664
666
|
if (typeof websiteLink === 'string') actualUpdates.websiteLink = websiteLink;
|
|
667
|
+
if (typeof linkedInOrganizationId === 'string') {
|
|
668
|
+
// Neo4j removes a property when SET += receives null.
|
|
669
|
+
(actualUpdates as Record<string, unknown>).linkedInOrganizationId =
|
|
670
|
+
linkedInOrganizationId || null;
|
|
671
|
+
}
|
|
665
672
|
if (typeof type === 'string') actualUpdates.type = type;
|
|
666
673
|
if (typeof notificationsWebhook === 'string')
|
|
667
674
|
actualUpdates.notificationsWebhook = notificationsWebhook;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { resolveTenantFromRequest } from '@learncard/email-templates';
|
|
3
|
+
import type { ProfileType } from 'types/profile';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* LC-2206: semantic search read routing behind SKILL_EMBEDDING_BEDROCK_READ_ENABLED, through
|
|
7
|
+
* the real router and embedding helpers. Bedrock and Google are faked; nothing leaves the box.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const mocks = vi.hoisted(() => ({
|
|
11
|
+
store: new Map<string, string>(),
|
|
12
|
+
getProfileByDid: vi.fn(),
|
|
13
|
+
searchSkillsByEmbedding: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('@cache', () => ({
|
|
17
|
+
default: {
|
|
18
|
+
get: vi.fn(async (key: string) => mocks.store.get(key)),
|
|
19
|
+
set: vi.fn(async (key: string, value: string) => {
|
|
20
|
+
mocks.store.set(key, value);
|
|
21
|
+
return 'OK';
|
|
22
|
+
}),
|
|
23
|
+
incr: vi.fn(async () => 1),
|
|
24
|
+
node: {},
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
vi.mock('@accesslayer/profile/read', () => ({ getProfileByDid: mocks.getProfileByDid }));
|
|
28
|
+
vi.mock('@accesslayer/skill/read', async importOriginal => ({
|
|
29
|
+
...(await importOriginal<typeof import('@accesslayer/skill/read')>()),
|
|
30
|
+
searchSkillsByEmbedding: mocks.searchSkillsByEmbedding,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
import { environment } from '@environment';
|
|
34
|
+
import {
|
|
35
|
+
setLegacySkillEmbeddingForTests,
|
|
36
|
+
setSkillEmbeddingInvokeForTests,
|
|
37
|
+
} from '@helpers/skill-embedding.helpers';
|
|
38
|
+
|
|
39
|
+
import { type Context } from './index';
|
|
40
|
+
import { skillsRouter } from './skills';
|
|
41
|
+
|
|
42
|
+
const profile: ProfileType = {
|
|
43
|
+
profileId: 'searcher',
|
|
44
|
+
did: 'did:key:searcher',
|
|
45
|
+
displayName: 'Searcher',
|
|
46
|
+
shortBio: '',
|
|
47
|
+
bio: '',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const context: Context = {
|
|
51
|
+
domain: 'network.example.com',
|
|
52
|
+
tenant: resolveTenantFromRequest({}),
|
|
53
|
+
user: { did: profile.did, isChallengeValid: true, scope: '*:*' },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const vector = (dimension: number) =>
|
|
57
|
+
Array.from({ length: dimension }, () => 1 / Math.sqrt(dimension));
|
|
58
|
+
|
|
59
|
+
const novaBody = (values: number[]) =>
|
|
60
|
+
new TextEncoder().encode(
|
|
61
|
+
JSON.stringify({ embeddings: [{ embeddingType: 'TEXT', embedding: values }] })
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const ORIGINAL = {
|
|
65
|
+
SKILL_EMBEDDING_BEDROCK_ENABLED: environment.SKILL_EMBEDDING_BEDROCK_ENABLED,
|
|
66
|
+
SKILL_EMBEDDING_BEDROCK_READ_ENABLED: environment.SKILL_EMBEDDING_BEDROCK_READ_ENABLED,
|
|
67
|
+
SKILL_EMBEDDING_GOOGLE_API_KEY: environment.SKILL_EMBEDDING_GOOGLE_API_KEY,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
describe('skills.semanticSearchSkills read routing (LC-2206)', () => {
|
|
71
|
+
const invoke = vi.fn();
|
|
72
|
+
const legacyEmbed = vi.fn();
|
|
73
|
+
|
|
74
|
+
const search = (text = 'help customers with complaints') =>
|
|
75
|
+
skillsRouter.createCaller(context).semanticSearchSkills({ text, limit: 5 });
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
mocks.store.clear();
|
|
79
|
+
mocks.getProfileByDid.mockResolvedValue(profile);
|
|
80
|
+
mocks.searchSkillsByEmbedding.mockReset().mockResolvedValue([]);
|
|
81
|
+
invoke.mockReset().mockImplementation(async () => novaBody(vector(1024)));
|
|
82
|
+
legacyEmbed
|
|
83
|
+
.mockReset()
|
|
84
|
+
.mockImplementation(async (texts: string[]) => texts.map(() => vector(768)));
|
|
85
|
+
setSkillEmbeddingInvokeForTests(invoke as never);
|
|
86
|
+
setLegacySkillEmbeddingForTests(legacyEmbed);
|
|
87
|
+
|
|
88
|
+
// Deployed shape: Nova writes on, Google key present for the legacy read path.
|
|
89
|
+
environment.SKILL_EMBEDDING_BEDROCK_ENABLED = true;
|
|
90
|
+
environment.SKILL_EMBEDDING_GOOGLE_API_KEY = 'legacy-google-key';
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
setSkillEmbeddingInvokeForTests(undefined);
|
|
95
|
+
setLegacySkillEmbeddingForTests(undefined);
|
|
96
|
+
Object.assign(environment, ORIGINAL);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('flag off (default): queries the legacy 768-d index with a Google query embedding', async () => {
|
|
100
|
+
environment.SKILL_EMBEDDING_BEDROCK_READ_ENABLED = false;
|
|
101
|
+
|
|
102
|
+
await search();
|
|
103
|
+
|
|
104
|
+
expect(legacyEmbed).toHaveBeenCalledWith(['help customers with complaints']);
|
|
105
|
+
expect(invoke).not.toHaveBeenCalled();
|
|
106
|
+
const [embedding, limit, frameworkId, index] = mocks.searchSkillsByEmbedding.mock.calls[0]!;
|
|
107
|
+
expect(embedding).toHaveLength(768);
|
|
108
|
+
expect(limit).toBe(5);
|
|
109
|
+
expect(frameworkId).toBeUndefined();
|
|
110
|
+
expect(index).toEqual({
|
|
111
|
+
source: 'legacy',
|
|
112
|
+
indexName: 'skill_embedding_vector_idx',
|
|
113
|
+
dimension: 768,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('flag on: queries the Nova 1024-d index with a TEXT_RETRIEVAL query embedding', async () => {
|
|
118
|
+
environment.SKILL_EMBEDDING_BEDROCK_READ_ENABLED = true;
|
|
119
|
+
|
|
120
|
+
await search();
|
|
121
|
+
|
|
122
|
+
expect(legacyEmbed).not.toHaveBeenCalled();
|
|
123
|
+
expect(invoke).toHaveBeenCalledTimes(1);
|
|
124
|
+
const request = JSON.parse(invoke.mock.calls[0]![0].body);
|
|
125
|
+
expect(request.singleEmbeddingParams.embeddingPurpose).toBe('TEXT_RETRIEVAL');
|
|
126
|
+
const [embedding, , , index] = mocks.searchSkillsByEmbedding.mock.calls[0]!;
|
|
127
|
+
expect(embedding).toHaveLength(1024);
|
|
128
|
+
expect(index).toEqual({
|
|
129
|
+
source: 'nova',
|
|
130
|
+
indexName: 'skill_embedding_nova2_v1_0_d1024_idx',
|
|
131
|
+
dimension: 1024,
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('flipping the flag back off is the rollback: the same query goes back to legacy', async () => {
|
|
136
|
+
environment.SKILL_EMBEDDING_BEDROCK_READ_ENABLED = true;
|
|
137
|
+
await search();
|
|
138
|
+
environment.SKILL_EMBEDDING_BEDROCK_READ_ENABLED = false;
|
|
139
|
+
await search();
|
|
140
|
+
|
|
141
|
+
const indexes = mocks.searchSkillsByEmbedding.mock.calls.map(call => call[3].indexName);
|
|
142
|
+
expect(indexes).toEqual([
|
|
143
|
+
'skill_embedding_nova2_v1_0_d1024_idx',
|
|
144
|
+
'skill_embedding_vector_idx',
|
|
145
|
+
]);
|
|
146
|
+
// The cached Nova vector was not reused for the legacy index (or vice versa).
|
|
147
|
+
expect(mocks.searchSkillsByEmbedding.mock.calls[1]![0]).toHaveLength(768);
|
|
148
|
+
expect(legacyEmbed).toHaveBeenCalledTimes(1);
|
|
149
|
+
expect(invoke).toHaveBeenCalledTimes(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('flag off keeps main behaviour for long text (no Nova 8,192-character limit)', async () => {
|
|
153
|
+
environment.SKILL_EMBEDDING_BEDROCK_READ_ENABLED = false;
|
|
154
|
+
await expect(search('x'.repeat(9000))).resolves.toMatchObject({ records: [] });
|
|
155
|
+
|
|
156
|
+
environment.SKILL_EMBEDDING_BEDROCK_READ_ENABLED = true;
|
|
157
|
+
await expect(search('x'.repeat(9000))).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('flag off without a Google key fails closed instead of querying Nova', async () => {
|
|
161
|
+
environment.SKILL_EMBEDDING_BEDROCK_READ_ENABLED = false;
|
|
162
|
+
environment.SKILL_EMBEDDING_GOOGLE_API_KEY = undefined;
|
|
163
|
+
|
|
164
|
+
await expect(search()).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE' });
|
|
165
|
+
expect(invoke).not.toHaveBeenCalled();
|
|
166
|
+
expect(mocks.searchSkillsByEmbedding).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
});
|