Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
"clean": "rimraf -I build coverage deploy",
"compile": "tsc --build tsconfig.prod.json",
"prebuild": "npm run clean && npm run compile",
"build": "esbuild ./src/index.ts ./src/import/async/index.ts ./src/export/async/index.ts ./src/auth/authenticator/index.ts --bundle --tsconfig=tsconfig.prod.json --sourcemap --minify --platform=node --target=node16 --outdir=build --main-fields=module,main",
"postbuild": "cp ./package.json ./build/ && (cd ./build; npm pkg delete devDependencies; npm pkg delete dependencies; npm pkg delete scripts) && cp ./build/package.json ./build/import/async && mkdir build/rest && mv build/index.js build/index.js.map build/package.json ./build/rest/",
"build": "esbuild ./src/index.ts ./src/import/async/index.ts ./src/export/async/index.ts ./src/embeddings/handler/index.ts ./src/auth/authenticator/index.ts --bundle --tsconfig=tsconfig.prod.json --sourcemap --minify --platform=node --target=node16 --outdir=build --main-fields=module,main",
"postbuild": "cp ./package.json ./build/ && (cd ./build; npm pkg delete devDependencies; npm pkg delete dependencies; npm pkg delete scripts) && cp ./build/package.json ./build/import/async && cp ./build/package.json ./build/embeddings/handler && mkdir build/rest && mv build/index.js build/index.js.map build/package.json ./build/rest/",
"test": "jest --coverage",
"test:integration": "jest --config jest.integration.config.js --runInBand",
"test:smoke": "jest --config jest.smoke.config.js --runInBand test/smoke/*.test.ts",
Expand Down
4 changes: 4 additions & 0 deletions backend/src/_test_utilities/getTestConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,9 @@ export function getTestConfiguration(dbname: string): IConfiguration {
asyncImportLambdaFunctionArn: "arn:aws:lambda:foo:bar:baz:import",
asyncExportLambdaFunctionArn: "arn:aws:lambda:foo:bar:baz:export",
asyncLambdaFunctionRegion: "foo",
geminiApiKey: "test-gemini-api-key",
geminiEmbeddingModel: "text-embedding-004",
embeddingsQueueUrl: "https://sqs.foo.amazonaws.com/123456789012/test-embeddings-queue",
embeddingsQueueRegion: "foo",
};
}
1 change: 1 addition & 0 deletions backend/src/common/lambda.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export enum Lambdas {
API = "API",
IMPORT = "IMPORT",
EXPORT = "EXPORT",
EMBEDDING = "EMBEDDING",
}
50 changes: 50 additions & 0 deletions backend/src/embeddings/handler/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { SQSEvent, SQSRecord } from "aws-lambda";
import { initOnce as serverInitOnce } from "server/init";
import { initializeSentry } from "initializeSentry";
import * as Sentry from "@sentry/aws-serverless";
import { Lambdas } from "common/lambda.types";
import { IGenerateEmbeddingTask } from "embeddings/service/types";
import { EmbeddingService } from "embeddings/service/service";

initializeSentry(Lambdas.EMBEDDING);

async function initOnce() {
await serverInitOnce();
return new EmbeddingService();
}

/**
* Entry point for the embeddings lambda function.
*
* This lambda is triggered by the embeddings SQS queue via an event source mapping: when N messages
* are available on the queue, the lambda is invoked with up to N records in `event.Records`, and this
* handler generates the embeddings for each of them in the background.
*
* If a record throws, we rethrow so that AWS retries the message and, after the configured number of
* attempts, moves it to the dead-letter queue.
*/
export const handler = Sentry.wrapHandler(async (event: SQSEvent): Promise<void> => {
const records = event.Records ?? [];
console.info(`Embeddings lambda triggered with ${records.length} record(s)`);

// Initialize the connection to the database (and registries). If it fails, rethrow so the lambda is retried.
const embeddingService = await initOnce();

for (const record of records) {
await handleRecord(embeddingService, record);
}
});

async function handleRecord(embeddingService: EmbeddingService, record: SQSRecord): Promise<void> {
let task: IGenerateEmbeddingTask;
try {
task = JSON.parse(record.body) as IGenerateEmbeddingTask;
} catch (e: unknown) {
// A malformed message will never succeed on retry, so log and skip it rather than throwing.
console.error(new Error(`Embeddings lambda: skipping unparseable SQS record ${record.messageId}`, { cause: e }));
return;
}

console.info("Processing embedding task", { messageId: record.messageId, task });
await embeddingService.processTask(task);
}
43 changes: 43 additions & 0 deletions backend/src/embeddings/models/gemini/geminiService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { IEmbeddingModelService } from "embeddings/models/modelsServiceTypes";

/**
* Base URL of the Gemini (Generative Language) REST API.
*/
// const GEMINI_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";

/**
* GeminiService talks to the Gemini REST API directly via `fetch`
* (no SDK dependency, so it bundles cleanly with esbuild).
*
* See:
* - https://ai.google.dev/api/embeddings#method:-models.embedcontent
* - https://ai.google.dev/api/embeddings#method:-models.batchembedcontents
*/
export class GeminiService implements IEmbeddingModelService {
private readonly apiKey: string;
private readonly model: string;

constructor(apiKey: string, model: string) {
if (!apiKey) {
throw new Error("GeminiService: GEMINI_API_KEY is not configured");
}
this.apiKey = apiKey;

if (!model)
throw new Error(
"GeminiService: GEMINI_MODEL_NAME is not configured. Please set it in the environment variables or in the config file."
);
this.model = model;
}

async generateEmbeddingBatch(texts: string[]): Promise<number[][]> {
console.debug("GeminiService: generateEmbeddingBatch", { texts });
console.warn("Not implemented yet");
return [[]];
}

async generateEmbedding(text: string): Promise<number[]> {
const [vector] = await this.generateEmbeddingBatch([text]);
return vector;
}
}
16 changes: 16 additions & 0 deletions backend/src/embeddings/models/modelsServiceTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export interface IEmbeddingModelService {
/**
* Generate an embedding vector for the given text.
*
* @param text - The text to embed.
* @returns The embedding vector as an array of numbers.
* @throws If the API key is not configured, or the API returns an error / empty response.
*/
generateEmbedding(text: string): Promise<number[]>;

/**
* Generate embeddings for a batch of texts.
* @param texts
*/
generateEmbeddingBatch(texts: string[]): Promise<number[][]>;
}
12 changes: 12 additions & 0 deletions backend/src/embeddings/service/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { IGenerateEmbeddingTask } from "./types";

export interface IEmbeddingClient {
pushTaskToQueue(task: IGenerateEmbeddingTask): Promise<void>;
}

export class EmbeddingClient implements IEmbeddingClient {
pushTaskToQueue(task: IGenerateEmbeddingTask): Promise<void> {
console.debug("EmbeddingClient.pushTaskToQueue", task);
throw new Error("Method not implemented.");
}
}
17 changes: 17 additions & 0 deletions backend/src/embeddings/service/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { IGenerateEmbeddingTask } from "./types";

export interface IEmbeddingService {
processTask(task: IGenerateEmbeddingTask): Promise<void>;
}

export class EmbeddingService implements IEmbeddingService {
constructor() {}

async processTask(task: IGenerateEmbeddingTask): Promise<void> {
console.info("Generated embedding", {
modelId: task.modelId,
entityType: task.entityType,
entityId: task.entityId,
});
}
}
20 changes: 20 additions & 0 deletions backend/src/embeddings/service/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export enum EmbeddableEntityType {
Skill = "Skill",
Occupation = "Occupation",
OccupationGroup = "OccupationGroup",
SkillGroup = "SkillGroup",
}

export enum EmbeddableField {
preferredLabel = "preferredLabel",
description = "description",
altLabels = "altLabels",
scopeNote = "scopeNote",
}

export interface IGenerateEmbeddingTask {
modelId: string;
entityId: string;
entityType: EmbeddableEntityType;
fields: EmbeddableField;
}
3 changes: 3 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { APIGatewayProxyEvent, Handler } from "aws-lambda";
import { handler as InfoHandler } from "applicationInfo";
import { handler as ModelHandler } from "modelInfo";
import { handler as ModelEmbeddingProcessesHandler } from "modelInfo/embeddingProcesses";
import { handler as ImportHandler } from "import";
import { handler as OccupationGroupHandler } from "esco/occupationGroup/index";
import { handler as OccupationHandler } from "esco/occupations";
Expand Down Expand Up @@ -65,6 +66,8 @@ export const handleRouteEvent = async (event: APIGatewayProxyEvent) => {
return ImportHandler(event);
} else if (path === Routes.EXPORT_ROUTE) {
return ExportHandler(event);
} else if (pathToRegexp(Routes.MODEL_EMBEDDING_PROCESSES_ROUTE).regexp.test(path)) {
return ModelEmbeddingProcessesHandler(event);
} else if (
pathToRegexp([
Routes.OCCUPATION_GROUPS_ROUTE,
Expand Down
8 changes: 8 additions & 0 deletions backend/src/modelInfo/embeddingProcesses/POST/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";

export class POSTModelEmbeddingProcessesHandler {
async handle(_event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> {
console.log(_event);
throw new Error("Not implemented yet");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The POST handler for model embedding processes is an unimplemented stub that will cause all requests to this endpoint to return a 500 error.
Severity: HIGH

Suggested Fix

Implement the logic for the POSTModelEmbeddingProcessesHandler to correctly handle the creation of model embedding processes. If this feature is not ready for release, either remove the route from the main router in backend/src/index.ts or place it behind a feature flag to prevent access.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: backend/src/modelInfo/embeddingProcesses/POST/index.ts#L6

Potential issue: The POST handler for the `/models/:modelId/embedding-processes` route
is a stub that unconditionally throws `new Error("Not implemented yet")`. This route is
wired into the production router without any feature flag or conditional logic to
prevent access. Consequently, any POST request to this endpoint will be caught by a
generic error handler, resulting in a 500 Internal Server Error response for the client.
The endpoint is effectively broken and will fail for every request.

Did we get this right? 👍 / 👎 to inform future reviews.

}
}
15 changes: 15 additions & 0 deletions backend/src/modelInfo/embeddingProcesses/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { APIGatewayProxyEvent } from "aws-lambda";
import { APIGatewayProxyResult } from "aws-lambda/trigger/api-gateway-proxy";
import { HTTP_VERBS, STD_ERRORS_RESPONSES } from "server/httpUtils";
import { POSTModelEmbeddingProcessesHandler } from "./POST";

export const handler: (
event: APIGatewayProxyEvent /*, context: Context, callback: Callback*/
) => Promise<APIGatewayProxyResult> = async (
event: APIGatewayProxyEvent /*, context: Context, callback: Callback*/
) => {
if (event?.httpMethod === HTTP_VERBS.POST) {
return new POSTModelEmbeddingProcessesHandler().handle(event);
}
return STD_ERRORS_RESPONSES.METHOD_NOT_ALLOWED;
};
1 change: 1 addition & 0 deletions backend/src/routes.constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ export const Routes = {
SKILL_OCCUPATIONS_ROUTE: "/models/:modelId/skills/:id/occupations",
SKILL_RELATED_ROUTE: "/models/:modelId/skills/:id/related",
SKILL_HISTORY_ROUTE: "/models/:modelId/skills/:id/history",
MODEL_EMBEDDING_PROCESSES_ROUTE: "/models/:modelId/embedding-processes",
};
20 changes: 20 additions & 0 deletions backend/src/server/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ describe("Test read Configuration()", () => {
process.env.ASYNC_IMPORT_LAMBDA_FUNCTION_ARN = getRandomString(10);
process.env.ASYNC_EXPORT_LAMBDA_FUNCTION_ARN = getRandomString(10);
process.env.ASYNC_LAMBDA_FUNCTION_REGION = getRandomString(10);
process.env.GEMINI_API_KEY = getRandomString(10);
process.env.GEMINI_EMBEDDING_MODEL = getRandomString(10);
process.env.EMBEDDINGS_QUEUE_URL = getRandomString(10);
process.env.EMBEDDINGS_QUEUE_REGION = getRandomString(10);

// WHEN reading the configuration from the environment
const actualConfig = readEnvironmentConfiguration();
Expand All @@ -63,6 +67,10 @@ describe("Test read Configuration()", () => {
asyncImportLambdaFunctionArn: process.env.ASYNC_IMPORT_LAMBDA_FUNCTION_ARN,
asyncExportLambdaFunctionArn: process.env.ASYNC_EXPORT_LAMBDA_FUNCTION_ARN,
asyncLambdaFunctionRegion: process.env.ASYNC_LAMBDA_FUNCTION_REGION,
geminiApiKey: process.env.GEMINI_API_KEY,
geminiEmbeddingModel: process.env.GEMINI_EMBEDDING_MODEL,
embeddingsQueueUrl: process.env.EMBEDDINGS_QUEUE_URL,
embeddingsQueueRegion: process.env.EMBEDDINGS_QUEUE_REGION,
});
});

Expand All @@ -77,6 +85,10 @@ describe("Test read Configuration()", () => {
delete process.env.DOWNLOAD_BUCKET_REGION;
delete process.env.ASYNC_LAMBDA_FUNCTION_ARN;
delete process.env.ASYNC_LAMBDA_FUNCTION_REGION;
delete process.env.GEMINI_API_KEY;
delete process.env.GEMINI_EMBEDDING_MODEL;
delete process.env.EMBEDDINGS_QUEUE_URL;
delete process.env.EMBEDDINGS_QUEUE_REGION;

// WHEN reading the configuration from the environment
const config = readEnvironmentConfiguration();
Expand All @@ -93,6 +105,10 @@ describe("Test read Configuration()", () => {
asyncImportLambdaFunctionArn: "",
asyncExportLambdaFunctionArn: "",
asyncLambdaFunctionRegion: "",
geminiApiKey: "",
geminiEmbeddingModel: "",
embeddingsQueueUrl: "",
embeddingsQueueRegion: "",
});
});
});
Expand Down Expand Up @@ -189,5 +205,9 @@ function getMockConfig(): IConfiguration {
asyncImportLambdaFunctionArn: getTestString(10),
asyncExportLambdaFunctionArn: getTestString(10),
asyncLambdaFunctionRegion: getTestString(10),
geminiApiKey: getTestString(10),
geminiEmbeddingModel: getTestString(10),
embeddingsQueueRegion: getTestString(10),
embeddingsQueueUrl: getTestString(10),
};
}
28 changes: 28 additions & 0 deletions backend/src/server/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export const ENV_VAR_NAMES = {
ASYNC_IMPORT_LAMBDA_FUNCTION_ARN: "ASYNC_IMPORT_LAMBDA_FUNCTION_ARN",
ASYNC_EXPORT_LAMBDA_FUNCTION_ARN: "ASYNC_EXPORT_LAMBDA_FUNCTION_ARN",
ASYNC_LAMBDA_FUNCTION_REGION: "ASYNC_LAMBDA_FUNCTION_REGION",
GEMINI_API_KEY: "GEMINI_API_KEY",
GEMINI_EMBEDDING_MODEL: "GEMINI_EMBEDDING_MODEL",
EMBEDDINGS_QUEUE_URL: "EMBEDDINGS_QUEUE_URL",
EMBEDDINGS_QUEUE_REGION: "EMBEDDINGS_QUEUE_REGION",
};

export interface IConfiguration {
Expand All @@ -24,6 +28,10 @@ export interface IConfiguration {
asyncImportLambdaFunctionArn: string;
asyncExportLambdaFunctionArn: string;
asyncLambdaFunctionRegion: string;
geminiApiKey: string;
geminiEmbeddingModel: string;
embeddingsQueueUrl: string;
embeddingsQueueRegion: string;
}
export function readEnvironmentConfiguration(): IConfiguration {
return {
Expand All @@ -37,6 +45,10 @@ export function readEnvironmentConfiguration(): IConfiguration {
asyncImportLambdaFunctionArn: process.env[ENV_VAR_NAMES.ASYNC_IMPORT_LAMBDA_FUNCTION_ARN] ?? "",
asyncExportLambdaFunctionArn: process.env[ENV_VAR_NAMES.ASYNC_EXPORT_LAMBDA_FUNCTION_ARN] ?? "",
asyncLambdaFunctionRegion: process.env[ENV_VAR_NAMES.ASYNC_LAMBDA_FUNCTION_REGION] ?? "",
geminiApiKey: process.env[ENV_VAR_NAMES.GEMINI_API_KEY] ?? "",
geminiEmbeddingModel: process.env[ENV_VAR_NAMES.GEMINI_EMBEDDING_MODEL] ?? "",
embeddingsQueueUrl: process.env[ENV_VAR_NAMES.EMBEDDINGS_QUEUE_URL] ?? "",
embeddingsQueueRegion: process.env[ENV_VAR_NAMES.EMBEDDINGS_QUEUE_REGION] ?? "",
};
}

Expand Down Expand Up @@ -88,3 +100,19 @@ export function getAsyncLambdaFunctionRegion() {
export function getDomainName() {
return _configuration?.domainName ?? "";
}

export function getGeminiApiKey() {
return _configuration?.geminiApiKey ?? "";
}

export function getGeminiEmbeddingModel() {
return _configuration?.geminiEmbeddingModel ?? "";
}

export function getEmbeddingsQueueUrl() {
return _configuration?.embeddingsQueueUrl ?? "";
}

export function getEmbeddingsQueueRegion() {
return _configuration?.embeddingsQueueRegion ?? "";
}
Loading
Loading