From 8ef61850b4afced527e7afeb678222dc9b638a8c Mon Sep 17 00:00:00 2001 From: Andriamanampisoa Date: Thu, 4 Jun 2026 17:54:47 +0900 Subject: [PATCH 1/8] feat(simulation): add redis queue and multi-user interview slots --- server/.env.example | 16 + server/docker-compose.yml | 5 +- server/package.json | 5 +- server/src/common/enums/AiInterviewStatus.ts | 9 + server/src/common/redis/redis.constants.ts | 3 + server/src/common/redis/redis.module.ts | 43 +++ server/src/modules/ai/ai.controller.ts | 30 +- server/src/modules/ai/ai.module.ts | 2 + server/src/modules/ai/ai.service.spec.ts | 290 +++++------------ server/src/modules/ai/ai.service.ts | 302 ++++++++++++------ .../modules/ai/dto/createAiInterview.dto.ts | 12 +- .../ai/dto/createAiInterviewResponse.dto.ts | 24 ++ .../modules/ai/dto/interviewSession.dto.ts | 23 ++ .../dto/appendSimulationHistory.dto.ts | 11 + .../guards/internal-api-key.guard.ts | 30 ++ .../simulation-capacity.service.spec.ts | 64 ++++ .../simulation/simulation-capacity.service.ts | 183 +++++++++++ .../simulation/simulation-context.service.ts | 107 +++++++ .../simulation-internal.controller.ts | 33 ++ .../simulation-promotion.service.ts | 164 ++++++++++ .../simulation/simulation-ws-token.service.ts | 33 ++ .../modules/simulation/simulation.config.ts | 43 +++ .../src/modules/simulation/simulation.cron.ts | 53 +++ .../modules/simulation/simulation.module.ts | 39 +++ .../simulation/simulation.redis-keys.ts | 11 + 25 files changed, 1230 insertions(+), 305 deletions(-) create mode 100644 server/src/common/redis/redis.constants.ts create mode 100644 server/src/common/redis/redis.module.ts create mode 100644 server/src/modules/ai/dto/createAiInterviewResponse.dto.ts create mode 100644 server/src/modules/ai/dto/interviewSession.dto.ts create mode 100644 server/src/modules/simulation/dto/appendSimulationHistory.dto.ts create mode 100644 server/src/modules/simulation/guards/internal-api-key.guard.ts create mode 100644 server/src/modules/simulation/simulation-capacity.service.spec.ts create mode 100644 server/src/modules/simulation/simulation-capacity.service.ts create mode 100644 server/src/modules/simulation/simulation-context.service.ts create mode 100644 server/src/modules/simulation/simulation-internal.controller.ts create mode 100644 server/src/modules/simulation/simulation-promotion.service.ts create mode 100644 server/src/modules/simulation/simulation-ws-token.service.ts create mode 100644 server/src/modules/simulation/simulation.config.ts create mode 100644 server/src/modules/simulation/simulation.cron.ts create mode 100644 server/src/modules/simulation/simulation.module.ts create mode 100644 server/src/modules/simulation/simulation.redis-keys.ts diff --git a/server/.env.example b/server/.env.example index cce67c50..5057f940 100644 --- a/server/.env.example +++ b/server/.env.example @@ -15,3 +15,19 @@ FRONTEND_URL=http://localhost:5173 # AI C++ server (ai/docker-compose, port 8088). In Docker on Linux, use host.docker.internal # with extra_hosts in docker-compose.yml, or set e.g. http://172.17.0.1:8088 # AI_SERVER_URL=http://host.docker.internal:8088 + +# Public WebSocket URL for browsers (production: wss://ai.your-domain/ws). Local default derives from AI_SERVER_URL. +# AI_WS_PUBLIC_URL=ws://localhost:8088/ws + +# Shared key with ai_server COMMUNICATION / VITE_WEBSOCKET_KEY (initialization endpoint). +# AI_COMMUNICATION_KEY=c7yPY8u644OE + +# --- Simulation capacity (Scenario A) — requires Redis --- +# REDIS_URL=redis://127.0.0.1:6379 +# SIM_MAX_CONCURRENT=2 +# SIM_QUEUE_MAX_SIZE=20 +# SIM_INTERNAL_API_KEY=change-me-internal-key +# SIM_SLOT_TTL_SEC=900 +# SIM_QUEUE_ENTRY_TTL_SEC=3600 +# SIM_WS_TOKEN_TTL_SEC=900 +# SIM_ESTIMATED_TURN_SEC=90 diff --git a/server/docker-compose.yml b/server/docker-compose.yml index ea637918..b42ed2ee 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -13,9 +13,12 @@ services: environment: - DOCKER_RUN=true - POSTGRES_DOCKER_HOST=postgres_db - - REDIS_URL=redis:6379 # Overrides server/.env — localhost inside this container is not the host. - AI_SERVER_URL=http://ai_server:8088 + - AI_WS_PUBLIC_URL=${AI_WS_PUBLIC_URL:-ws://localhost:8088/ws} + - REDIS_URL=redis://redis:6379 + - SIM_MAX_CONCURRENT=${SIM_MAX_CONCURRENT:-2} + - SIM_INTERNAL_API_KEY=${SIM_INTERNAL_API_KEY:-talkup-dev-internal-key} depends_on: postgres_db: condition: service_healthy diff --git a/server/package.json b/server/package.json index f9a20687..962fe3d9 100644 --- a/server/package.json +++ b/server/package.json @@ -19,7 +19,10 @@ "test:watch": "jest --watch --maxWorkers=4", "test:coverage": "jest --coverage --maxWorkers=4", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand --maxWorkers=4", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "redis:up": "docker compose up -d redis", + "redis:down": "docker compose stop redis", + "redis:logs": "docker compose logs -f redis" }, "dependencies": { "@nestjs/axios": "^4.0.0", diff --git a/server/src/common/enums/AiInterviewStatus.ts b/server/src/common/enums/AiInterviewStatus.ts index f32448ae..0472df2b 100644 --- a/server/src/common/enums/AiInterviewStatus.ts +++ b/server/src/common/enums/AiInterviewStatus.ts @@ -1,6 +1,15 @@ export enum AiInterviewStatus { + QUEUED = "queued", ASKED = "asked", IN_PROGRESS = "in_progress", COMPLETED = "completed", CANCELLED = "cancelled", + EXPIRED = "expired", } + +/** Statuses that block starting another simulation for the same user. */ +export const ACTIVE_SIMULATION_STATUSES: AiInterviewStatus[] = [ + AiInterviewStatus.QUEUED, + AiInterviewStatus.ASKED, + AiInterviewStatus.IN_PROGRESS, +]; diff --git a/server/src/common/redis/redis.constants.ts b/server/src/common/redis/redis.constants.ts new file mode 100644 index 00000000..9fca627f --- /dev/null +++ b/server/src/common/redis/redis.constants.ts @@ -0,0 +1,3 @@ +export const REDIS_CLIENT = "REDIS_CLIENT"; + +export const SIM_REDIS_PREFIX = "talkup:sim:"; diff --git a/server/src/common/redis/redis.module.ts b/server/src/common/redis/redis.module.ts new file mode 100644 index 00000000..2daf27a8 --- /dev/null +++ b/server/src/common/redis/redis.module.ts @@ -0,0 +1,43 @@ +import { Module, Logger } from "@nestjs/common"; +import Redis from "ioredis"; + +import { REDIS_CLIENT } from "./redis.constants"; + +@Module({ + providers: [ + { + provide: REDIS_CLIENT, + useFactory: () => { + const redisUrl = process.env.REDIS_URL; + if (!redisUrl) { + throw new Error( + "REDIS_URL is required for simulation capacity (e.g. redis://127.0.0.1:6379). See server/.env.example.", + ); + } + + const logger = new Logger("RedisModule"); + const client = new Redis(redisUrl, { + maxRetriesPerRequest: 3, + retryStrategy(times) { + if (times > 5) return null; + return Math.min(times * 200, 2000); + }, + lazyConnect: false, + enableReadyCheck: true, + }); + + client.on("error", (err) => { + logger.error(`Redis connection error: ${err.message}`); + }); + + client.on("connect", () => { + logger.log("Redis connected (simulation + shared)"); + }); + + return client; + }, + }, + ], + exports: [REDIS_CLIENT], +}) +export class RedisModule {} diff --git a/server/src/modules/ai/ai.controller.ts b/server/src/modules/ai/ai.controller.ts index 7a37e737..11806d03 100644 --- a/server/src/modules/ai/ai.controller.ts +++ b/server/src/modules/ai/ai.controller.ts @@ -32,6 +32,8 @@ import { CreateAiInterviewDto } from "./dto/createAiInterview.dto"; import { PutAiInterviewDto } from "./dto/putAiInterview.dto"; import { GetInterviewsQueryDto } from "./dto/getInterviewsQuery.dto"; import { CreateAiTranscriptsDto } from "./dto/createAiTranscripts.dto"; +import { CreateAiInterviewResponseDto } from "./dto/createAiInterviewResponse.dto"; +import { InterviewSessionDto } from "./dto/interviewSession.dto"; @ApiTags("AI") @Controller("ai") @@ -39,9 +41,15 @@ import { CreateAiTranscriptsDto } from "./dto/createAiTranscripts.dto"; export class AiController { constructor(private readonly aiService: AiService) {} + @ApiOkResponse({ description: "Current simulation capacity snapshot." }) + @Get("capacity") + async getCapacity() { + return this.aiService.getCapacity(); + } + @ApiCreatedResponse({ - description: "The AI interview has been successfully created.", - type: CreateAiInterviewDto, + description: "The AI interview has been successfully created or queued.", + type: CreateAiInterviewResponseDto, }) @ApiBadRequestResponse({ description: "Badly formatted parameter.", @@ -90,6 +98,24 @@ export class AiController { return await this.aiService.getInterviewById(id, userId, true); } + @ApiOkResponse({ + description: "Live simulation session state (queue position, entrypoint).", + type: InterviewSessionDto, + }) + @Get("interviews/:id/session") + async getInterviewSession( + @Param("id") id: string, + @UserId() userId: string, + ) { + return this.aiService.getInterviewSession(id, userId); + } + + @ApiOkResponse({ description: "Interview cancelled and slot released." }) + @Post("interviews/:id/cancel") + async cancelInterview(@Param("id") id: string, @UserId() userId: string) { + return this.aiService.cancelInterview(id, userId); + } + @ApiOkResponse({ description: "The AI interview has been successfully edited.", type: PutAiInterviewDto, diff --git a/server/src/modules/ai/ai.module.ts b/server/src/modules/ai/ai.module.ts index 5fa9f03f..438137ea 100644 --- a/server/src/modules/ai/ai.module.ts +++ b/server/src/modules/ai/ai.module.ts @@ -10,9 +10,11 @@ import { ai_transcript } from "@entities/aiTranscript.entity"; import { AiController } from "./ai.controller"; import { AiService } from "./ai.service"; +import { SimulationModule } from "../simulation/simulation.module"; @Module({ imports: [ + SimulationModule, TypeOrmModule.forFeature([ai_interview, ai_transcript, user]), HttpModule.register({ timeout: 5000, diff --git a/server/src/modules/ai/ai.service.spec.ts b/server/src/modules/ai/ai.service.spec.ts index d0cac6f2..7c9c0e79 100644 --- a/server/src/modules/ai/ai.service.spec.ts +++ b/server/src/modules/ai/ai.service.spec.ts @@ -2,10 +2,10 @@ import { ConflictException, InternalServerErrorException, NotFoundException, + ServiceUnavailableException, } from "@nestjs/common"; import { Test, TestingModule } from "@nestjs/testing"; import { getRepositoryToken } from "@nestjs/typeorm"; -import { HttpService } from "@nestjs/axios"; import { ai_interview } from "@entities/aiInterview.entity"; import { ai_transcript } from "@entities/aiTranscript.entity"; @@ -13,6 +13,9 @@ import { ai_transcript } from "@entities/aiTranscript.entity"; import { AiInterviewStatus } from "@common/enums/AiInterviewStatus"; import { AiService } from "./ai.service"; +import { SimulationCapacityService } from "../simulation/simulation-capacity.service"; +import { SimulationContextService } from "../simulation/simulation-context.service"; +import { SimulationPromotionService } from "../simulation/simulation-promotion.service"; describe("AiService", () => { let service: AiService; @@ -29,7 +32,9 @@ describe("AiService", () => { let mockAiInterviewRepo: any; let mockAiTranscriptRepo: any; - let mockHttpService: any; + let mockCapacity: any; + let mockPromotion: any; + let mockContext: any; beforeEach(async () => { mockAiInterviewRepo = { @@ -38,6 +43,8 @@ describe("AiService", () => { save: jest.fn(), find: jest.fn(), findAndCount: jest.fn(), + delete: jest.fn(), + update: jest.fn(), }; mockAiTranscriptRepo = { @@ -46,10 +53,32 @@ describe("AiService", () => { find: jest.fn(), }; - mockHttpService = { - axiosRef: { - post: jest.fn(), - }, + mockCapacity = { + tryAcquireSlot: jest.fn().mockResolvedValue({ acquired: true }), + enqueue: jest.fn().mockResolvedValue({ ok: true }), + getQueuePosition: jest.fn().mockResolvedValue(1), + releaseSlot: jest.fn(), + removeFromQueue: jest.fn(), + touchHeartbeat: jest.fn(), + getSnapshot: jest.fn().mockResolvedValue({ + active: 0, + max: 2, + queueLength: 0, + accepting: true, + }), + }; + + mockPromotion = { + prepareReadySession: jest + .fn() + .mockResolvedValue({ entrypoint: "ws://test/ws?token=abc" }), + getReadyPayload: jest.fn(), + promoteNextFromQueue: jest.fn(), + clearReady: jest.fn(), + }; + + mockContext = { + deleteContext: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ @@ -63,7 +92,9 @@ describe("AiService", () => { provide: getRepositoryToken(ai_transcript), useValue: mockAiTranscriptRepo, }, - { provide: HttpService, useValue: mockHttpService }, + { provide: SimulationCapacityService, useValue: mockCapacity }, + { provide: SimulationContextService, useValue: mockContext }, + { provide: SimulationPromotionService, useValue: mockPromotion }, ], }).compile(); @@ -75,88 +106,75 @@ describe("AiService", () => { }); describe("createInterview", () => { - it("creates an interview and returns entrypoint on success", async () => { + it("creates a ready interview when a slot is available", async () => { mockAiInterviewRepo.findOne.mockResolvedValueOnce(null); - mockHttpService.axiosRef.post.mockResolvedValueOnce({ - status: 200, - data: { data: "entry" }, - }); mockAiInterviewRepo.save.mockResolvedValueOnce({ interview_id: "new-id", }); const res = await service.createInterview( - { type: "Technical" } as any, + { type: "Technical", language: "French" } as any, "user-1", ); - expect(mockAiInterviewRepo.findOne).toHaveBeenCalled(); - expect(mockHttpService.axiosRef.post).toHaveBeenCalled(); - expect(mockAiInterviewRepo.create).toHaveBeenCalled(); - expect(mockAiInterviewRepo.save).toHaveBeenCalled(); - expect(res).toEqual({ interviewID: "new-id", entrypoint: "entry" }); - }); - - it("throws ConflictException when interview already exists", async () => { - mockAiInterviewRepo.findOne.mockResolvedValueOnce({ - interview_id: "exists", + expect(mockCapacity.tryAcquireSlot).toHaveBeenCalledWith( + "new-id", + "user-1", + ); + expect(mockPromotion.prepareReadySession).toHaveBeenCalled(); + expect(res).toEqual({ + interviewID: "new-id", + status: "ready", + entrypoint: "ws://test/ws?token=abc", + queuePosition: 0, }); - - await expect( - service.createInterview({} as any, "user-1"), - ).rejects.toThrow(ConflictException); }); - it("throws InternalServerErrorException when AI server returns non-200", async () => { + it("returns queued when no slot is available", async () => { mockAiInterviewRepo.findOne.mockResolvedValueOnce(null); - mockHttpService.axiosRef.post.mockResolvedValueOnce({ - status: 500, - data: {}, + mockCapacity.tryAcquireSlot.mockResolvedValueOnce({ + acquired: false, + reason: "capacity", + }); + mockAiInterviewRepo.save.mockResolvedValueOnce({ + interview_id: "new-id", }); - await expect( - service.createInterview({} as any, "user-1"), - ).rejects.toThrow(InternalServerErrorException); - }); - - it("throws InternalServerErrorException when http call fails", async () => { - mockAiInterviewRepo.findOne.mockResolvedValueOnce(null); - mockHttpService.axiosRef.post.mockRejectedValueOnce(new Error("network")); + const res = await service.createInterview( + { type: "Technical", language: "French" } as any, + "user-1", + ); - await expect( - service.createInterview({} as any, "user-1"), - ).rejects.toThrow(InternalServerErrorException); + expect(mockCapacity.enqueue).toHaveBeenCalledWith("new-id"); + expect(res.status).toBe("queued"); + expect(res.entrypoint).toBeNull(); + expect(res.queuePosition).toBe(1); }); - it("includes response data in log when axios error has response body", async () => { - mockAiInterviewRepo.findOne.mockResolvedValueOnce(null); - const axiosLike: any = new Error("bad request"); - axiosLike.response = { data: { detail: "invalid payload" } }; - mockHttpService.axiosRef.post.mockRejectedValueOnce(axiosLike); + it("throws ConflictException when interview already exists", async () => { + mockAiInterviewRepo.findOne.mockResolvedValueOnce({ + interview_id: "exists", + }); await expect( - service.createInterview({} as any, "user-1"), - ).rejects.toThrow(InternalServerErrorException); + service.createInterview({ type: "x", language: "fr" } as any, "user-1"), + ).rejects.toThrow(ConflictException); }); - it("throws InternalServerErrorException when save fails", async () => { + it("throws ServiceUnavailableException when queue is full", async () => { mockAiInterviewRepo.findOne.mockResolvedValueOnce(null); - mockHttpService.axiosRef.post.mockResolvedValueOnce({ - status: 200, - data: { data: "entry" }, + mockCapacity.tryAcquireSlot.mockResolvedValueOnce({ + acquired: false, + reason: "capacity", + }); + mockCapacity.enqueue.mockResolvedValueOnce({ ok: false }); + mockAiInterviewRepo.save.mockResolvedValueOnce({ + interview_id: "new-id", }); - mockAiInterviewRepo.save.mockRejectedValueOnce(new Error("db")); - - await expect( - service.createInterview({} as any, "user-1"), - ).rejects.toThrow(InternalServerErrorException); - }); - it("throws InternalServerErrorException when findOne throws", async () => { - mockAiInterviewRepo.findOne.mockRejectedValueOnce(new Error("boomfind")); await expect( - service.createInterview({} as any, "user-1"), - ).rejects.toThrow(InternalServerErrorException); + service.createInterview({ type: "x", language: "fr" } as any, "user-1"), + ).rejects.toThrow(ServiceUnavailableException); }); }); @@ -171,39 +189,22 @@ describe("AiService", () => { ).rejects.toThrow(NotFoundException); }); - it("updates interview and returns true when successful", async () => { + it("releases slot when interview is completed", async () => { const existing = { ...mockInterview, - status: AiInterviewStatus.ASKED, + status: AiInterviewStatus.IN_PROGRESS, } as any; jest.spyOn(service, "getInterviewById").mockResolvedValueOnce(existing); mockAiInterviewRepo.save.mockResolvedValueOnce(true); - const res = await service.editAiInterview( + await service.editAiInterview( "i1", { status: AiInterviewStatus.COMPLETED } as any, "user-1", ); - expect(mockAiInterviewRepo.save).toHaveBeenCalled(); - expect(res).toEqual(true); - }); - - it("throws InternalServerErrorException when save fails on edit", async () => { - const existing = { - ...mockInterview, - status: AiInterviewStatus.ASKED, - } as any; - jest.spyOn(service, "getInterviewById").mockResolvedValueOnce(existing); - mockAiInterviewRepo.save.mockRejectedValueOnce(new Error("savefail")); - - await expect( - service.editAiInterview( - "i1", - { status: AiInterviewStatus.COMPLETED } as any, - "user-1", - ), - ).rejects.toThrow(InternalServerErrorException); + expect(mockCapacity.releaseSlot).toHaveBeenCalledWith("i1", "user-1"); + expect(mockPromotion.promoteNextFromQueue).toHaveBeenCalled(); }); }); @@ -213,82 +214,11 @@ describe("AiService", () => { const res = await service.getUserInterviews({} as any, "user-1"); - expect(mockAiInterviewRepo.find).toHaveBeenCalled(); expect(res).toEqual({ data: [mockInterview], meta: { total: 1 } }); }); - - it("throws InternalServerErrorException when find throws", async () => { - mockAiInterviewRepo.find.mockRejectedValueOnce(new Error("finderr")); - await expect( - service.getUserInterviews({} as any, "user-1"), - ).rejects.toThrow(InternalServerErrorException); - }); - - it("returns paginated interviews when page/limit provided", async () => { - mockAiInterviewRepo.findAndCount.mockResolvedValueOnce([ - [mockInterview], - 10, - ]); - - const res = await service.getUserInterviews( - { page: 2, limit: 5 } as any, - "user-1", - ); - - expect(mockAiInterviewRepo.findAndCount).toHaveBeenCalled(); - expect(res).toEqual({ - data: [mockInterview], - meta: { total: 10, page: 2, limit: 5 }, - }); - }); - - it("defaults limit to 20 when only page is provided", async () => { - mockAiInterviewRepo.findAndCount.mockResolvedValueOnce([[], 0]); - - await service.getUserInterviews({ page: 3 } as any, "user-1"); - - expect(mockAiInterviewRepo.findAndCount).toHaveBeenCalledWith( - expect.objectContaining({ - take: 20, - skip: 40, - }), - ); - }); - - it("defaults page to 1 when only limit is provided", async () => { - mockAiInterviewRepo.findAndCount.mockResolvedValueOnce([[], 0]); - - await service.getUserInterviews({ limit: 15 } as any, "user-1"); - - expect(mockAiInterviewRepo.findAndCount).toHaveBeenCalledWith( - expect.objectContaining({ - take: 15, - skip: 0, - }), - ); - }); - - it("throws InternalServerErrorException when findAndCount throws", async () => { - mockAiInterviewRepo.findAndCount.mockRejectedValueOnce( - new Error("counterr"), - ); - await expect( - service.getUserInterviews({ page: 1, limit: 10 } as any, "user-1"), - ).rejects.toThrow(InternalServerErrorException); - }); }); describe("addTranscripts", () => { - it("throws NotFoundException when interview not found", async () => { - jest - .spyOn(service, "getInterviewById") - .mockRejectedValueOnce(new NotFoundException()); - - await expect( - service.addTranscripts("i1", { transcripts: [] } as any, "user-1"), - ).rejects.toThrow(NotFoundException); - }); - it("saves transcripts and returns inserted count", async () => { jest .spyOn(service, "getInterviewById") @@ -301,64 +231,16 @@ describe("AiService", () => { "user-1", ); - expect(mockAiTranscriptRepo.save).toHaveBeenCalled(); expect(res).toEqual({ inserted: 1, data: [{ id: 1 }] }); }); - - it("throws InternalServerErrorException when transcript save fails", async () => { - jest - .spyOn(service, "getInterviewById") - .mockResolvedValueOnce({ interview_id: "i1" } as any); - mockAiTranscriptRepo.save.mockRejectedValueOnce(new Error("saveerr")); - await expect( - service.addTranscripts( - "i1", - { transcripts: [{ content: "hi", who_stated: "user" }] } as any, - "user-1", - ), - ).rejects.toThrow(InternalServerErrorException); - }); }); describe("getInterviewById", () => { - it("throws NotFoundException when no interviewId provided", async () => { - await expect(service.getInterviewById("", "user-1")).rejects.toThrow( - NotFoundException, - ); - }); - it("throws NotFoundException when interview not found", async () => { mockAiInterviewRepo.findOne.mockResolvedValueOnce(null); await expect(service.getInterviewById("i100", "user-1")).rejects.toThrow( NotFoundException, ); - expect(mockAiInterviewRepo.findOne).toHaveBeenCalled(); - }); - - it("returns interview with transcripts when requested", async () => { - mockAiInterviewRepo.findOne.mockResolvedValueOnce({ - interview_id: "i1", - user_id: "user-1", - }); - mockAiTranscriptRepo.find.mockResolvedValueOnce([ - { id: 1, content: "t1" }, - ]); - - const res = await service.getInterviewById("i1", "user-1", true); - expect(mockAiInterviewRepo.findOne).toHaveBeenCalled(); - expect(mockAiTranscriptRepo.find).toHaveBeenCalled(); - expect(res).toEqual({ - interview_id: "i1", - user_id: "user-1", - transcripts: [{ id: 1, content: "t1" }], - }); - }); - - it("throws InternalServerErrorException when repo errors", async () => { - mockAiInterviewRepo.findOne.mockRejectedValueOnce(new Error("boom")); - await expect(service.getInterviewById("i1", "user-1")).rejects.toThrow( - InternalServerErrorException, - ); }); }); }); diff --git a/server/src/modules/ai/ai.service.ts b/server/src/modules/ai/ai.service.ts index 6ae99f5d..7eef15da 100644 --- a/server/src/modules/ai/ai.service.ts +++ b/server/src/modules/ai/ai.service.ts @@ -4,27 +4,34 @@ import { InternalServerErrorException, Logger, NotFoundException, + ServiceUnavailableException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { FindOptionsOrder, Repository } from "typeorm"; - -import { HttpService } from "@nestjs/axios"; -import { AxiosResponse } from "axios"; +import { FindOptionsOrder, In, Repository } from "typeorm"; import { ai_interview } from "@entities/aiInterview.entity"; import { ai_transcript } from "@entities/aiTranscript.entity"; -import { AiInterviewStatus } from "@common/enums/AiInterviewStatus"; +import { + ACTIVE_SIMULATION_STATUSES, + AiInterviewStatus, +} from "@common/enums/AiInterviewStatus"; import { CreateAiInterviewDto } from "./dto/createAiInterview.dto"; import { PutAiInterviewDto } from "./dto/putAiInterview.dto"; import { GetInterviewsQueryDto } from "./dto/getInterviewsQuery.dto"; import { CreateAiTranscriptsDto } from "./dto/createAiTranscripts.dto"; +import { CreateAiInterviewResponseDto } from "./dto/createAiInterviewResponse.dto"; +import { InterviewSessionDto } from "./dto/interviewSession.dto"; + +import { SimulationCapacityService } from "../simulation/simulation-capacity.service"; +import { SimulationContextService } from "../simulation/simulation-context.service"; +import { SimulationPromotionService } from "../simulation/simulation-promotion.service"; +import { loadSimulationConfig } from "../simulation/simulation.config"; @Injectable() export class AiService { - private AI_SERVER_URL = process.env.AI_SERVER_URL ?? ""; private readonly logger: Logger; constructor( @@ -32,97 +39,195 @@ export class AiService { private aiInterviewRepository: Repository, @InjectRepository(ai_transcript) private aiTranscriptRepository: Repository, - private readonly httpService: HttpService, + private readonly capacity: SimulationCapacityService, + private readonly context: SimulationContextService, + private readonly promotion: SimulationPromotionService, ) { this.logger = new Logger(AiService.name); } - async createInterview(dto: CreateAiInterviewDto, userId: string) { - let AIresponse: AxiosResponse<{ - key: string; - type: string; - format: string; - data: string; - }>; - let alreadyExists; + async getCapacity() { + return this.capacity.getSnapshot(); + } - // Check if an interview is already asked and not completed + async createInterview( + dto: CreateAiInterviewDto, + userId: string, + ): Promise { try { - alreadyExists = await this.aiInterviewRepository.findOne({ - where: { user_id: userId, status: AiInterviewStatus.ASKED }, + const redisActiveId = + await this.capacity.getUserActiveInterviewId(userId); + if (redisActiveId) { + throw new ConflictException( + "A simulation is already active or queued for this user.", + ); + } + + const alreadyExists = await this.aiInterviewRepository.findOne({ + where: { + user_id: userId, + status: In(ACTIVE_SIMULATION_STATUSES), + }, + }); + + if (alreadyExists) { + this.logger.warn( + `Active simulation already exists for user ${userId}`, + ); + throw new ConflictException( + "A simulation is already active or queued for this user.", + ); + } + + const newInterview = this.aiInterviewRepository.create({ + user_id: userId, + type: dto.type, + language: dto.language, + status: AiInterviewStatus.QUEUED, }); - } catch (error) { - this.logger.error( - `Failed to check existing interview for user ${userId}: ${error.message}`, - error.stack, - ); - throw new InternalServerErrorException( - "Internal server error while checking existing interview.", - ); - } - if (alreadyExists) { - this.logger.warn( - `AI interview already asked for user ${userId}, creating duplicate request blocked.`, + await this.aiInterviewRepository.save(newInterview); + + const acquired = await this.capacity.tryAcquireSlot( + newInterview.interview_id, + userId, ); - throw new ConflictException("AI interview already asked."); - } - // Call AI server to init interview - try { - AIresponse = await this.httpService.axiosRef.post( - `${this.AI_SERVER_URL}/process/initialization`, - { - key: "c7yPY8u644OE", // Temporary key for testing purposes - type: "initialization", - format: "text", - data: "", - }, + if (acquired.acquired) { + const { entrypoint } = await this.promotion.prepareReadySession( + newInterview, + dto, + ); + + return { + interviewID: newInterview.interview_id, + status: "ready", + entrypoint, + queuePosition: 0, + }; + } + + const enqueued = await this.capacity.enqueue(newInterview.interview_id); + if (!enqueued.ok) { + await this.aiInterviewRepository.delete({ + interview_id: newInterview.interview_id, + }); + throw new ServiceUnavailableException( + "Simulation queue is full. Please try again later.", + ); + } + + const queuePosition = await this.capacity.getQueuePosition( + newInterview.interview_id, ); + const { estimatedTurnSec } = loadSimulationConfig(); + + return { + interviewID: newInterview.interview_id, + status: "queued", + entrypoint: null, + queuePosition, + estimatedWaitSec: queuePosition * estimatedTurnSec, + }; } catch (error) { + if ( + error instanceof ConflictException || + error instanceof ServiceUnavailableException + ) { + throw error; + } + this.logger.error( - `Failed to call AI server for user ${userId}: ${error.message}` + - (error.response?.data - ? ` | Response data: ${JSON.stringify(error.response.data)}` - : ""), - error.stack, + `Failed to create AI interview for user ${userId}: ${(error as Error).message}`, + (error as Error).stack, ); throw new InternalServerErrorException( - "Internal server error while contacting AI server.", + "Internal server error while creating AI interview.", ); } + } - if (AIresponse.status !== 200) { - this.logger.error( - `AI server responded with status ${AIresponse.status} for user ${userId}: ${JSON.stringify(AIresponse.data)}`, - ); - throw new InternalServerErrorException("AI server error."); + async getInterviewSession( + interviewId: string, + userId: string, + ): Promise { + const interview = await this.getInterviewById(interviewId, userId); + const { estimatedTurnSec } = loadSimulationConfig(); + + if ( + interview.status === AiInterviewStatus.COMPLETED || + interview.status === AiInterviewStatus.CANCELLED || + interview.status === AiInterviewStatus.EXPIRED + ) { + return { + interviewID: interviewId, + dbStatus: interview.status, + sessionStatus: "ended", + queuePosition: 0, + entrypoint: null, + }; } - // Create and save new interview record - const newInterview = this.aiInterviewRepository.create({ - user_id: userId, - ...dto, - }); + if (interview.status === AiInterviewStatus.IN_PROGRESS) { + const ready = await this.promotion.getReadyPayload(interviewId); + return { + interviewID: interviewId, + dbStatus: interview.status, + sessionStatus: "active", + queuePosition: 0, + entrypoint: ready?.entrypoint ?? null, + }; + } - try { - await this.aiInterviewRepository.save(newInterview); - } catch (error) { - this.logger.error( - `Failed to create AI interview for user ${userId}: ${error.message}`, - error.stack, - ); - throw new InternalServerErrorException( - "Internal server error while creating AI interview.", - ); + if (interview.status === AiInterviewStatus.QUEUED) { + const queuePosition = await this.capacity.getQueuePosition(interviewId); + return { + interviewID: interviewId, + dbStatus: interview.status, + sessionStatus: "queued", + queuePosition, + entrypoint: null, + estimatedWaitSec: queuePosition * estimatedTurnSec, + }; } + const ready = await this.promotion.getReadyPayload(interviewId); return { - interviewID: newInterview.interview_id, - entrypoint: AIresponse.data.data, + interviewID: interviewId, + dbStatus: interview.status, + sessionStatus: "ready", + queuePosition: 0, + entrypoint: ready?.entrypoint ?? null, }; } + async cancelInterview(interviewId: string, userId: string): Promise { + const interview = await this.getInterviewById(interviewId, userId); + + if ( + interview.status === AiInterviewStatus.COMPLETED || + interview.status === AiInterviewStatus.CANCELLED + ) { + return true; + } + + if (interview.status === AiInterviewStatus.QUEUED) { + await this.capacity.removeFromQueue(interviewId); + } else { + await this.capacity.releaseSlot(interviewId, userId); + await this.promotion.promoteNextFromQueue(); + } + + await this.context.deleteContext(interviewId); + await this.promotion.clearReady(interviewId); + + interview.status = AiInterviewStatus.CANCELLED; + interview.ended_at = new Date(); + await this.aiInterviewRepository.save(interview); + + return true; + } + async editAiInterview( interviewId: string, dto: PutAiInterviewDto, @@ -130,15 +235,14 @@ export class AiService { ) { let alreadyExists = await this.getInterviewById(interviewId, userId); - // if interview is completed, set ended_at date if ( dto.status && alreadyExists.status !== AiInterviewStatus.COMPLETED && dto.status === AiInterviewStatus.COMPLETED - ) + ) { alreadyExists.ended_at = new Date(); + } - // Update the interview record with the new data Object.assign(alreadyExists, { ...dto, }); @@ -147,23 +251,40 @@ export class AiService { await this.aiInterviewRepository.save(alreadyExists); } catch (error) { this.logger.error( - `Failed to edit AI interview for user ${userId} and id ${interviewId}: ${error.message}`, - error.stack, + `Failed to edit AI interview for user ${userId} and id ${interviewId}: ${(error as Error).message}`, + (error as Error).stack, ); throw new InternalServerErrorException( "Internal server error while editing AI interview.", ); } + if ( + dto.status === AiInterviewStatus.COMPLETED || + dto.status === AiInterviewStatus.CANCELLED + ) { + await this.finalizeSimulation(interviewId, userId); + } else if (dto.status === AiInterviewStatus.IN_PROGRESS) { + await this.capacity.touchHeartbeat(interviewId, userId); + } + return true; } + private async finalizeSimulation( + interviewId: string, + userId: string, + ): Promise { + await this.capacity.releaseSlot(interviewId, userId); + await this.context.deleteContext(interviewId); + await this.promotion.clearReady(interviewId); + await this.promotion.promoteNextFromQueue(); + } + async getUserInterviews(query: GetInterviewsQueryDto, userId: string) { - // build order object to sort depending on query const order: FindOptionsOrder = {}; order[query.sort ?? "created_at"] = query.order ?? "DESC"; - // no pagination parameters provided, return all items if (!query.page && !query.limit) { try { const items = await this.aiInterviewRepository.find({ @@ -177,8 +298,8 @@ export class AiService { }; } catch (error) { this.logger.error( - `Failed to retrieve AI interviews for user ${userId}: ${error.message}`, - error.stack, + `Failed to retrieve AI interviews for user ${userId}: ${(error as Error).message}`, + (error as Error).stack, ); throw new InternalServerErrorException( "Internal server error while retrieving AI interviews.", @@ -186,7 +307,6 @@ export class AiService { } } - // Set default pagination values because otherwise typescript complains const page = query.page || 1; const limit = query.limit || 20; const skip = (page - 1) * limit; @@ -205,8 +325,8 @@ export class AiService { }; } catch (error) { this.logger.error( - `Failed to retrieve AI interviews for user ${userId}: ${error.message}`, - error.stack, + `Failed to retrieve AI interviews for user ${userId}: ${(error as Error).message}`, + (error as Error).stack, ); throw new InternalServerErrorException( "Internal server error while retrieving AI interviews.", @@ -219,7 +339,6 @@ export class AiService { dto: CreateAiTranscriptsDto, userId: string, ) { - // Check that interview exists and belongs to user, if not throw error await this.getInterviewById(interviewId, userId); const records = dto.transcripts.map((t) => @@ -235,8 +354,8 @@ export class AiService { return { inserted: saved.length, data: saved }; } catch (error) { this.logger.error( - `Failed to save transcripts for interview ${interviewId} (user ${userId}): ${error.message}`, - error.stack, + `Failed to save transcripts for interview ${interviewId} (user ${userId}): ${(error as Error).message}`, + (error as Error).stack, ); throw new InternalServerErrorException( "Internal server error while saving transcripts.", @@ -244,15 +363,6 @@ export class AiService { } } - /*----------- UTILITY FUNCTIONS -----------*/ - - /** - * UTILS function - * Check if an interview exists for a given user and interview ID (if no ID provided, returns null) - * @param interviewId id of the interview to check - * @param userId id of the user owning the interview - * @returns The interview entity if found, null otherwise - */ async getInterviewById( interviewId: string, userId: string, @@ -281,8 +391,8 @@ export class AiService { if (error instanceof NotFoundException) throw error; this.logger.error( - `Failed to check existing interview for user ${userId} and id ${interviewId}: ${error.message}`, - error.stack, + `Failed to check existing interview for user ${userId} and id ${interviewId}: ${(error as Error).message}`, + (error as Error).stack, ); throw new InternalServerErrorException( "Internal server error while getting the interview.", diff --git a/server/src/modules/ai/dto/createAiInterview.dto.ts b/server/src/modules/ai/dto/createAiInterview.dto.ts index 33c59ef3..2126a6a2 100644 --- a/server/src/modules/ai/dto/createAiInterview.dto.ts +++ b/server/src/modules/ai/dto/createAiInterview.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional, ApiSchema } from "@nestjs/swagger"; -import { IsEnum, IsOptional, IsString, Length } from "class-validator"; +import { IsEnum, IsOptional, IsString, Length, MaxLength } from "class-validator"; import { AiInterviewStatus } from "@common/enums/AiInterviewStatus"; @@ -29,6 +29,16 @@ export class CreateAiInterviewDto { }) language: string; + @ApiPropertyOptional({ + description: + "Optional free-text context (CV summary, job offer, preparation notes) injected into the STS system prompt.", + example: "Candidate: 5 ans Java. Poste: dev backend fintech Paris.", + }) + @IsOptional() + @IsString() + @MaxLength(8000) + jobContext?: string; + @ApiPropertyOptional({ description: "The current status of the AI interview request.", example: AiInterviewStatus.ASKED, diff --git a/server/src/modules/ai/dto/createAiInterviewResponse.dto.ts b/server/src/modules/ai/dto/createAiInterviewResponse.dto.ts new file mode 100644 index 00000000..0bf4f570 --- /dev/null +++ b/server/src/modules/ai/dto/createAiInterviewResponse.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; + +export type SimulationSessionPhase = "ready" | "queued" | "active"; + +export class CreateAiInterviewResponseDto { + @ApiProperty() + interviewID: string; + + @ApiProperty({ enum: ["ready", "queued", "active"] }) + status: SimulationSessionPhase; + + @ApiPropertyOptional({ + description: "WebSocket URL when status is ready (includes short-lived token).", + }) + entrypoint?: string | null; + + @ApiProperty({ description: "0 when ready; 1-based position when queued." }) + queuePosition: number; + + @ApiPropertyOptional({ + description: "Rough wait estimate in seconds when queued.", + }) + estimatedWaitSec?: number; +} diff --git a/server/src/modules/ai/dto/interviewSession.dto.ts b/server/src/modules/ai/dto/interviewSession.dto.ts new file mode 100644 index 00000000..9b37bec8 --- /dev/null +++ b/server/src/modules/ai/dto/interviewSession.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; + +import { AiInterviewStatus } from "@common/enums/AiInterviewStatus"; + +export class InterviewSessionDto { + @ApiProperty() + interviewID: string; + + @ApiProperty({ enum: AiInterviewStatus }) + dbStatus: AiInterviewStatus; + + @ApiProperty({ enum: ["ready", "queued", "active", "ended"] }) + sessionStatus: "ready" | "queued" | "active" | "ended"; + + @ApiProperty() + queuePosition: number; + + @ApiPropertyOptional() + entrypoint?: string | null; + + @ApiPropertyOptional() + estimatedWaitSec?: number; +} diff --git a/server/src/modules/simulation/dto/appendSimulationHistory.dto.ts b/server/src/modules/simulation/dto/appendSimulationHistory.dto.ts new file mode 100644 index 00000000..9155f6fb --- /dev/null +++ b/server/src/modules/simulation/dto/appendSimulationHistory.dto.ts @@ -0,0 +1,11 @@ +import { IsString, MinLength } from "class-validator"; + +export class AppendSimulationHistoryDto { + @IsString() + @MinLength(1) + userText: string; + + @IsString() + @MinLength(1) + assistantText: string; +} diff --git a/server/src/modules/simulation/guards/internal-api-key.guard.ts b/server/src/modules/simulation/guards/internal-api-key.guard.ts new file mode 100644 index 00000000..4346f9dd --- /dev/null +++ b/server/src/modules/simulation/guards/internal-api-key.guard.ts @@ -0,0 +1,30 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; +import { Request } from "express"; + +/** + * Protects STS ↔ NestJS internal routes (no user JWT). + * Set SIM_INTERNAL_API_KEY on NestJS and the STS container. + */ +@Injectable() +export class InternalApiKeyGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const expected = process.env.SIM_INTERNAL_API_KEY?.trim(); + if (!expected) { + throw new UnauthorizedException("Internal API is not configured."); + } + + const req = context.switchToHttp().getRequest(); + const provided = req.header("x-internal-api-key")?.trim(); + + if (!provided || provided !== expected) { + throw new UnauthorizedException("Invalid internal API key."); + } + + return true; + } +} diff --git a/server/src/modules/simulation/simulation-capacity.service.spec.ts b/server/src/modules/simulation/simulation-capacity.service.spec.ts new file mode 100644 index 00000000..61bcb716 --- /dev/null +++ b/server/src/modules/simulation/simulation-capacity.service.spec.ts @@ -0,0 +1,64 @@ +import { Test, TestingModule } from "@nestjs/testing"; + +import { REDIS_CLIENT } from "@common/redis/redis.constants"; +import { SimulationCapacityService } from "./simulation-capacity.service"; + +describe("SimulationCapacityService", () => { + let service: SimulationCapacityService; + let redis: { + get: jest.Mock; + set: jest.Mock; + eval: jest.Mock; + llen: jest.Mock; + rpush: jest.Mock; + lpos: jest.Mock; + lrem: jest.Mock; + lpop: jest.Mock; + del: jest.Mock; + hgetall: jest.Mock; + exists: jest.Mock; + hset: jest.Mock; + expire: jest.Mock; + smembers: jest.Mock; + }; + + beforeEach(async () => { + redis = { + get: jest.fn().mockResolvedValue("0"), + set: jest.fn().mockResolvedValue("OK"), + eval: jest.fn().mockResolvedValue(1), + llen: jest.fn().mockResolvedValue(0), + rpush: jest.fn(), + lpos: jest.fn().mockResolvedValue(null), + lrem: jest.fn(), + lpop: jest.fn().mockResolvedValue(null), + del: jest.fn(), + hgetall: jest.fn().mockResolvedValue({}), + exists: jest.fn().mockResolvedValue(1), + hset: jest.fn(), + expire: jest.fn(), + smembers: jest.fn().mockResolvedValue([]), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SimulationCapacityService, + { provide: REDIS_CLIENT, useValue: redis }, + ], + }).compile(); + + service = module.get(SimulationCapacityService); + }); + + it("returns capacity snapshot", async () => { + const snap = await service.getSnapshot(); + expect(snap.max).toBe(2); + expect(snap.active).toBe(0); + }); + + it("acquires a slot via lua", async () => { + const result = await service.tryAcquireSlot("int-1", "user-1"); + expect(result.acquired).toBe(true); + expect(redis.eval).toHaveBeenCalled(); + }); +}); diff --git a/server/src/modules/simulation/simulation-capacity.service.ts b/server/src/modules/simulation/simulation-capacity.service.ts new file mode 100644 index 00000000..91933df0 --- /dev/null +++ b/server/src/modules/simulation/simulation-capacity.service.ts @@ -0,0 +1,183 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Inject } from "@nestjs/common"; +import Redis from "ioredis"; + +import { REDIS_CLIENT } from "@common/redis/redis.constants"; + +import { loadSimulationConfig } from "./simulation.config"; +import { SimRedisKeys } from "./simulation.redis-keys"; + +export type CapacitySnapshot = { + active: number; + max: number; + queueLength: number; + accepting: boolean; +}; + +export type AcquireSlotResult = + | { acquired: true } + | { acquired: false; reason: "capacity" | "queue_full" }; + +const ACQUIRE_SLOT_LUA = ` +local max = tonumber(ARGV[1]) +local count = tonumber(redis.call('GET', KEYS[1]) or '0') +if count >= max then + return 0 +end +redis.call('INCR', KEYS[1]) +redis.call('HSET', KEYS[2], 'userId', ARGV[2], 'startedAt', ARGV[3], 'lastHeartbeat', ARGV[3]) +redis.call('SADD', KEYS[3], ARGV[4]) +redis.call('SET', KEYS[4], ARGV[4], 'EX', tonumber(ARGV[5])) +return 1 +`; + +const RELEASE_SLOT_LUA = ` +local count = tonumber(redis.call('GET', KEYS[1]) or '0') +if count > 0 then + redis.call('DECR', KEYS[1]) +end +redis.call('DEL', KEYS[2]) +redis.call('SREM', KEYS[3], ARGV[1]) +redis.call('DEL', KEYS[4]) +redis.call('DEL', KEYS[5]) +return 1 +`; + +@Injectable() +export class SimulationCapacityService { + private readonly logger = new Logger(SimulationCapacityService.name); + + constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {} + + async getSnapshot(): Promise { + const { maxConcurrent } = loadSimulationConfig(); + const [activeRaw, queueLength] = await Promise.all([ + this.redis.get(SimRedisKeys.activeCount), + this.redis.llen(SimRedisKeys.queue), + ]); + const active = parseInt(activeRaw ?? "0", 10); + + return { + active, + max: maxConcurrent, + queueLength, + accepting: active < maxConcurrent || queueLength > 0, + }; + } + + async tryAcquireSlot( + interviewId: string, + userId: string, + ): Promise { + const { maxConcurrent, slotTtlSec } = loadSimulationConfig(); + const now = Math.floor(Date.now() / 1000).toString(); + + const acquired = (await this.redis.eval( + ACQUIRE_SLOT_LUA, + 4, + SimRedisKeys.activeCount, + SimRedisKeys.active(interviewId), + SimRedisKeys.activeSet, + SimRedisKeys.userActive(userId), + maxConcurrent.toString(), + userId, + now, + interviewId, + slotTtlSec.toString(), + )) as number; + + if (acquired === 1) { + return { acquired: true }; + } + + return { acquired: false, reason: "capacity" }; + } + + async enqueue(interviewId: string): Promise<{ ok: true } | { ok: false }> { + const { queueMaxSize, queueEntryTtlSec } = loadSimulationConfig(); + const queueLength = await this.redis.llen(SimRedisKeys.queue); + + if (queueLength >= queueMaxSize) { + return { ok: false }; + } + + await this.redis.rpush(SimRedisKeys.queue, interviewId); + await this.redis.set( + SimRedisKeys.queuedAt(interviewId), + Date.now().toString(), + "EX", + queueEntryTtlSec, + ); + + return { ok: true }; + } + + async getQueuePosition(interviewId: string): Promise { + const pos = await this.redis.lpos(SimRedisKeys.queue, interviewId); + if (pos === null) return 0; + return pos + 1; + } + + async removeFromQueue(interviewId: string): Promise { + await this.redis.lrem(SimRedisKeys.queue, 0, interviewId); + await this.redis.del(SimRedisKeys.queuedAt(interviewId)); + } + + async releaseSlot(interviewId: string, userId: string): Promise { + await this.redis.eval( + RELEASE_SLOT_LUA, + 5, + SimRedisKeys.activeCount, + SimRedisKeys.active(interviewId), + SimRedisKeys.activeSet, + SimRedisKeys.userActive(userId), + SimRedisKeys.queuedAt(interviewId), + interviewId, + userId, + ); + } + + async touchHeartbeat(interviewId: string, userId: string): Promise { + const { slotTtlSec } = loadSimulationConfig(); + const now = Math.floor(Date.now() / 1000).toString(); + const key = SimRedisKeys.active(interviewId); + + const exists = await this.redis.exists(key); + if (!exists) return; + + await this.redis.hset(key, "lastHeartbeat", now); + await this.redis.expire(key, slotTtlSec); + await this.redis.expire(SimRedisKeys.userActive(userId), slotTtlSec); + } + + async dequeueNext(): Promise { + return this.redis.lpop(SimRedisKeys.queue); + } + + async getUserActiveInterviewId(userId: string): Promise { + return this.redis.get(SimRedisKeys.userActive(userId)); + } + + async listActiveInterviewIds(): Promise { + return this.redis.smembers(SimRedisKeys.activeSet); + } + + async getActiveMeta( + interviewId: string, + ): Promise<{ userId: string; lastHeartbeat: number } | null> { + const data = await this.redis.hgetall(SimRedisKeys.active(interviewId)); + if (!data.userId) return null; + + return { + userId: data.userId, + lastHeartbeat: parseInt(data.lastHeartbeat ?? data.startedAt ?? "0", 10), + }; + } + + /** Reconcile counter after Redis flush or crash (dev/ops). */ + async reconcileActiveCount(): Promise { + const ids = await this.listActiveInterviewIds(); + await this.redis.set(SimRedisKeys.activeCount, ids.length.toString()); + this.logger.warn(`Reconciled active_count to ${ids.length}`); + } +} diff --git a/server/src/modules/simulation/simulation-context.service.ts b/server/src/modules/simulation/simulation-context.service.ts new file mode 100644 index 00000000..d3888638 --- /dev/null +++ b/server/src/modules/simulation/simulation-context.service.ts @@ -0,0 +1,107 @@ +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { Inject } from "@nestjs/common"; +import Redis from "ioredis"; + +import { REDIS_CLIENT } from "@common/redis/redis.constants"; + +import { CreateAiInterviewDto } from "../ai/dto/createAiInterview.dto"; +import { loadSimulationConfig } from "./simulation.config"; +import { SimRedisKeys } from "./simulation.redis-keys"; + +export type SimulationChatTurn = { + role: "user" | "assistant"; + content: string; +}; + +export type SimulationSessionContext = { + interviewId: string; + userId: string; + systemPrompt: string; + history: SimulationChatTurn[]; +}; + +const BASE_RECRUITER_PERSONA = `Tu es Sophie Martin, recruteuse senior IT chez une ESN francaise. Tu es chaleureuse, professionnelle, patiente et humaine. Tu parles de facon naturelle comme dans une vraie conversation. Tu dois repondre uniquement a la derniere prise de parole du candidat, en une seule reponse courte et naturelle. N'ecris jamais un dialogue multi-tours, n'imite jamais des balises comme system: ou user:, et ne recopie jamais l'historique de conversation.`; + +@Injectable() +export class SimulationContextService { + private readonly logger = new Logger(SimulationContextService.name); + + constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {} + + buildSystemPrompt(dto: CreateAiInterviewDto): string { + const language = dto.language?.trim() || "French"; + const interviewType = dto.type?.trim() || "general"; + const jobContext = dto.jobContext?.trim(); + + let prompt = `${BASE_RECRUITER_PERSONA}\n\nLangue de l'entretien: ${language}.\nType de simulation: ${interviewType}.`; + + if (jobContext) { + prompt += `\n\nContexte candidat et poste (a utiliser pour personnaliser tes questions, sans tout reciter):\n${jobContext}`; + } else { + prompt += + "\n\n(Pas de CV/offre detailles pour cette session — pose des questions generiques adaptees au type de simulation.)"; + } + + return prompt; + } + + async storeContext( + interviewId: string, + userId: string, + systemPrompt: string, + ): Promise { + const { contextTtlSec } = loadSimulationConfig(); + const payload: SimulationSessionContext = { + interviewId, + userId, + systemPrompt, + history: [], + }; + + await this.redis.set( + SimRedisKeys.context(interviewId), + JSON.stringify(payload), + "EX", + contextTtlSec, + ); + } + + async getContextForSts( + interviewId: string, + ): Promise { + const raw = await this.redis.get(SimRedisKeys.context(interviewId)); + if (!raw) { + this.logger.warn(`No simulation context for interview ${interviewId}`); + throw new NotFoundException("Simulation session context not found."); + } + + return JSON.parse(raw) as SimulationSessionContext; + } + + async appendTurn( + interviewId: string, + userText: string, + assistantText: string, + ): Promise { + const ctx = await this.getContextForSts(interviewId); + const { contextTtlSec, historyMaxTurns } = loadSimulationConfig(); + + ctx.history.push({ role: "user", content: userText }); + ctx.history.push({ role: "assistant", content: assistantText }); + + if (ctx.history.length > historyMaxTurns * 2) { + ctx.history = ctx.history.slice(-historyMaxTurns * 2); + } + + await this.redis.set( + SimRedisKeys.context(interviewId), + JSON.stringify(ctx), + "EX", + contextTtlSec, + ); + } + + async deleteContext(interviewId: string): Promise { + await this.redis.del(SimRedisKeys.context(interviewId)); + } +} diff --git a/server/src/modules/simulation/simulation-internal.controller.ts b/server/src/modules/simulation/simulation-internal.controller.ts new file mode 100644 index 00000000..854e0c81 --- /dev/null +++ b/server/src/modules/simulation/simulation-internal.controller.ts @@ -0,0 +1,33 @@ +import { Body, Controller, Get, Param, Post, UseGuards } from "@nestjs/common"; +import { ApiExcludeController } from "@nestjs/swagger"; +import { UsePipes } from "@nestjs/common"; + +import { PostValidationPipe } from "@common/pipes/PostValidationPipe"; +import { InternalApiKeyGuard } from "./guards/internal-api-key.guard"; +import { SimulationContextService } from "./simulation-context.service"; +import { AppendSimulationHistoryDto } from "./dto/appendSimulationHistory.dto"; + +@ApiExcludeController() +@Controller("ai/internal") +@UseGuards(InternalApiKeyGuard) +export class SimulationInternalController { + constructor(private readonly contextService: SimulationContextService) {} + + @Get("sessions/:interviewId/context") + getSessionContext(@Param("interviewId") interviewId: string) { + return this.contextService.getContextForSts(interviewId); + } + + @UsePipes(new PostValidationPipe()) + @Post("sessions/:interviewId/history") + appendHistory( + @Param("interviewId") interviewId: string, + @Body() dto: AppendSimulationHistoryDto, + ) { + return this.contextService.appendTurn( + interviewId, + dto.userText, + dto.assistantText, + ); + } +} diff --git a/server/src/modules/simulation/simulation-promotion.service.ts b/server/src/modules/simulation/simulation-promotion.service.ts new file mode 100644 index 00000000..8c6effb2 --- /dev/null +++ b/server/src/modules/simulation/simulation-promotion.service.ts @@ -0,0 +1,164 @@ +import { + Injectable, + InternalServerErrorException, + Logger, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { HttpService } from "@nestjs/axios"; +import { Repository } from "typeorm"; +import Redis from "ioredis"; +import { Inject } from "@nestjs/common"; + +import { REDIS_CLIENT } from "@common/redis/redis.constants"; +import { ai_interview } from "@entities/aiInterview.entity"; +import { AiInterviewStatus } from "@common/enums/AiInterviewStatus"; +import { CreateAiInterviewDto } from "../ai/dto/createAiInterview.dto"; + +import { SimulationCapacityService } from "./simulation-capacity.service"; +import { SimulationContextService } from "./simulation-context.service"; +import { SimulationWsTokenService } from "./simulation-ws-token.service"; +import { SimRedisKeys } from "./simulation.redis-keys"; +import { loadSimulationConfig } from "./simulation.config"; + +export type ReadySessionPayload = { + entrypoint: string; +}; + +@Injectable() +export class SimulationPromotionService { + private readonly logger = new Logger(SimulationPromotionService.name); + private readonly aiServerUrl = process.env.AI_SERVER_URL ?? ""; + + constructor( + @Inject(REDIS_CLIENT) private readonly redis: Redis, + private readonly httpService: HttpService, + private readonly capacity: SimulationCapacityService, + private readonly context: SimulationContextService, + private readonly wsToken: SimulationWsTokenService, + @InjectRepository(ai_interview) + private readonly interviewRepo: Repository, + ) {} + + private readyKey(interviewId: string): string { + return `${SimRedisKeys.context(interviewId)}:ready`; + } + + async prepareReadySession( + interview: ai_interview, + dto: CreateAiInterviewDto, + ): Promise { + const userId = interview.user_id as string; + const systemPrompt = this.context.buildSystemPrompt(dto); + await this.context.storeContext( + interview.interview_id, + userId, + systemPrompt, + ); + + await this.callAiInitialization(); + + const entrypoint = this.wsToken.buildEntrypoint( + interview.interview_id, + userId, + ); + + const { wsTokenTtlSec } = loadSimulationConfig(); + await this.redis.set( + this.readyKey(interview.interview_id), + JSON.stringify({ entrypoint }), + "EX", + wsTokenTtlSec, + ); + + await this.interviewRepo.update( + { interview_id: interview.interview_id }, + { status: AiInterviewStatus.ASKED }, + ); + + return { entrypoint }; + } + + async getReadyPayload( + interviewId: string, + ): Promise { + const raw = await this.redis.get(this.readyKey(interviewId)); + if (!raw) return null; + return JSON.parse(raw) as ReadySessionPayload; + } + + async promoteNextFromQueue(): Promise { + while (true) { + const nextId = await this.capacity.dequeueNext(); + if (!nextId) return null; + + const interview = await this.interviewRepo.findOne({ + where: { interview_id: nextId, status: AiInterviewStatus.QUEUED }, + }); + + if (!interview) continue; + + const acquired = await this.capacity.tryAcquireSlot( + nextId, + interview.user_id as string, + ); + + if (!acquired.acquired) { + await this.capacity.enqueue(nextId); + return null; + } + + const dto: CreateAiInterviewDto = { + type: interview.type, + language: interview.language, + status: AiInterviewStatus.ASKED, + }; + + try { + await this.prepareReadySession(interview, dto); + this.logger.log(`Promoted queued interview ${nextId}`); + return nextId; + } catch (err) { + this.logger.error( + `Failed to promote interview ${nextId}: ${(err as Error).message}`, + ); + await this.capacity.releaseSlot(nextId, interview.user_id as string); + await this.interviewRepo.update( + { interview_id: nextId }, + { status: AiInterviewStatus.EXPIRED }, + ); + continue; + } + } + } + + async clearReady(interviewId: string): Promise { + await this.redis.del(this.readyKey(interviewId)); + } + + private async callAiInitialization(): Promise { + const commKey = process.env.AI_COMMUNICATION_KEY ?? "c7yPY8u644OE"; + + try { + const response = await this.httpService.axiosRef.post( + `${this.aiServerUrl}/process/initialization`, + { + key: commKey, + type: "initialization", + format: "text", + data: "", + }, + ); + + if (response.status !== 200) { + throw new InternalServerErrorException("AI server error."); + } + } catch (error) { + this.logger.error( + `AI initialization failed: ${(error as Error).message}`, + ); + throw new InternalServerErrorException( + "Internal server error while contacting AI server.", + ); + } + } +} diff --git a/server/src/modules/simulation/simulation-ws-token.service.ts b/server/src/modules/simulation/simulation-ws-token.service.ts new file mode 100644 index 00000000..e52b1454 --- /dev/null +++ b/server/src/modules/simulation/simulation-ws-token.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; + +import { loadSimulationConfig, resolveAiWsPublicBase } from "./simulation.config"; + +export type SimulationWsTokenPayload = { + purpose: "simulation_ws"; + interviewId: string; + userId: string; +}; + +@Injectable() +export class SimulationWsTokenService { + constructor(private readonly jwtService: JwtService) {} + + buildEntrypoint(interviewId: string, userId: string): string { + const { wsTokenTtlSec } = loadSimulationConfig(); + const token = this.jwtService.sign( + { + purpose: "simulation_ws", + interviewId, + userId, + } satisfies SimulationWsTokenPayload, + { + expiresIn: wsTokenTtlSec, + }, + ); + + const base = resolveAiWsPublicBase(); + const separator = base.includes("?") ? "&" : "?"; + return `${base}${separator}token=${encodeURIComponent(token)}`; + } +} diff --git a/server/src/modules/simulation/simulation.config.ts b/server/src/modules/simulation/simulation.config.ts new file mode 100644 index 00000000..e320c699 --- /dev/null +++ b/server/src/modules/simulation/simulation.config.ts @@ -0,0 +1,43 @@ +export type SimulationConfig = { + maxConcurrent: number; + queueMaxSize: number; + slotTtlSec: number; + queueEntryTtlSec: number; + wsTokenTtlSec: number; + estimatedTurnSec: number; + historyMaxTurns: number; + contextTtlSec: number; +}; + +export function loadSimulationConfig(): SimulationConfig { + return { + maxConcurrent: parseInt(process.env.SIM_MAX_CONCURRENT ?? "2", 10), + queueMaxSize: parseInt(process.env.SIM_QUEUE_MAX_SIZE ?? "20", 10), + slotTtlSec: parseInt(process.env.SIM_SLOT_TTL_SEC ?? "900", 10), + queueEntryTtlSec: parseInt(process.env.SIM_QUEUE_ENTRY_TTL_SEC ?? "3600", 10), + wsTokenTtlSec: parseInt(process.env.SIM_WS_TOKEN_TTL_SEC ?? "900", 10), + estimatedTurnSec: parseInt(process.env.SIM_ESTIMATED_TURN_SEC ?? "90", 10), + historyMaxTurns: parseInt(process.env.SIM_HISTORY_MAX_TURNS ?? "30", 10), + contextTtlSec: parseInt(process.env.SIM_CONTEXT_TTL_SEC ?? "7200", 10), + }; +} + +/** Public WebSocket URL for browsers (VM/proxy). Falls back from AI_SERVER_URL for local dev. */ +export function resolveAiWsPublicBase(): string { + const explicit = process.env.AI_WS_PUBLIC_URL?.trim(); + if (explicit) { + return explicit.replace(/\/$/, ""); + } + + const aiServer = process.env.AI_SERVER_URL?.trim(); + if (!aiServer) { + throw new Error( + "AI_WS_PUBLIC_URL or AI_SERVER_URL must be set to build simulation WebSocket entrypoints.", + ); + } + + const wsBase = aiServer.replace(/^http/i, (m) => + m.toLowerCase() === "https" ? "wss" : "ws", + ); + return `${wsBase.replace(/\/$/, "")}/ws`; +} diff --git a/server/src/modules/simulation/simulation.cron.ts b/server/src/modules/simulation/simulation.cron.ts new file mode 100644 index 00000000..236b532b --- /dev/null +++ b/server/src/modules/simulation/simulation.cron.ts @@ -0,0 +1,53 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Cron, CronExpression } from "@nestjs/schedule"; +import { InjectRepository } from "@nestjs/typeorm"; +import { In, Repository } from "typeorm"; + +import { ai_interview } from "@entities/aiInterview.entity"; +import { AiInterviewStatus } from "@common/enums/AiInterviewStatus"; + +import { loadSimulationConfig } from "./simulation.config"; +import { SimulationCapacityService } from "./simulation-capacity.service"; +import { SimulationContextService } from "./simulation-context.service"; +import { SimulationPromotionService } from "./simulation-promotion.service"; + +@Injectable() +export class SimulationCronService { + private readonly logger = new Logger(SimulationCronService.name); + + constructor( + private readonly capacity: SimulationCapacityService, + private readonly context: SimulationContextService, + private readonly promotion: SimulationPromotionService, + @InjectRepository(ai_interview) + private readonly interviewRepo: Repository, + ) {} + + @Cron(CronExpression.EVERY_MINUTE) + async expireStaleSlots(): Promise { + const { slotTtlSec } = loadSimulationConfig(); + const now = Math.floor(Date.now() / 1000); + const activeIds = await this.capacity.listActiveInterviewIds(); + + for (const interviewId of activeIds) { + const meta = await this.capacity.getActiveMeta(interviewId); + if (!meta) continue; + + if (now - meta.lastHeartbeat <= slotTtlSec) continue; + + this.logger.warn( + `Expiring stale simulation slot for interview ${interviewId}`, + ); + + await this.capacity.releaseSlot(interviewId, meta.userId); + await this.context.deleteContext(interviewId); + + await this.interviewRepo.update( + { interview_id: interviewId }, + { status: AiInterviewStatus.EXPIRED }, + ); + + await this.promotion.promoteNextFromQueue(); + } + } +} diff --git a/server/src/modules/simulation/simulation.module.ts b/server/src/modules/simulation/simulation.module.ts new file mode 100644 index 00000000..8e0edf9d --- /dev/null +++ b/server/src/modules/simulation/simulation.module.ts @@ -0,0 +1,39 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { HttpModule } from "@nestjs/axios"; + +import { RedisModule } from "@common/redis/redis.module"; +import { ai_interview } from "@entities/aiInterview.entity"; + +import { SimulationCapacityService } from "./simulation-capacity.service"; +import { SimulationContextService } from "./simulation-context.service"; +import { SimulationWsTokenService } from "./simulation-ws-token.service"; +import { SimulationPromotionService } from "./simulation-promotion.service"; +import { SimulationCronService } from "./simulation.cron"; +import { SimulationInternalController } from "./simulation-internal.controller"; +import { InternalApiKeyGuard } from "./guards/internal-api-key.guard"; + +@Module({ + imports: [ + RedisModule, + HttpModule.register({ timeout: 5000 }), + TypeOrmModule.forFeature([ai_interview]), + ], + controllers: [SimulationInternalController], + providers: [ + SimulationCapacityService, + SimulationContextService, + SimulationWsTokenService, + SimulationPromotionService, + SimulationCronService, + InternalApiKeyGuard, + ], + exports: [ + SimulationCapacityService, + SimulationContextService, + SimulationWsTokenService, + SimulationPromotionService, + SimulationCronService, + ], +}) +export class SimulationModule {} diff --git a/server/src/modules/simulation/simulation.redis-keys.ts b/server/src/modules/simulation/simulation.redis-keys.ts new file mode 100644 index 00000000..9ed459ca --- /dev/null +++ b/server/src/modules/simulation/simulation.redis-keys.ts @@ -0,0 +1,11 @@ +import { SIM_REDIS_PREFIX } from "@common/redis/redis.constants"; + +export const SimRedisKeys = { + activeCount: `${SIM_REDIS_PREFIX}active_count`, + activeSet: `${SIM_REDIS_PREFIX}active_ids`, + active: (interviewId: string) => `${SIM_REDIS_PREFIX}active:${interviewId}`, + queue: `${SIM_REDIS_PREFIX}queue`, + userActive: (userId: string) => `${SIM_REDIS_PREFIX}user:active:${userId}`, + context: (interviewId: string) => `${SIM_REDIS_PREFIX}ctx:${interviewId}`, + queuedAt: (interviewId: string) => `${SIM_REDIS_PREFIX}queued_at:${interviewId}`, +} as const; From 63dcb991f51de3e3326ec6becdcc6f35e376223e Mon Sep 17 00:00:00 2001 From: Andriamanampisoa Date: Thu, 4 Jun 2026 17:55:28 +0900 Subject: [PATCH 2/8] feat(sts): load per-interview context from backend over websocket --- .../src/network/MicroservicesManager.cpp | 5 + ai/docker-compose.yml | 2 + .../speech-to-speech/engine/pipeline.py | 25 +++- .../speech-to-speech/engine/queueService.py | 22 ++- .../engine/session_context.py | 131 ++++++++++++++++++ .../speech-to-speech/engine/stsProcess.py | 24 +++- 6 files changed, 196 insertions(+), 13 deletions(-) create mode 100644 ai/microservices/speech-to-speech/engine/session_context.py diff --git a/ai/core/server/src/network/MicroservicesManager.cpp b/ai/core/server/src/network/MicroservicesManager.cpp index 45fdd2fd..2a7351f8 100644 --- a/ai/core/server/src/network/MicroservicesManager.cpp +++ b/ai/core/server/src/network/MicroservicesManager.cpp @@ -389,8 +389,13 @@ void talkup_network::MicroservicesManager::process_sts_job(const nlohmann::json std::unique_lock io_lock(*io_mutex); const uint64_t request_id = ++g_sts_request_id; + std::string interview_id; + if (data.contains("stream_id") && data["stream_id"].is_string()) + interview_id = data["stream_id"].get(); nlohmann::json audio_json = {{"services", {"STS"}}, {"type", "stream_chunk"}, {"request_id", request_id}, {"timestamp", std::time(nullptr)}, {"data", {{"chunk", chunk_val}, {"eof", true}}}}; + if (!interview_id.empty()) + audio_json["interview_id"] = interview_id; ws->write(boost::asio::buffer(audio_json.dump())); std::cout << "[MicroservicesManager] Sent STS stream_chunk request_id=" << request_id << std::endl; diff --git a/ai/docker-compose.yml b/ai/docker-compose.yml index 1c903ddb..a81db4e4 100644 --- a/ai/docker-compose.yml +++ b/ai/docker-compose.yml @@ -54,6 +54,8 @@ services: volumes: - ./llm-core/models/sup-it-v3-merged:/app/llm/sup-it-v3-merged environment: + - BACKEND_URL=${BACKEND_URL:-http://host.docker.internal:3000/v1/api} + - SIM_INTERNAL_API_KEY=${SIM_INTERNAL_API_KEY:-talkup-dev-internal-key} - CUDA_VISIBLE_DEVICES=-1 - NVIDIA_VISIBLE_DEVICES=none - LLM_BACKEND=hf diff --git a/ai/microservices/speech-to-speech/engine/pipeline.py b/ai/microservices/speech-to-speech/engine/pipeline.py index 16a7f677..9e111f1e 100644 --- a/ai/microservices/speech-to-speech/engine/pipeline.py +++ b/ai/microservices/speech-to-speech/engine/pipeline.py @@ -13,6 +13,11 @@ from dataclasses import dataclass from .audio_decode import decode_audio_to_float32 from .models import STSModels, generate_ai_response, synthesize_tts_chunks +from .session_context import ( + append_session_history, + build_messages_from_context, + fetch_session_context, +) @dataclass class STSResult: @@ -23,7 +28,11 @@ class STSResult: ai_response: str audio_chunks: list[bytes] -def process_sts_request(models: STSModels, audio_bytes: bytes) -> STSResult: +def process_sts_request( + models: STSModels, + audio_bytes: bytes, + interview_id: str | None = None, +) -> STSResult: """ Processes a Speech-to-Speech request by transcribing the input audio, generating an AI response, and synthesizing the response into audio chunks. """ @@ -40,11 +49,17 @@ def process_sts_request(models: STSModels, audio_bytes: bytes) -> STSResult: if not user_text or len(user_text) < 2: return STSResult(transcription="", ai_response="", audio_chunks=[]) - messages = [ - {"role": "system", "content": models.settings.system_prompt}, - {"role": "user", "content": user_text}, - ] + session = fetch_session_context(interview_id) if interview_id else None + messages = build_messages_from_context( + models.settings.system_prompt, + session, + user_text, + ) ai_response = generate_ai_response(models, messages) audio_chunks = list(synthesize_tts_chunks(models, ai_response)) + + if interview_id and ai_response: + append_session_history(interview_id, user_text, ai_response) + return STSResult(transcription=user_text, ai_response=ai_response, audio_chunks=audio_chunks) diff --git a/ai/microservices/speech-to-speech/engine/queueService.py b/ai/microservices/speech-to-speech/engine/queueService.py index 86307f86..151809ff 100644 --- a/ai/microservices/speech-to-speech/engine/queueService.py +++ b/ai/microservices/speech-to-speech/engine/queueService.py @@ -24,6 +24,7 @@ class StsJob: """ audio_bytes: bytes result_future: asyncio.Future[STSResult] + interview_id: str | None = None class StsQueueService: """ @@ -63,14 +64,24 @@ async def stop(self) -> None: await self._worker_task self._worker_task = None - async def submit(self, audio_bytes: bytes) -> STSResult: + async def submit( + self, + audio_bytes: bytes, + interview_id: str | None = None, + ) -> STSResult: """ Submits an audio request for processing and returns the result. """ loop = asyncio.get_running_loop() result_future: asyncio.Future[STSResult] = loop.create_future() - await self._queue.put(StsJob(audio_bytes=audio_bytes, result_future=result_future)) + await self._queue.put( + StsJob( + audio_bytes=audio_bytes, + result_future=result_future, + interview_id=interview_id, + ), + ) return await result_future async def _worker(self) -> None: @@ -87,7 +98,12 @@ async def _worker(self) -> None: break try: - result = await asyncio.to_thread(process_sts_request, self._models, job.audio_bytes) + result = await asyncio.to_thread( + process_sts_request, + self._models, + job.audio_bytes, + job.interview_id, + ) if not job.result_future.done(): job.result_future.set_result(result) except Exception as err: diff --git a/ai/microservices/speech-to-speech/engine/session_context.py b/ai/microservices/speech-to-speech/engine/session_context.py new file mode 100644 index 00000000..f449070f --- /dev/null +++ b/ai/microservices/speech-to-speech/engine/session_context.py @@ -0,0 +1,131 @@ +## +## Talkup Project, 2026 +## TalkUp.AI +## Fetches per-interview LLM context from NestJS (Scenario A). +## + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from typing import Any + +from .notifications import Notifications +from .enumMcs import EnumMcs + +NOTIFIER = Notifications() + + +def _backend_base() -> str: + base = os.environ.get("BACKEND_URL", "http://127.0.0.1:3000/v1/api").strip() + return base.rstrip("/") + + +def _internal_key() -> str: + return os.environ.get("SIM_INTERNAL_API_KEY", "").strip() + + +def fetch_session_context(interview_id: str) -> dict[str, Any] | None: + """ + Loads system prompt and conversation history for one interview. + Returns None if unavailable (caller should fall back to global prompt). + """ + if not interview_id or interview_id == "unknown": + return None + + key = _internal_key() + if not key: + NOTIFIER.send_notification( + EnumMcs.MicroservicesNames.STS, + 1, + "SIM_INTERNAL_API_KEY not set; using default system prompt", + ) + return None + + url = f"{_backend_base()}/ai/internal/sessions/{interview_id}/context" + req = urllib.request.Request( + url, + headers={"X-Internal-Api-Key": key, "Accept": "application/json"}, + method="GET", + ) + + try: + with urllib.request.urlopen(req, timeout=10) as resp: + body = resp.read().decode("utf-8") + return json.loads(body) + except urllib.error.HTTPError as err: + NOTIFIER.send_notification( + EnumMcs.MicroservicesNames.STS, + 1, + f"Session context HTTP {err.code} for {interview_id}", + ) + except Exception as err: + NOTIFIER.send_notification( + EnumMcs.MicroservicesNames.STS, + 1, + f"Session context fetch failed for {interview_id}: {err}", + ) + return None + + +def append_session_history( + interview_id: str, + user_text: str, + assistant_text: str, +) -> None: + if not interview_id or interview_id == "unknown": + return + + key = _internal_key() + if not key: + return + + url = f"{_backend_base()}/ai/internal/sessions/{interview_id}/history" + payload = json.dumps( + {"userText": user_text, "assistantText": assistant_text}, + ).encode("utf-8") + req = urllib.request.Request( + url, + data=payload, + headers={ + "X-Internal-Api-Key": key, + "Content-Type": "application/json", + }, + method="POST", + ) + + try: + with urllib.request.urlopen(req, timeout=10) as resp: + resp.read() + except Exception as err: + NOTIFIER.send_notification( + EnumMcs.MicroservicesNames.STS, + 1, + f"Failed to append session history for {interview_id}: {err}", + ) + + +def build_messages_from_context( + default_system_prompt: str, + session: dict[str, Any] | None, + user_text: str, +) -> list[dict[str, str]]: + if not session: + return [ + {"role": "system", "content": default_system_prompt}, + {"role": "user", "content": user_text}, + ] + + system_prompt = session.get("systemPrompt") or default_system_prompt + history = session.get("history") or [] + + messages: list[dict[str, str]] = [{"role": "system", "content": system_prompt}] + for turn in history: + role = turn.get("role") + content = turn.get("content") + if role in ("user", "assistant") and isinstance(content, str) and content.strip(): + messages.append({"role": role, "content": content}) + messages.append({"role": "user", "content": user_text}) + return messages diff --git a/ai/microservices/speech-to-speech/engine/stsProcess.py b/ai/microservices/speech-to-speech/engine/stsProcess.py index 2015babc..ecec02b7 100644 --- a/ai/microservices/speech-to-speech/engine/stsProcess.py +++ b/ai/microservices/speech-to-speech/engine/stsProcess.py @@ -42,6 +42,7 @@ async def _process_stream_and_reply( send_lock: asyncio.Lock, audio_bytes: bytes, request_id: int | None = None, + interview_id: str | None = None, ) -> None: """Process one utterance and reply (responses stay in FIFO order per connection).""" try: @@ -63,7 +64,7 @@ async def _process_stream_and_reply( ) return - result = await queue_service.submit(audio_bytes) + result = await queue_service.submit(audio_bytes, interview_id=interview_id) if not result.transcription: payload: dict = { @@ -133,7 +134,7 @@ async def websocket_endpoint(websocket: WebSocket): await websocket.accept() NOTIFIER.send_notification(EnumMcs.MicroservicesNames.STS, 0, "Client connected to the STS service") send_lock = asyncio.Lock() - audio_queue: asyncio.Queue[tuple[int | None, bytes] | None] = asyncio.Queue() + audio_queue: asyncio.Queue[tuple[int | None, str | None, bytes] | None] = asyncio.Queue() async def audio_worker() -> None: while True: @@ -141,8 +142,14 @@ async def audio_worker() -> None: try: if item is None: return - request_id, audio_bytes = item - await _process_stream_and_reply(websocket, send_lock, audio_bytes, request_id) + request_id, interview_id, audio_bytes = item + await _process_stream_and_reply( + websocket, + send_lock, + audio_bytes, + request_id, + interview_id, + ) finally: audio_queue.task_done() @@ -157,6 +164,7 @@ async def audio_worker() -> None: return audio_bytes = b"" request_id: int | None = None + interview_id: str | None = None if message.get("type") == "websocket.receive" and message.get("text") is not None: try: @@ -179,6 +187,12 @@ async def audio_worker() -> None: if payload.get("type") == "stream_chunk": request_id = payload.get("request_id") + interview_id = payload.get("interview_id") + if isinstance(interview_id, str): + interview_id = interview_id.strip() or None + else: + interview_id = None + if request_id is not None and not isinstance(request_id, int): try: request_id = int(request_id) @@ -217,7 +231,7 @@ async def audio_worker() -> None: if not audio_bytes: continue - await audio_queue.put((request_id, audio_bytes)) + await audio_queue.put((request_id, interview_id, audio_bytes)) except WebSocketDisconnect: NOTIFIER.send_notification(EnumMcs.MicroservicesNames.STS, 1, "Client disconnected") From 739d88cda36eb0bfb840efe964ba559d38acb835 Mon Sep 17 00:00:00 2001 From: Andriamanampisoa Date: Thu, 4 Jun 2026 17:55:40 +0900 Subject: [PATCH 3/8] feat(simulation): handle queued interviews and poll session status --- .../simulation-queue-banner/index.tsx | 41 ++++++ .../hooks/simulation/useInterviewSession.ts | 135 ++++++++++++++---- web/src/routes/simulations.tsx | 13 ++ web/src/services/ai/http.ts | 14 ++ web/src/services/ai/types.ts | 32 ++++- 5 files changed, 204 insertions(+), 31 deletions(-) create mode 100644 web/src/components/molecules/simulation-queue-banner/index.tsx diff --git a/web/src/components/molecules/simulation-queue-banner/index.tsx b/web/src/components/molecules/simulation-queue-banner/index.tsx new file mode 100644 index 00000000..a7227dad --- /dev/null +++ b/web/src/components/molecules/simulation-queue-banner/index.tsx @@ -0,0 +1,41 @@ +type SimulationQueueBannerProps = { + queuePosition: number; + estimatedWaitSec?: number; + activeCount?: number; + maxConcurrent?: number; +}; + +export default function SimulationQueueBanner({ + queuePosition, + estimatedWaitSec, + activeCount, + maxConcurrent, +}: SimulationQueueBannerProps) { + const waitMin = + estimatedWaitSec != null ? Math.max(1, Math.ceil(estimatedWaitSec / 60)) : null; + + return ( +
+

File d'attente simulation

+

+ Position : {queuePosition} + {waitMin != null && ( + <> + {' '} + — attente estimée ~{waitMin} min + + )} +

+ {activeCount != null && maxConcurrent != null && ( +

+ {activeCount}/{maxConcurrent} créneaux GPU utilisés. Votre session démarrera + automatiquement. +

+ )} +
+ ); +} diff --git a/web/src/hooks/simulation/useInterviewSession.ts b/web/src/hooks/simulation/useInterviewSession.ts index fccc1e8b..9c04a48b 100644 --- a/web/src/hooks/simulation/useInterviewSession.ts +++ b/web/src/hooks/simulation/useInterviewSession.ts @@ -1,4 +1,9 @@ -import { createInterview, updateInterview } from '@/services/ai/http'; +import { + cancelInterview, + createInterview, + getInterviewSession, + updateInterview, +} from '@/services/ai/http'; import { useCallback, useEffect, useRef, useState } from 'react'; import toast from 'react-hot-toast'; @@ -10,6 +15,8 @@ const STORAGE_KEYS = { const WEBSOCKET_CLOSE_CODE_NORMAL = 1000; const STREAM_RESUME_DELAY_MS = 100; +const QUEUE_POLL_INTERVAL_MS = 3000; +const QUEUE_POLL_MAX_MS = 20 * 60 * 1000; /** * Props for the useInterviewSession hook. @@ -29,6 +36,12 @@ export interface UseInterviewSessionProps { export interface UseInterviewSessionReturn { /** Whether an interview call is currently active */ isCallActive: boolean; + /** Whether the user is waiting in the simulation queue */ + isQueued: boolean; + /** Queue position when isQueued (1-based) */ + queuePosition: number; + /** Estimated wait in seconds when queued */ + estimatedWaitSec?: number; /** WebSocket URL for the current interview session */ inputUrl: string; /** ID of the current interview session, if any */ @@ -37,27 +50,33 @@ export interface UseInterviewSessionReturn { handleStreamToggle: (streaming: boolean) => Promise; } +async function waitForReadyEntrypoint( + interviewId: string, + signal: AbortSignal, +): Promise { + const started = Date.now(); + + while (Date.now() - started < QUEUE_POLL_MAX_MS) { + if (signal.aborted) { + throw new Error('Queue wait aborted'); + } + + const session = await getInterviewSession(interviewId); + if (session.sessionStatus === 'ready' && session.entrypoint) { + return session.entrypoint; + } + if (session.sessionStatus === 'ended') { + throw new Error('Simulation session ended before start'); + } + + await new Promise((resolve) => setTimeout(resolve, QUEUE_POLL_INTERVAL_MS)); + } + + throw new Error('Queue wait timed out'); +} + /** * Custom hook to manage AI interview session lifecycle. - * - * Features: - * - Creates new interview sessions via API - * - Manages WebSocket connection lifecycle - * - Persists session state to localStorage - * - Auto-resumes interrupted sessions after page refresh - * - Updates interview status on completion - * - * @param props - Configuration for interview session management - * @returns Interview session state and control functions - * - * @example - * ```tsx - * const { isCallActive, inputUrl, handleStreamToggle } = useInterviewSession({ - * onConnect: (url) => websocket.connect(url), - * onDisconnect: (code, reason) => websocket.disconnect(code, reason), - * onResumeStream: () => videoStream.start(), - * }); - * ``` */ export function useInterviewSession({ onConnect, @@ -67,8 +86,12 @@ export function useInterviewSession({ const [inputUrl, setInputUrl] = useState(''); const [interviewID, setInterviewID] = useState(null); const [isCallActive, setIsCallActive] = useState(false); + const [isQueued, setIsQueued] = useState(false); + const [queuePosition, setQueuePosition] = useState(0); + const [estimatedWaitSec, setEstimatedWaitSec] = useState(); const processingRef = useRef(false); const hasResumedRef = useRef(false); + const queueAbortRef = useRef(null); useEffect(() => { if (hasResumedRef.current) return; @@ -80,7 +103,6 @@ export function useInterviewSession({ localStorage.getItem(STORAGE_KEYS.IS_STREAMING) === 'true'; if (savedInterviewID && savedInterviewURL) { - console.log('Resuming interrupted interview:', savedInterviewID); setInputUrl(savedInterviewURL); setInterviewID(savedInterviewID); setIsCallActive(true); @@ -96,6 +118,12 @@ export function useInterviewSession({ } }, [onConnect, onResumeStream]); + useEffect(() => { + return () => { + queueAbortRef.current?.abort(); + }; + }, []); + const handleStreamToggle = useCallback( async (streaming: boolean) => { if (processingRef.current) return; @@ -103,25 +131,69 @@ export function useInterviewSession({ if (streaming) { try { - const { entrypoint, interviewID } = await createInterview({ + const created = await createInterview({ type: 'technical', - language: 'English', + language: 'French', }); - setInterviewID(interviewID); - localStorage.setItem(STORAGE_KEYS.INTERVIEW_ID, interviewID); + + setInterviewID(created.interviewID); + localStorage.setItem(STORAGE_KEYS.INTERVIEW_ID, created.interviewID); + + let entrypoint = created.entrypoint ?? null; + + if (created.status === 'queued') { + setIsQueued(true); + setQueuePosition(created.queuePosition); + setEstimatedWaitSec(created.estimatedWaitSec); + toast.loading('En file d\'attente…', { id: 'sim-queue' }); + + queueAbortRef.current?.abort(); + queueAbortRef.current = new AbortController(); + + entrypoint = await waitForReadyEntrypoint( + created.interviewID, + queueAbortRef.current.signal, + ); + + toast.dismiss('sim-queue'); + setIsQueued(false); + setQueuePosition(0); + toast.success('C\'est votre tour — démarrage de la simulation'); + } + + if (!entrypoint) { + throw new Error('No WebSocket entrypoint returned'); + } + localStorage.setItem(STORAGE_KEYS.INTERVIEW_URL, entrypoint); localStorage.setItem(STORAGE_KEYS.IS_STREAMING, 'true'); setInputUrl(entrypoint); setIsCallActive(true); onConnect(entrypoint); + + await updateInterview(created.interviewID, { status: 'in_progress' }); } catch (error) { console.error('Failed to start interview:', error); + toast.dismiss('sim-queue'); + setIsQueued(false); + const interviewId = localStorage.getItem(STORAGE_KEYS.INTERVIEW_ID); + if (interviewId) { + try { + await cancelInterview(interviewId); + } catch { + /* ignore cleanup errors */ + } + } + localStorage.removeItem(STORAGE_KEYS.INTERVIEW_ID); + localStorage.removeItem(STORAGE_KEYS.INTERVIEW_URL); + localStorage.removeItem(STORAGE_KEYS.IS_STREAMING); toast.error('Failed to start interview. Please try again.'); } finally { processingRef.current = false; } } else { - const interviewID = localStorage.getItem(STORAGE_KEYS.INTERVIEW_ID); + const interviewId = localStorage.getItem(STORAGE_KEYS.INTERVIEW_ID); + queueAbortRef.current?.abort(); try { onDisconnect(WEBSOCKET_CLOSE_CODE_NORMAL, 'Call ended'); @@ -129,9 +201,9 @@ export function useInterviewSession({ console.error('Failed to disconnect WebSocket:', error); } - if (interviewID) { + if (interviewId) { try { - await updateInterview(interviewID, { status: 'completed' }); + await updateInterview(interviewId, { status: 'completed' }); localStorage.removeItem(STORAGE_KEYS.INTERVIEW_ID); localStorage.removeItem(STORAGE_KEYS.INTERVIEW_URL); localStorage.removeItem(STORAGE_KEYS.IS_STREAMING); @@ -140,6 +212,10 @@ export function useInterviewSession({ } } + setIsCallActive(false); + setIsQueued(false); + setInputUrl(''); + setInterviewID(null); processingRef.current = false; } }, @@ -148,6 +224,9 @@ export function useInterviewSession({ return { isCallActive, + isQueued, + queuePosition, + estimatedWaitSec, inputUrl, interviewID, handleStreamToggle, diff --git a/web/src/routes/simulations.tsx b/web/src/routes/simulations.tsx index 00e85c7d..07ccc6aa 100644 --- a/web/src/routes/simulations.tsx +++ b/web/src/routes/simulations.tsx @@ -1,4 +1,5 @@ import InfoBox from '@/components/molecules/info-box'; +import SimulationQueueBanner from '@/components/molecules/simulation-queue-banner'; import NotesEditor from '@/components/molecules/notes-editor/notes-editor'; import SimulationTranscriptionArea from '@/components/organisms/simulation-transcription-area'; import { TranscriptionProps } from '@/components/organisms/simulation-transcription-area/types'; @@ -68,6 +69,9 @@ function Simulations() { const { isCallActive, + isQueued, + queuePosition, + estimatedWaitSec, inputUrl, interviewID: sessionInterviewID, handleStreamToggle, @@ -147,6 +151,15 @@ function Simulations() { + {isQueued && ( +
+ +
+ )} +
=> { + const response = await axiosInstance.get( + `${API_ROUTES.ai}/interviews/${interviewId}/session`, + ); + return response.data; +}; + +export const cancelInterview = async (interviewId: string): Promise => { + await axiosInstance.post(`${API_ROUTES.ai}/interviews/${interviewId}/cancel`); +}; + export const updateInterview = async ( interviewId: string, dto: UpdateAiInterviewDto, diff --git a/web/src/services/ai/types.ts b/web/src/services/ai/types.ts index e95dd6a1..e3b753e0 100644 --- a/web/src/services/ai/types.ts +++ b/web/src/services/ai/types.ts @@ -8,16 +8,42 @@ export interface CreateAiInterviewDto { language: string; /** Optional initial status of the interview */ status?: string; + /** CV / job offer / preparation notes for the AI persona */ + jobContext?: string; } +export type SimulationSessionPhase = 'ready' | 'queued' | 'active'; + /** * Response object returned when an AI interview is created. */ export interface AiInterviewResponse { /** Unique identifier for the created interview */ interviewID: string; - /** WebSocket endpoint URL to connect to for the interview session */ - entrypoint: string; + /** Whether the session can connect immediately or must wait in queue */ + status: SimulationSessionPhase; + /** WebSocket endpoint URL when status is ready */ + entrypoint?: string | null; + /** 0 when ready; 1-based position when queued */ + queuePosition: number; + /** Rough wait estimate in seconds when queued */ + estimatedWaitSec?: number; +} + +export interface InterviewSessionResponse { + interviewID: string; + dbStatus: string; + sessionStatus: 'ready' | 'queued' | 'active' | 'ended'; + queuePosition: number; + entrypoint?: string | null; + estimatedWaitSec?: number; +} + +export interface SimulationCapacityResponse { + active: number; + max: number; + queueLength: number; + accepting: boolean; } /** @@ -25,7 +51,7 @@ export interface AiInterviewResponse { */ export interface UpdateAiInterviewDto { /** Updated status of the interview */ - status?: 'asked' | 'in_progress' | 'completed' | 'cancelled'; + status?: 'asked' | 'queued' | 'in_progress' | 'completed' | 'cancelled' | 'expired'; /** Score assigned to the interview (if applicable) */ score?: number; /** Feedback text for the interview */ From 51291ba2f3c05c518702523fa58d984ba46c98b7 Mon Sep 17 00:00:00 2001 From: Andriamanampisoa Date: Fri, 5 Jun 2026 16:56:54 +0900 Subject: [PATCH 4/8] task: enable the use of OpenRouter --- ai/docker-compose.yml | 28 +++++----- ai/microservices/speech-to-speech/config.json | 2 +- .../speech-to-speech/engine/models.py | 39 ++++++++++++-- .../speech-to-speech/engine/settings.py | 54 ++++++++++++++++++- 4 files changed, 103 insertions(+), 20 deletions(-) diff --git a/ai/docker-compose.yml b/ai/docker-compose.yml index a81db4e4..6ee1de70 100644 --- a/ai/docker-compose.yml +++ b/ai/docker-compose.yml @@ -42,26 +42,26 @@ services: sts-service: build: ./microservices/speech-to-speech container_name: talkup-sts - deploy: - resources: - reservations: - devices: - - driver: nvidia - count: 1 - capabilities: [gpu] + env_file: + - core/server/.env + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - default + - talkup-ai ports: - "8002:8002" - volumes: - - ./llm-core/models/sup-it-v3-merged:/app/llm/sup-it-v3-merged environment: - - BACKEND_URL=${BACKEND_URL:-http://host.docker.internal:3000/v1/api} + - BACKEND_URL=${BACKEND_URL:-http://server:3000/v1/api} - SIM_INTERNAL_API_KEY=${SIM_INTERNAL_API_KEY:-talkup-dev-internal-key} + - LLM_BACKEND=${LLM_BACKEND:-openrouter} + - LLM_MAX_NEW_TOKENS=${LLM_MAX_NEW_TOKENS:-160} + # OpenRouter: no local LLM — keep GPU free (Whisper runs on CPU). - CUDA_VISIBLE_DEVICES=-1 - NVIDIA_VISIBLE_DEVICES=none - - LLM_BACKEND=hf - - LLM_GPU_MAX_MEMORY=3GiB - - LLM_CPU_MAX_MEMORY=8GiB - - LLM_MAX_NEW_TOKENS=128 + # Local LLM (LLM_BACKEND=local): add deploy GPU reservation + volume below. + # volumes: + # - ./llm-core/models/sup-it-v3-merged:/app/llm/sup-it-v3-merged restart: unless-stopped healthcheck: test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8002/health')"] diff --git a/ai/microservices/speech-to-speech/config.json b/ai/microservices/speech-to-speech/config.json index 54f6f82f..7453f4fb 100644 --- a/ai/microservices/speech-to-speech/config.json +++ b/ai/microservices/speech-to-speech/config.json @@ -3,7 +3,7 @@ "SYSTEM_PROMPT": "Tu es Sophie Martin, recruteuse senior IT chez une ESN francaise. Tu es chaleureuse, professionnelle, patiente et humaine. Tu parles de facon naturelle comme dans une vraie conversation. Tu dois repondre uniquement a la derniere prise de parole du candidat, en une seule reponse courte et naturelle. N'ecris jamais un dialogue multi-tours, n'imite jamais des balises comme system: ou user:, et ne recopie jamais l'historique de conversation.", "PIPER_VOICE_PATH": "/app/models/piper/fr_FR-siwis-medium.onnx", "WHISPER_MODEL_PATH": "/app/models/whisper/faster-whisper-large-v3", - "LLM_BACKEND": "auto", + "LLM_BACKEND": "openrouter", "LLM_GPU_MAX_MEMORY": "3GiB", "LLM_CPU_MAX_MEMORY": "3GiB", "LLM_MAX_NEW_TOKENS": 160, diff --git a/ai/microservices/speech-to-speech/engine/models.py b/ai/microservices/speech-to-speech/engine/models.py index 1dbe1045..7e77a604 100644 --- a/ai/microservices/speech-to-speech/engine/models.py +++ b/ai/microservices/speech-to-speech/engine/models.py @@ -30,6 +30,7 @@ from .enumMcs import EnumMcs from .notifications import Notifications +from .openrouter import generate_openrouter_response from .settings import STSSettings NOTIFIER = Notifications() @@ -284,13 +285,28 @@ def load_models(settings: STSSettings) -> STSModels: hf_tokenizer = None llm_backend = "none" - if settings.llm_backend in {"auto", "vllm"}: + if settings.llm_backend == "openrouter": + if settings.openrouter_api_key: + llm_backend = "openrouter" + NOTIFIER.send_notification( + EnumMcs.MicroservicesNames.STS, + 0, + f"LLM: ACTIVE (OpenRouter model={settings.openrouter_model})", + ) + else: + NOTIFIER.send_notification( + EnumMcs.MicroservicesNames.STS, + 1, + "LLM_BACKEND=openrouter but OPENROUTER_API_KEY is missing", + ) + + if llm_backend == "none" and settings.llm_backend in {"auto", "vllm"}: llm_engine, llm_sampling_params, llm_backend = _init_vllm(settings) if llm_backend == "none" and settings.llm_backend in {"auto", "hf"}: hf_model, hf_tokenizer, llm_backend = _init_hf_offload(settings) - if llm_backend == "none": + if llm_backend == "none" and settings.llm_backend != "openrouter": NOTIFIER.send_notification( EnumMcs.MicroservicesNames.STS, 1, @@ -303,11 +319,13 @@ def load_models(settings: STSSettings) -> STSModels: NOTIFIER.send_notification(EnumMcs.MicroservicesNames.STS, 0, "============================================================") NOTIFIER.send_notification(EnumMcs.MicroservicesNames.STS, 0, "All models loaded successfully!") - if llm_backend == "vllm": + if llm_backend == "openrouter": + pass # already logged + elif llm_backend == "vllm": NOTIFIER.send_notification(EnumMcs.MicroservicesNames.STS, 0, "LLM: ACTIVE (vLLM)") elif llm_backend == "hf": NOTIFIER.send_notification(EnumMcs.MicroservicesNames.STS, 0, "LLM: ACTIVE (Transformers offload)") - else: + elif llm_backend == "none": NOTIFIER.send_notification(EnumMcs.MicroservicesNames.STS, 2, "LLM: DISABLED (fallback mode)") NOTIFIER.send_notification(EnumMcs.MicroservicesNames.STS, 0, "STT: ACTIVE") NOTIFIER.send_notification(EnumMcs.MicroservicesNames.STS, 0, "TTS: ACTIVE") @@ -328,6 +346,19 @@ def generate_ai_response(models: STSModels, messages: list[dict[str, str]]) -> s """ Generates an AI response based on the provided messages and models. """ + if models.llm_backend == "openrouter": + try: + text = generate_openrouter_response( + messages, + api_key=models.settings.openrouter_api_key, + model=models.settings.openrouter_model, + max_tokens=models.settings.llm_max_new_tokens, + base_url=models.settings.openrouter_base_url, + ) + return _sanitize_llm_response(text) + except Exception: + return "Je rencontre une indisponibilite temporaire du modele de reponse. Peux-tu reformuler ta phrase ?" + if models.llm_backend == "vllm" and models.vllm_engine is not None and models.vllm_sampling_params is not None: response = models.vllm_engine.chat(messages=messages, sampling_params=models.vllm_sampling_params) return _sanitize_llm_response(response.outputs[0].text) diff --git a/ai/microservices/speech-to-speech/engine/settings.py b/ai/microservices/speech-to-speech/engine/settings.py index d5794c43..a078b095 100644 --- a/ai/microservices/speech-to-speech/engine/settings.py +++ b/ai/microservices/speech-to-speech/engine/settings.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import os from dataclasses import dataclass from pathlib import Path @@ -31,6 +32,9 @@ class STSSettings: llm_cpu_max_memory: str llm_max_new_tokens: int queue_maxsize: int + openrouter_api_key: str + openrouter_model: str + openrouter_base_url: str DEFAULT_SETTINGS = STSSettings( llm_path="/app/llm/sup-it-v3-merged", @@ -42,8 +46,25 @@ class STSSettings: llm_cpu_max_memory="16GiB", llm_max_new_tokens=160, queue_maxsize=32, + openrouter_api_key="", + openrouter_model="mistralai/mistral-small-3.2-24b-instruct", + openrouter_base_url="https://openrouter.ai/api/v1", ) +def _normalize_llm_backend(raw: str) -> str: + """local = on-pod HF/vLLM; openrouter = remote API.""" + value = (raw or "auto").strip().lower() + if value == "local": + return "auto" + return value + + +def _env_override(key: str, defaults: dict[str, object]) -> None: + value = os.environ.get(key) + if value is not None and str(value).strip() != "": + defaults[key] = value + + def load_settings(config_path: Path | None = None) -> STSSettings: """ Loads the settings for the Speech-to-Speech processing module. @@ -61,6 +82,9 @@ def load_settings(config_path: Path | None = None) -> STSSettings: "LLM_CPU_MAX_MEMORY": DEFAULT_SETTINGS.llm_cpu_max_memory, "LLM_MAX_NEW_TOKENS": DEFAULT_SETTINGS.llm_max_new_tokens, "QUEUE_MAXSIZE": DEFAULT_SETTINGS.queue_maxsize, + "OPENROUTER_API_KEY": DEFAULT_SETTINGS.openrouter_api_key, + "OPENROUTER_MODEL": DEFAULT_SETTINGS.openrouter_model, + "OPENROUTER_BASE_URL": DEFAULT_SETTINGS.openrouter_base_url, } try: @@ -94,14 +118,42 @@ def load_settings(config_path: Path | None = None) -> STSSettings: f"Unable to load config file {resolved_config_path}: {err}. Using default values", ) + # Environment / .env wins over config.json (e.g. LLM_BACKEND=openrouter). + for env_key in ( + "LLM_PATH", + "SYSTEM_PROMPT", + "PIPER_VOICE_PATH", + "WHISPER_MODEL_PATH", + "LLM_BACKEND", + "LLM_GPU_MAX_MEMORY", + "LLM_CPU_MAX_MEMORY", + "LLM_MAX_NEW_TOKENS", + "QUEUE_MAXSIZE", + "OPENROUTER_API_KEY", + "OPENROUTER_MODEL", + "OPENROUTER_BASE_URL", + ): + _env_override(env_key, defaults) + + backend = _normalize_llm_backend(str(defaults["LLM_BACKEND"])) + key_set = bool(str(defaults["OPENROUTER_API_KEY"]).strip()) + NOTIFIER.send_notification( + EnumMcs.MicroservicesNames.STS, + 0, + f"STS config: LLM_BACKEND={backend}, OPENROUTER_API_KEY={'set' if key_set else 'MISSING'}", + ) + return STSSettings( llm_path=str(defaults["LLM_PATH"]), system_prompt=str(defaults["SYSTEM_PROMPT"]), piper_voice_path=str(defaults["PIPER_VOICE_PATH"]), whisper_model_path=str(defaults["WHISPER_MODEL_PATH"]), - llm_backend=str(defaults["LLM_BACKEND"]).lower(), + llm_backend=_normalize_llm_backend(str(defaults["LLM_BACKEND"])), llm_gpu_max_memory=str(defaults["LLM_GPU_MAX_MEMORY"]), llm_cpu_max_memory=str(defaults["LLM_CPU_MAX_MEMORY"]), llm_max_new_tokens=int(defaults["LLM_MAX_NEW_TOKENS"]), queue_maxsize=int(defaults["QUEUE_MAXSIZE"]), + openrouter_api_key=str(defaults["OPENROUTER_API_KEY"]).strip(), + openrouter_model=str(defaults["OPENROUTER_MODEL"]).strip(), + openrouter_base_url=str(defaults["OPENROUTER_BASE_URL"]).strip(), ) From 9cec8b2ff3d6dcd2287d062492d901d2e4e3d7cc Mon Sep 17 00:00:00 2001 From: Andriamanampisoa Date: Fri, 5 Jun 2026 17:01:12 +0900 Subject: [PATCH 5/8] task: add openrouter to the sts service --- .../speech-to-speech/engine/openrouter.py | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 ai/microservices/speech-to-speech/engine/openrouter.py diff --git a/ai/microservices/speech-to-speech/engine/openrouter.py b/ai/microservices/speech-to-speech/engine/openrouter.py new file mode 100644 index 00000000..fa428858 --- /dev/null +++ b/ai/microservices/speech-to-speech/engine/openrouter.py @@ -0,0 +1,90 @@ +## +## Talkup Project, 2026 +## TalkUp.AI +## OpenRouter chat completions (remote LLM). +## + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request + +from .enumMcs import EnumMcs +from .notifications import Notifications + +NOTIFIER = Notifications() + +DEFAULT_BASE_URL = "https://openrouter.ai/api/v1" +DEFAULT_MODEL = "mistralai/mistral-small-3.2-24b-instruct" + + +def generate_openrouter_response( + messages: list[dict[str, str]], + *, + api_key: str, + model: str, + max_tokens: int, + base_url: str = DEFAULT_BASE_URL, +) -> str: + """ + Calls OpenRouter /chat/completions with the full message list (system + history + user). + """ + url = f"{base_url.rstrip('/')}/chat/completions" + payload = { + "model": model, + "messages": messages, + "max_tokens": max_tokens, + "temperature": 0.75, + "top_p": 0.92, + } + + body = json.dumps(payload).encode("utf-8") + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "HTTP-Referer": os.environ.get("OPENROUTER_HTTP_REFERER", "https://talkup.ai"), + "X-Title": os.environ.get("OPENROUTER_APP_TITLE", "TalkUp.AI"), + } + + req = urllib.request.Request(url, data=body, headers=headers, method="POST") + + try: + with urllib.request.urlopen(req, timeout=120) as resp: + raw = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as err: + detail = err.read().decode("utf-8", errors="replace")[:500] + NOTIFIER.send_notification( + EnumMcs.MicroservicesNames.STS, + 1, + f"OpenRouter HTTP {err.code}: {detail}", + ) + raise + except Exception as err: + NOTIFIER.send_notification( + EnumMcs.MicroservicesNames.STS, + 1, + f"OpenRouter request failed: {err}", + ) + raise + + choices = raw.get("choices") or [] + if not choices: + raise ValueError("OpenRouter returned no choices") + + message = choices[0].get("message") or {} + content = message.get("content", "") + if isinstance(content, list): + # Multimodal-style chunks + parts = [ + p.get("text", "") + for p in content + if isinstance(p, dict) and p.get("type") == "text" + ] + content = "".join(parts) + + if not isinstance(content, str) or not content.strip(): + raise ValueError("OpenRouter returned empty content") + + return content.strip() From 43dc85db41d7e42b460892cacbe71bf3c4bbf434 Mon Sep 17 00:00:00 2001 From: Andriamanampisoa Date: Sat, 20 Jun 2026 17:30:19 +0900 Subject: [PATCH 6/8] feat: add company and user context to the communiaction between the app and the ai + add the information to the llm --- .../inc/network/MicroservicesManager.hpp | 8 + .../server/inc/network/WebsocketManager.hpp | 3 + ...nication_Protocol_AI_Server_Frontend_EN.md | 11 + ...ole_Communication_ServeurIA_Frontend_FR.md | 47 +++++ .../src/network/MicroservicesManager.cpp | 82 ++++++++ .../server/src/network/WebsocketManager.cpp | 74 +++++++ .../speech-to-speech/engine/pipeline.py | 12 +- .../engine/session_context.py | 24 +-- .../engine/simulation_brief.py | 196 ++++++++++++++++++ .../speech-to-speech/engine/stsProcess.py | 47 +++++ 10 files changed, 478 insertions(+), 26 deletions(-) create mode 100644 ai/microservices/speech-to-speech/engine/simulation_brief.py diff --git a/ai/core/server/inc/network/MicroservicesManager.hpp b/ai/core/server/inc/network/MicroservicesManager.hpp index fb9b7f2f..c5fa098c 100644 --- a/ai/core/server/inc/network/MicroservicesManager.hpp +++ b/ai/core/server/inc/network/MicroservicesManager.hpp @@ -74,6 +74,14 @@ namespace talkup_network { */ static void send_to_sts_microservice(const nlohmann::json &data, ResponseCallback callback); + /** + * @brief Push structured simulation context (company, job offer) to STS for one interview. + * @return true if STS acknowledged registration. + */ + static bool send_simulation_context_to_sts( + const std::string &interview_id, + const nlohmann::json &context_data); + /** * @brief Initialize WebSocket connections to all registered microservices. * It's establishes persistent WebSocket connections to each microservice diff --git a/ai/core/server/inc/network/WebsocketManager.hpp b/ai/core/server/inc/network/WebsocketManager.hpp index 476109a0..f393d286 100644 --- a/ai/core/server/inc/network/WebsocketManager.hpp +++ b/ai/core/server/inc/network/WebsocketManager.hpp @@ -86,6 +86,9 @@ namespace talkup_network { void handle_stream_chunk(const nlohmann::json& json, crow::websocket::connection& conn, std::shared_ptr microservices_manager); + void handle_simulation_context(const nlohmann::json& json, crow::websocket::connection& conn, + std::shared_ptr microservices_manager); + private: std::unordered_map)>> _type_handlers; diff --git a/ai/core/server/protocol/EN/Communication_Protocol_AI_Server_Frontend_EN.md b/ai/core/server/protocol/EN/Communication_Protocol_AI_Server_Frontend_EN.md index 89cb6e4d..7d625e00 100644 --- a/ai/core/server/protocol/EN/Communication_Protocol_AI_Server_Frontend_EN.md +++ b/ai/core/server/protocol/EN/Communication_Protocol_AI_Server_Frontend_EN.md @@ -84,6 +84,17 @@ Type format can be `audio`, `video`, `image` or `text` depending on the stream c ``` --- + +## 4.5 Simulation context — company and job offer + +Before audio `stream_chunk` messages, send `simulation_context` so the STS LLM receives company/job context in its system prompt. + +**Order:** WebSocket open → `simulation_context` → `simulation_context_ack` → `stream_chunk`. + +See the French protocol document for the full JSON schema (`company`, `jobOffer`, `additionalInfo`, `language`, `interviewType`). + +--- + ## 5. Advanced Level — AI Server ↔ Microservices The protocol is designed to evolve into a modular architecture where the AI Server delegates tasks to Python microservices. diff --git a/ai/core/server/protocol/FR/Protocole_Communication_ServeurIA_Frontend_FR.md b/ai/core/server/protocol/FR/Protocole_Communication_ServeurIA_Frontend_FR.md index 67a1d9b2..be914a86 100644 --- a/ai/core/server/protocol/FR/Protocole_Communication_ServeurIA_Frontend_FR.md +++ b/ai/core/server/protocol/FR/Protocole_Communication_ServeurIA_Frontend_FR.md @@ -85,6 +85,53 @@ Le format de type peut être `audio`, `video`, `image` ou `text` selon le conten --- +## 4.5 Contexte de simulation — entreprise et offre d'emploi + +Avant d'envoyer des `stream_chunk` audio, le Frontend **doit** enregistrer le contexte métier via `simulation_context`. Ce contexte alimente le prompt système du LLM (STS). + +**Ordre recommandé :** connexion WS → `simulation_context` → `simulation_context_ack` → `stream_chunk`. + +### Exemple : Frontend → Serveur IA + +```json +{ + "key": "exemple_key", + "type": "simulation_context", + "stream_id": "019bf2a0-....", + "format": "json", + "timestamp": 1739592334, + "data": { + "language": "French", + "interviewType": "technical", + "company": { + "name": "Acme Digital", + "description": "ESN specialisee en transformation digitale." + }, + "jobOffer": { + "title": "Developpeur Backend Java", + "description": "Mission SI bancaire microservices.", + "requirements": "Java 17, Spring Boot, Kafka." + }, + "additionalInfo": "Notes complementaires (CV resume, objectifs)." + } +} +``` + +### Exemple : Serveur IA → Frontend + +```json +{ + "key": "exemple_key", + "type": "simulation_context_ack", + "stream_id": "019bf2a0-....", + "format": "text", + "timestamp": 1739592335, + "data": "simulation context registered" +} +``` + +--- + ## 5. Niveau avancé — Serveur IA ↔ Microservices Le protocole est conçu pour évoluer vers une architecture modulaire où le Serveur IA délègue certaines tâches à des microservices Python. diff --git a/ai/core/server/src/network/MicroservicesManager.cpp b/ai/core/server/src/network/MicroservicesManager.cpp index 2a7351f8..0fb7c73e 100644 --- a/ai/core/server/src/network/MicroservicesManager.cpp +++ b/ai/core/server/src/network/MicroservicesManager.cpp @@ -349,6 +349,88 @@ void talkup_network::MicroservicesManager::send_to_sts_microservice( } } +bool talkup_network::MicroservicesManager::send_simulation_context_to_sts( + const std::string &interview_id, + const nlohmann::json &context_data) +{ + if (interview_id.empty()) { + std::cerr << "[MicroservicesManager] simulation_context: missing interview_id" << std::endl; + return false; + } + + try { + std::shared_ptr> ws; + std::mutex *io_mutex = nullptr; + + { + std::lock_guard lock(__ws_mutex); + auto it = __ws_connections.find("sts"); + if (it == __ws_connections.end() || !it->second.is_connected || + !it->second.ws || !it->second.ws->is_open()) { + if (!reconnect_service_connection("sts")) { + std::cerr << "[MicroservicesManager] STS connection not available for simulation_context" << std::endl; + return false; + } + it = __ws_connections.find("sts"); + } + if (it == __ws_connections.end() || !it->second.ws) { + return false; + } + ws = it->second.ws; + io_mutex = &it->second.io_mutex; + } + + std::unique_lock io_lock(*io_mutex); + nlohmann::json payload = { + {"services", {"STS"}}, + {"type", "simulation_context"}, + {"interview_id", interview_id}, + {"timestamp", std::time(nullptr)}, + {"data", context_data}, + }; + ws->write(boost::asio::buffer(payload.dump())); + std::cout << "[MicroservicesManager] Sent simulation_context for interview_id=" + << interview_id << std::endl; + + const int timeout_ms = 15000; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + + while (std::chrono::steady_clock::now() < deadline) { + const int remaining_ms = static_cast(std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()).count()); + if (remaining_ms <= 0) + break; + + nlohmann::json msg_json; + if (!read_sts_json_message(*ws, msg_json, std::min(remaining_ms, 5000))) + continue; + + const std::string msg_type = msg_json.value("type", ""); + if (msg_type == "pong") + continue; + + if (msg_type == "simulation_context_ack") { + const std::string ack_id = msg_json.value("interview_id", ""); + if (ack_id.empty() || ack_id == interview_id) + return true; + } + + if (msg_type == "error") { + std::cerr << "[MicroservicesManager] STS simulation_context error: " + << msg_json.dump() << std::endl; + return false; + } + } + + std::cerr << "[MicroservicesManager] simulation_context ack timeout for " + << interview_id << std::endl; + return false; + } catch (const std::exception &e) { + std::cerr << "[MicroservicesManager] simulation_context exception: " << e.what() << std::endl; + return false; + } +} + void talkup_network::MicroservicesManager::process_sts_job(const nlohmann::json &data, ResponseCallback callback) { try { diff --git a/ai/core/server/src/network/WebsocketManager.cpp b/ai/core/server/src/network/WebsocketManager.cpp index 2eab9338..7b04c8da 100644 --- a/ai/core/server/src/network/WebsocketManager.cpp +++ b/ai/core/server/src/network/WebsocketManager.cpp @@ -30,6 +30,10 @@ talkup_network::WsManager::WsManager() crow::websocket::connection& conn, std::shared_ptr microservices_manager) { handle_stream_chunk(json, conn, microservices_manager); }; + _type_handlers["simulation_context"] = [this](const nlohmann::json& json, + crow::websocket::connection& conn, std::shared_ptr microservices_manager) { + handle_simulation_context(json, conn, microservices_manager); + }; } void talkup_network::WsManager::connection_type_manager(nlohmann::json &json, crow::websocket::connection &conn, @@ -214,6 +218,76 @@ void talkup_network::WsManager::handle_stream_chunk(const nlohmann::json& json, } } +void talkup_network::WsManager::handle_simulation_context(const nlohmann::json& json, + crow::websocket::connection& conn, std::shared_ptr microservices_manager) +{ + const std::string stream_id = json.value("stream_id", ""); + const std::string key = json.value("key", ""); + const int64_t timestamp = json.value("timestamp", static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count())); + + if (stream_id.empty()) { + conn.send_text(set_respond_json_format({ + .type = "error", + .key = key, + .stream_id = "", + .format = "text", + .timestamp = timestamp, + .data = "simulation_context requires stream_id" + }).dump()); + return; + } + + if (!json.contains("data") || !json["data"].is_object()) { + conn.send_text(set_respond_json_format({ + .type = "error", + .key = key, + .stream_id = stream_id, + .format = "text", + .timestamp = timestamp, + .data = "simulation_context requires data object" + }).dump()); + return; + } + + if (!microservices_manager) { + conn.send_text(set_respond_json_format({ + .type = "error", + .key = key, + .stream_id = stream_id, + .format = "text", + .timestamp = timestamp, + .data = "microservices manager unavailable" + }).dump()); + return; + } + + const bool ok = MicroservicesManager::send_simulation_context_to_sts( + stream_id, json["data"]); + + if (!ok) { + conn.send_text(set_respond_json_format({ + .type = "error", + .key = key, + .stream_id = stream_id, + .format = "text", + .timestamp = timestamp, + .data = "failed to register simulation context on STS" + }).dump()); + return; + } + + conn.send_text(set_respond_json_format({ + .type = "simulation_context_ack", + .key = key, + .stream_id = stream_id, + .format = "text", + .timestamp = timestamp, + .data = "simulation context registered" + }).dump()); +} + nlohmann::json talkup_network::WsManager::set_respond_json_format(const WebSocketConnectionInfo& info) const { nlohmann::json json; diff --git a/ai/microservices/speech-to-speech/engine/pipeline.py b/ai/microservices/speech-to-speech/engine/pipeline.py index 9e111f1e..fcf3401e 100644 --- a/ai/microservices/speech-to-speech/engine/pipeline.py +++ b/ai/microservices/speech-to-speech/engine/pipeline.py @@ -13,11 +13,8 @@ from dataclasses import dataclass from .audio_decode import decode_audio_to_float32 from .models import STSModels, generate_ai_response, synthesize_tts_chunks -from .session_context import ( - append_session_history, - build_messages_from_context, - fetch_session_context, -) +from .simulation_brief import build_messages_for_turn +from .session_context import append_session_history @dataclass class STSResult: @@ -49,10 +46,9 @@ def process_sts_request( if not user_text or len(user_text) < 2: return STSResult(transcription="", ai_response="", audio_chunks=[]) - session = fetch_session_context(interview_id) if interview_id else None - messages = build_messages_from_context( + messages = build_messages_for_turn( models.settings.system_prompt, - session, + interview_id, user_text, ) diff --git a/ai/microservices/speech-to-speech/engine/session_context.py b/ai/microservices/speech-to-speech/engine/session_context.py index f449070f..96ef8ab7 100644 --- a/ai/microservices/speech-to-speech/engine/session_context.py +++ b/ai/microservices/speech-to-speech/engine/session_context.py @@ -1,7 +1,7 @@ ## ## Talkup Project, 2026 ## TalkUp.AI -## Fetches per-interview LLM context from NestJS (Scenario A). +## Session context helpers (NestJS fallback + history sync). ## from __future__ import annotations @@ -14,6 +14,7 @@ from .notifications import Notifications from .enumMcs import EnumMcs +from .simulation_brief import SimulationBriefStore NOTIFIER = Notifications() @@ -28,20 +29,11 @@ def _internal_key() -> str: def fetch_session_context(interview_id: str) -> dict[str, Any] | None: - """ - Loads system prompt and conversation history for one interview. - Returns None if unavailable (caller should fall back to global prompt). - """ if not interview_id or interview_id == "unknown": return None key = _internal_key() if not key: - NOTIFIER.send_notification( - EnumMcs.MicroservicesNames.STS, - 1, - "SIM_INTERNAL_API_KEY not set; using default system prompt", - ) return None url = f"{_backend_base()}/ai/internal/sessions/{interview_id}/context" @@ -78,6 +70,8 @@ def append_session_history( if not interview_id or interview_id == "unknown": return + SimulationBriefStore.append_turn(interview_id, user_text, assistant_text) + key = _internal_key() if not key: return @@ -107,17 +101,11 @@ def append_session_history( ) -def build_messages_from_context( +def build_messages_from_nest_session( default_system_prompt: str, - session: dict[str, Any] | None, + session: dict[str, Any], user_text: str, ) -> list[dict[str, str]]: - if not session: - return [ - {"role": "system", "content": default_system_prompt}, - {"role": "user", "content": user_text}, - ] - system_prompt = session.get("systemPrompt") or default_system_prompt history = session.get("history") or [] diff --git a/ai/microservices/speech-to-speech/engine/simulation_brief.py b/ai/microservices/speech-to-speech/engine/simulation_brief.py new file mode 100644 index 00000000..1ce19ddc --- /dev/null +++ b/ai/microservices/speech-to-speech/engine/simulation_brief.py @@ -0,0 +1,196 @@ +## +## Talkup Project, 2026 +## TalkUp.AI +## Per-session simulation brief (company, job offer) for LLM system prompt. +## + +from __future__ import annotations + +from dataclasses import dataclass, field +from threading import Lock +from typing import Any + +from .enumMcs import EnumMcs +from .notifications import Notifications + +NOTIFIER = Notifications() + +BASE_RECRUITER_PERSONA = ( + "Tu es Sophie Martin, recruteuse senior IT chez une ESN francaise. " + "Tu es chaleureuse, professionnelle, patiente et humaine. " + "Tu parles de facon naturelle comme dans une vraie conversation. " + "Tu dois repondre uniquement a la derniere prise de parole du candidat, " + "en une seule reponse courte et naturelle. " + "N'ecris jamais un dialogue multi-tours, n'imite jamais des balises comme system: ou user:, " + "et ne recopie jamais l'historique de conversation." +) + + +@dataclass +class SimulationBrief: + company_name: str | None = None + company_description: str | None = None + job_title: str | None = None + job_description: str | None = None + job_requirements: str | None = None + additional_info: str | None = None + language: str | None = None + interview_type: str | None = None + + @classmethod + def from_payload(cls, data: dict[str, Any]) -> SimulationBrief: + company = data.get("company") if isinstance(data.get("company"), dict) else {} + job_offer = data.get("jobOffer") if isinstance(data.get("jobOffer"), dict) else {} + + legacy_context = data.get("jobContext") + additional = data.get("additionalInfo") + if isinstance(legacy_context, str) and legacy_context.strip(): + additional = ( + f"{additional}\n{legacy_context}".strip() + if isinstance(additional, str) and additional.strip() + else legacy_context.strip() + ) + + return cls( + company_name=_optional_str(company.get("name")), + company_description=_optional_str(company.get("description")), + job_title=_optional_str(job_offer.get("title")), + job_description=_optional_str(job_offer.get("description")), + job_requirements=_optional_str(job_offer.get("requirements")), + additional_info=_optional_str(additional), + language=_optional_str(data.get("language")), + interview_type=_optional_str(data.get("interviewType") or data.get("type")), + ) + + def is_empty(self) -> bool: + return not any( + [ + self.company_name, + self.company_description, + self.job_title, + self.job_description, + self.job_requirements, + self.additional_info, + ], + ) + + +@dataclass +class _SessionState: + brief: SimulationBrief + history: list[dict[str, str]] = field(default_factory=list) + + +class SimulationBriefStore: + """In-memory session store (Scenario A — IA stack, one process STS).""" + + _lock = Lock() + _sessions: dict[str, _SessionState] = {} + + @classmethod + def register(cls, interview_id: str, brief: SimulationBrief) -> None: + with cls._lock: + existing = cls._sessions.get(interview_id) + history = existing.history if existing else [] + cls._sessions[interview_id] = _SessionState(brief=brief, history=history) + NOTIFIER.send_notification( + EnumMcs.MicroservicesNames.STS, + 0, + f"Simulation brief registered for interview {interview_id}", + ) + + @classmethod + def get(cls, interview_id: str) -> _SessionState | None: + with cls._lock: + return cls._sessions.get(interview_id) + + @classmethod + def append_turn(cls, interview_id: str, user_text: str, assistant_text: str) -> None: + with cls._lock: + state = cls._sessions.get(interview_id) + if state is None: + return + state.history.append({"role": "user", "content": user_text}) + state.history.append({"role": "assistant", "content": assistant_text}) + if len(state.history) > 60: + state.history = state.history[-60:] + + @classmethod + def clear(cls, interview_id: str) -> None: + with cls._lock: + cls._sessions.pop(interview_id, None) + + +def _optional_str(value: Any) -> str | None: + if not isinstance(value, str): + return None + stripped = value.strip() + return stripped if stripped else None + + +def build_system_prompt_from_brief(brief: SimulationBrief, fallback: str) -> str: + if brief.is_empty() and not brief.language and not brief.interview_type: + return fallback + + sections: list[str] = [BASE_RECRUITER_PERSONA] + + if brief.language: + sections.append(f"\nLangue de l'entretien: {brief.language}.") + if brief.interview_type: + sections.append(f"Type de simulation: {brief.interview_type}.") + + if brief.company_name or brief.company_description: + sections.append("\n## Entreprise") + if brief.company_name: + sections.append(f"Nom: {brief.company_name}") + if brief.company_description: + sections.append(brief.company_description) + + if brief.job_title or brief.job_description or brief.job_requirements: + sections.append("\n## Offre d'emploi") + if brief.job_title: + sections.append(f"Intitule: {brief.job_title}") + if brief.job_description: + sections.append(brief.job_description) + if brief.job_requirements: + sections.append(f"Competences / exigences: {brief.job_requirements}") + + if brief.additional_info: + sections.append(f"\n## Informations complementaires\n{brief.additional_info}") + + sections.append( + "\nConsigne: utilise ce contexte pour poser des questions pertinentes et realistes. " + "Ne recite pas l'integralite du CV ou de l'offre d'un seul coup." + ) + + return "\n".join(sections) + + +def build_messages_for_turn( + default_system_prompt: str, + interview_id: str | None, + user_text: str, +) -> list[dict[str, str]]: + from .session_context import build_messages_from_nest_session, fetch_session_context + + if interview_id: + state = SimulationBriefStore.get(interview_id) + if state is not None: + system_prompt = build_system_prompt_from_brief(state.brief, default_system_prompt) + messages: list[dict[str, str]] = [{"role": "system", "content": system_prompt}] + for turn in state.history: + role = turn.get("role") + content = turn.get("content") + if role in ("user", "assistant") and isinstance(content, str) and content.strip(): + messages.append({"role": role, "content": content}) + messages.append({"role": "user", "content": user_text}) + return messages + + nest_session = fetch_session_context(interview_id) + if nest_session is not None: + return build_messages_from_nest_session(default_system_prompt, nest_session, user_text) + + return [ + {"role": "system", "content": default_system_prompt}, + {"role": "user", "content": user_text}, + ] diff --git a/ai/microservices/speech-to-speech/engine/stsProcess.py b/ai/microservices/speech-to-speech/engine/stsProcess.py index ecec02b7..3919424d 100644 --- a/ai/microservices/speech-to-speech/engine/stsProcess.py +++ b/ai/microservices/speech-to-speech/engine/stsProcess.py @@ -20,6 +20,7 @@ from .notifications import Notifications from .queueService import StsQueueService from .settings import load_settings +from .simulation_brief import SimulationBrief, SimulationBriefStore app = FastAPI(title="TalkUp STS Service") @@ -37,6 +38,48 @@ async def _ws_send_json(websocket: WebSocket, send_lock: asyncio.Lock, payload: await websocket.send_text(json.dumps(payload)) +async def _handle_simulation_context( + websocket: WebSocket, + send_lock: asyncio.Lock, + payload: dict, +) -> None: + interview_id = payload.get("interview_id") or payload.get("stream_id") + context_data = payload.get("data") + + if not isinstance(interview_id, str) or not interview_id.strip(): + await _ws_send_json( + websocket, + send_lock, + {"type": "error", "text": "simulation_context requires interview_id"}, + ) + return + + if not isinstance(context_data, dict): + await _ws_send_json( + websocket, + send_lock, + { + "type": "error", + "text": "simulation_context requires data object", + "interview_id": interview_id, + }, + ) + return + + brief = SimulationBrief.from_payload(context_data) + SimulationBriefStore.register(interview_id.strip(), brief) + + await _ws_send_json( + websocket, + send_lock, + { + "type": "simulation_context_ack", + "interview_id": interview_id.strip(), + "status": "registered", + }, + ) + + async def _process_stream_and_reply( websocket: WebSocket, send_lock: asyncio.Lock, @@ -185,6 +228,10 @@ async def audio_worker() -> None: ) continue + if payload.get("type") == "simulation_context": + await _handle_simulation_context(websocket, send_lock, payload) + continue + if payload.get("type") == "stream_chunk": request_id = payload.get("request_id") interview_id = payload.get("interview_id") From 5518c7108d1fe24583d29d4823ef641f3d970297 Mon Sep 17 00:00:00 2001 From: Andriamanampisoa Date: Sat, 20 Jun 2026 17:46:09 +0900 Subject: [PATCH 7/8] fix: implement Scenario A multi-user queue and LLM session context --- server/src/modules/ai/ai.service.spec.ts | 29 ++++++++++++ server/src/modules/ai/ai.service.ts | 45 ++++++++++++++----- .../simulation-promotion.service.ts | 18 +++++++- 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/server/src/modules/ai/ai.service.spec.ts b/server/src/modules/ai/ai.service.spec.ts index 7c9c0e79..01458ce1 100644 --- a/server/src/modules/ai/ai.service.spec.ts +++ b/server/src/modules/ai/ai.service.spec.ts @@ -75,6 +75,7 @@ describe("AiService", () => { getReadyPayload: jest.fn(), promoteNextFromQueue: jest.fn(), clearReady: jest.fn(), + rollbackPreparedSession: jest.fn(), }; mockContext = { @@ -151,6 +152,34 @@ describe("AiService", () => { expect(res.queuePosition).toBe(1); }); + it("releases slot and marks interview expired when prepareReadySession fails", async () => { + mockAiInterviewRepo.findOne.mockResolvedValueOnce(null); + mockAiInterviewRepo.save.mockResolvedValueOnce({ + interview_id: "new-id", + }); + mockPromotion.prepareReadySession.mockRejectedValueOnce( + new Error("AI init failed"), + ); + mockAiInterviewRepo.update.mockResolvedValueOnce(undefined); + + await expect( + service.createInterview( + { type: "Technical", language: "French" } as any, + "user-1", + ), + ).rejects.toThrow(InternalServerErrorException); + + expect(mockPromotion.rollbackPreparedSession).toHaveBeenCalledWith( + "new-id", + "user-1", + ); + expect(mockAiInterviewRepo.update).toHaveBeenCalledWith( + { interview_id: "new-id" }, + { status: AiInterviewStatus.EXPIRED }, + ); + expect(mockPromotion.promoteNextFromQueue).toHaveBeenCalled(); + }); + it("throws ConflictException when interview already exists", async () => { mockAiInterviewRepo.findOne.mockResolvedValueOnce({ interview_id: "exists", diff --git a/server/src/modules/ai/ai.service.ts b/server/src/modules/ai/ai.service.ts index 7eef15da..cab72cfb 100644 --- a/server/src/modules/ai/ai.service.ts +++ b/server/src/modules/ai/ai.service.ts @@ -94,17 +94,37 @@ export class AiService { ); if (acquired.acquired) { - const { entrypoint } = await this.promotion.prepareReadySession( - newInterview, - dto, - ); - - return { - interviewID: newInterview.interview_id, - status: "ready", - entrypoint, - queuePosition: 0, - }; + try { + const { entrypoint } = await this.promotion.prepareReadySession( + newInterview, + dto, + ); + + return { + interviewID: newInterview.interview_id, + status: "ready", + entrypoint, + queuePosition: 0, + }; + } catch (prepError) { + await this.promotion.rollbackPreparedSession( + newInterview.interview_id, + userId, + ); + await this.aiInterviewRepository.update( + { interview_id: newInterview.interview_id }, + { status: AiInterviewStatus.EXPIRED }, + ); + await this.promotion.promoteNextFromQueue(); + + this.logger.error( + `Failed to prepare simulation session for interview ${newInterview.interview_id}: ${(prepError as Error).message}`, + (prepError as Error).stack, + ); + throw new InternalServerErrorException( + "Internal server error while preparing simulation session.", + ); + } } const enqueued = await this.capacity.enqueue(newInterview.interview_id); @@ -132,7 +152,8 @@ export class AiService { } catch (error) { if ( error instanceof ConflictException || - error instanceof ServiceUnavailableException + error instanceof ServiceUnavailableException || + error instanceof InternalServerErrorException ) { throw error; } diff --git a/server/src/modules/simulation/simulation-promotion.service.ts b/server/src/modules/simulation/simulation-promotion.service.ts index 8c6effb2..c2ea9e77 100644 --- a/server/src/modules/simulation/simulation-promotion.service.ts +++ b/server/src/modules/simulation/simulation-promotion.service.ts @@ -121,7 +121,10 @@ export class SimulationPromotionService { this.logger.error( `Failed to promote interview ${nextId}: ${(err as Error).message}`, ); - await this.capacity.releaseSlot(nextId, interview.user_id as string); + await this.rollbackPreparedSession( + nextId, + interview.user_id as string, + ); await this.interviewRepo.update( { interview_id: nextId }, { status: AiInterviewStatus.EXPIRED }, @@ -135,6 +138,19 @@ export class SimulationPromotionService { await this.redis.del(this.readyKey(interviewId)); } + /** + * Releases Redis capacity and partial session artifacts when prepareReadySession fails + * after tryAcquireSlot succeeded. + */ + async rollbackPreparedSession( + interviewId: string, + userId: string, + ): Promise { + await this.capacity.releaseSlot(interviewId, userId); + await this.context.deleteContext(interviewId); + await this.clearReady(interviewId); + } + private async callAiInitialization(): Promise { const commKey = process.env.AI_COMMUNICATION_KEY ?? "c7yPY8u644OE"; From 3087866145a55d4f4e52d770761367570eafc4d2 Mon Sep 17 00:00:00 2001 From: Andriamanampisoa Date: Wed, 24 Jun 2026 19:38:24 +0900 Subject: [PATCH 8/8] refactor: fix issues for the pull request --- .gitignore | 13 ++ .../inc/network/MicroservicesManager.hpp | 17 ++- .../src/network/MicroservicesManager.cpp | 135 +++++++++++++++--- .../speech-to-speech/engine/stsProcess.py | 19 +-- server/src/entities/aiInterview.entity.ts | 3 + server/src/modules/ai/ai.controller.ts | 14 ++ server/src/modules/ai/ai.service.spec.ts | 26 ++++ server/src/modules/ai/ai.service.ts | 20 +++ .../simulation/simulation-context.service.ts | 8 +- .../simulation-promotion.service.spec.ts | 84 +++++++++++ .../simulation-promotion.service.ts | 15 +- .../modules/simulation/simulation.config.ts | 5 + web/src/hooks/simulation/useAudioStreaming.ts | 5 +- .../hooks/simulation/useInterviewSession.ts | 21 +++ .../simulation/useSimulationWebSocket.ts | 8 +- web/src/routes/simulations.tsx | 50 +++---- web/src/services/ai/http.ts | 4 + 17 files changed, 379 insertions(+), 68 deletions(-) create mode 100644 server/src/modules/simulation/simulation-promotion.service.spec.ts diff --git a/.gitignore b/.gitignore index f26efcd4..165166c5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules CLAUDE.md .vercel .env +key.pem +*.pem .vscode/ .github/agents @@ -11,3 +13,14 @@ recordings/ # Local design specs and implementation plans (not pushed) docs/ + +# AI build artifacts and local dev files +ai/core/server/build/ +ai/**/.venv/ +ai/**/venv/ +ai/llm-core/models/ +ai/llm-core/sup-it-lora-v3/ +ai/llm-core/unsloth_compiled_cache/ +ai/microservices/**/llm/ +*.wav +*.mp4 diff --git a/ai/core/server/inc/network/MicroservicesManager.hpp b/ai/core/server/inc/network/MicroservicesManager.hpp index c5fa098c..718b97ac 100644 --- a/ai/core/server/inc/network/MicroservicesManager.hpp +++ b/ai/core/server/inc/network/MicroservicesManager.hpp @@ -27,7 +27,8 @@ #include #include #include -#include +#include +#include #include "ExceptionManager.hpp" @@ -116,9 +117,18 @@ namespace talkup_network { protected: private: struct WebSocketConnection { + enum class StsJobKind { + StreamChunk, + SimulationContext, + }; + struct StsJob { + StsJobKind kind = StsJobKind::StreamChunk; nlohmann::json data; ResponseCallback callback; + std::string interview_id; + nlohmann::json context_data; + std::shared_ptr> context_promise; }; std::shared_ptr io_context; @@ -176,5 +186,10 @@ namespace talkup_network { * @param data The JSON data containing the job information. */ static void process_sts_job(const nlohmann::json &data, ResponseCallback callback); + + static void process_simulation_context_job( + const std::string &interview_id, + const nlohmann::json &context_data, + const std::shared_ptr> &result_promise); }; } diff --git a/ai/core/server/src/network/MicroservicesManager.cpp b/ai/core/server/src/network/MicroservicesManager.cpp index 0fb7c73e..c2766a74 100644 --- a/ai/core/server/src/network/MicroservicesManager.cpp +++ b/ai/core/server/src/network/MicroservicesManager.cpp @@ -10,6 +10,7 @@ #include #include #include +#include namespace { std::atomic g_sts_request_id{0}; @@ -139,7 +140,14 @@ void talkup_network::MicroservicesManager::create_service_worker( conn_ptr->job_queue.pop(); } try { - process_sts_job(job.data, std::move(job.callback)); + if (job.kind == WebSocketConnection::StsJobKind::SimulationContext) { + process_simulation_context_job( + job.interview_id, + job.context_data, + job.context_promise); + } else { + process_sts_job(job.data, std::move(job.callback)); + } } catch (const std::exception &e) { std::cerr << "[MicroservicesManager] Worker error for service " << service_name << ": " << e.what() << std::endl; @@ -336,7 +344,11 @@ void talkup_network::MicroservicesManager::send_to_sts_microservice( } else { { std::lock_guard qlock(it->second.queue_mutex); - it->second.job_queue.push({data, std::move(callback)}); + WebSocketConnection::StsJob job; + job.kind = WebSocketConnection::StsJobKind::StreamChunk; + job.data = data; + job.callback = std::move(callback); + it->second.job_queue.push(std::move(job)); } it->second.queue_cv.notify_one(); enqueue_ok = true; @@ -358,6 +370,58 @@ bool talkup_network::MicroservicesManager::send_simulation_context_to_sts( return false; } + auto result_promise = std::make_shared>(); + std::future result_future = result_promise->get_future(); + bool enqueue_ok = false; + + { + std::lock_guard lock(__ws_mutex); + auto it = __ws_connections.find("sts"); + if (it == __ws_connections.end() || !it->second.is_connected) { + std::cerr << "[MicroservicesManager] STS connection not available for simulation_context" << std::endl; + } else { + WebSocketConnection::StsJob job; + job.kind = WebSocketConnection::StsJobKind::SimulationContext; + job.interview_id = interview_id; + job.context_data = context_data; + job.context_promise = result_promise; + + { + std::lock_guard qlock(it->second.queue_mutex); + it->second.job_queue.push(std::move(job)); + } + it->second.queue_cv.notify_one(); + enqueue_ok = true; + std::cout << "[MicroservicesManager] Enqueued simulation_context for interview_id=" + << interview_id << std::endl; + } + } + + if (!enqueue_ok) { + result_promise->set_value(false); + return false; + } + + constexpr int timeout_ms = 15000; + if (result_future.wait_for(std::chrono::milliseconds(timeout_ms)) != std::future_status::ready) { + std::cerr << "[MicroservicesManager] simulation_context ack timeout for " + << interview_id << std::endl; + return false; + } + + return result_future.get(); +} + +void talkup_network::MicroservicesManager::process_simulation_context_job( + const std::string &interview_id, + const nlohmann::json &context_data, + const std::shared_ptr> &result_promise) +{ + auto complete = [&](bool ok) { + if (result_promise) + result_promise->set_value(ok); + }; + try { std::shared_ptr> ws; std::mutex *io_mutex = nullptr; @@ -369,28 +433,32 @@ bool talkup_network::MicroservicesManager::send_simulation_context_to_sts( !it->second.ws || !it->second.ws->is_open()) { if (!reconnect_service_connection("sts")) { std::cerr << "[MicroservicesManager] STS connection not available for simulation_context" << std::endl; - return false; + complete(false); + return; } it = __ws_connections.find("sts"); } if (it == __ws_connections.end() || !it->second.ws) { - return false; + complete(false); + return; } ws = it->second.ws; io_mutex = &it->second.io_mutex; } std::unique_lock io_lock(*io_mutex); + const uint64_t request_id = ++g_sts_request_id; nlohmann::json payload = { {"services", {"STS"}}, {"type", "simulation_context"}, + {"request_id", request_id}, {"interview_id", interview_id}, {"timestamp", std::time(nullptr)}, {"data", context_data}, }; ws->write(boost::asio::buffer(payload.dump())); - std::cout << "[MicroservicesManager] Sent simulation_context for interview_id=" - << interview_id << std::endl; + std::cout << "[MicroservicesManager] Sent simulation_context request_id=" + << request_id << " interview_id=" << interview_id << std::endl; const int timeout_ms = 15000; const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); @@ -409,25 +477,39 @@ bool talkup_network::MicroservicesManager::send_simulation_context_to_sts( if (msg_type == "pong") continue; + if (msg_json.contains("request_id")) { + const uint64_t response_id = msg_json.value("request_id", static_cast(0)); + if (response_id != request_id) + continue; + } else if (msg_type != "simulation_context_ack") { + continue; + } + if (msg_type == "simulation_context_ack") { const std::string ack_id = msg_json.value("interview_id", ""); - if (ack_id.empty() || ack_id == interview_id) - return true; + if (!ack_id.empty() && ack_id != interview_id) { + std::cerr << "[MicroservicesManager] simulation_context_ack interview mismatch: " + << ack_id << " expected " << interview_id << std::endl; + continue; + } + complete(true); + return; } if (msg_type == "error") { std::cerr << "[MicroservicesManager] STS simulation_context error: " << msg_json.dump() << std::endl; - return false; + complete(false); + return; } } - std::cerr << "[MicroservicesManager] simulation_context ack timeout for " + std::cerr << "[MicroservicesManager] simulation_context read timeout for " << interview_id << std::endl; - return false; + complete(false); } catch (const std::exception &e) { std::cerr << "[MicroservicesManager] simulation_context exception: " << e.what() << std::endl; - return false; + complete(false); } } @@ -495,17 +577,21 @@ void talkup_network::MicroservicesManager::process_sts_job(const nlohmann::json continue; const std::string msg_type = msg_json.value("type", ""); - if (msg_type == "pong") { + if (msg_type == "pong" || msg_type == "simulation_context_ack") { continue; } - if (msg_json.contains("request_id")) { - const uint64_t response_id = msg_json.value("request_id", static_cast(0)); - if (response_id != request_id) { - std::cout << "[MicroservicesManager] Ignoring STS message for request_id=" - << response_id << " (expected " << request_id << ")" << std::endl; - continue; - } + if (!msg_json.contains("request_id")) { + std::cout << "[MicroservicesManager] Ignoring STS message without request_id type=" + << msg_type << std::endl; + continue; + } + + const uint64_t response_id = msg_json.value("request_id", static_cast(0)); + if (response_id != request_id) { + std::cout << "[MicroservicesManager] Ignoring STS message for request_id=" + << response_id << " (expected " << request_id << ")" << std::endl; + continue; } std::cout << "[MicroservicesManager] Received STS message type=" << msg_type @@ -573,7 +659,14 @@ void talkup_network::MicroservicesManager::start_service_worker(const std::strin conn_ptr->job_queue.pop(); } try { - process_sts_job(job.data, std::move(job.callback)); + if (job.kind == WebSocketConnection::StsJobKind::SimulationContext) { + process_simulation_context_job( + job.interview_id, + job.context_data, + job.context_promise); + } else { + process_sts_job(job.data, std::move(job.callback)); + } } catch (const std::exception &e) { std::cerr << "[MicroservicesManager] Worker error for service " << service_name << ": " << e.what() << std::endl; } diff --git a/ai/microservices/speech-to-speech/engine/stsProcess.py b/ai/microservices/speech-to-speech/engine/stsProcess.py index 3919424d..277a2dd0 100644 --- a/ai/microservices/speech-to-speech/engine/stsProcess.py +++ b/ai/microservices/speech-to-speech/engine/stsProcess.py @@ -69,15 +69,16 @@ async def _handle_simulation_context( brief = SimulationBrief.from_payload(context_data) SimulationBriefStore.register(interview_id.strip(), brief) - await _ws_send_json( - websocket, - send_lock, - { - "type": "simulation_context_ack", - "interview_id": interview_id.strip(), - "status": "registered", - }, - ) + ack: dict = { + "type": "simulation_context_ack", + "interview_id": interview_id.strip(), + "status": "registered", + } + request_id = payload.get("request_id") + if request_id is not None: + ack["request_id"] = request_id + + await _ws_send_json(websocket, send_lock, ack) async def _process_stream_and_reply( diff --git a/server/src/entities/aiInterview.entity.ts b/server/src/entities/aiInterview.entity.ts index 35744ba0..855ab199 100644 --- a/server/src/entities/aiInterview.entity.ts +++ b/server/src/entities/aiInterview.entity.ts @@ -44,6 +44,9 @@ export class ai_interview { @Column({ type: "varchar", length: 2048, nullable: true, name: "video_link" }) video_link: string; + @Column({ type: "text", nullable: true, name: "job_context" }) + job_context: string | null; + @CreateDateColumn({ type: "timestamp with time zone", default: () => "CURRENT_TIMESTAMP", diff --git a/server/src/modules/ai/ai.controller.ts b/server/src/modules/ai/ai.controller.ts index 11806d03..815537d0 100644 --- a/server/src/modules/ai/ai.controller.ts +++ b/server/src/modules/ai/ai.controller.ts @@ -116,6 +116,20 @@ export class AiController { return this.aiService.cancelInterview(id, userId); } + @ApiOkResponse({ + description: "Refresh Redis slot heartbeat for an active simulation.", + }) + @ApiConflictResponse({ + description: "Interview is not in an active simulation state.", + }) + @Post("interviews/:id/heartbeat") + async heartbeatSimulation( + @Param("id") id: string, + @UserId() userId: string, + ) { + return this.aiService.heartbeatSimulation(id, userId); + } + @ApiOkResponse({ description: "The AI interview has been successfully edited.", type: PutAiInterviewDto, diff --git a/server/src/modules/ai/ai.service.spec.ts b/server/src/modules/ai/ai.service.spec.ts index 01458ce1..4451d6a5 100644 --- a/server/src/modules/ai/ai.service.spec.ts +++ b/server/src/modules/ai/ai.service.spec.ts @@ -152,6 +152,32 @@ describe("AiService", () => { expect(res.queuePosition).toBe(1); }); + it("persists jobContext when interview is queued", async () => { + mockAiInterviewRepo.findOne.mockResolvedValueOnce(null); + mockCapacity.tryAcquireSlot.mockResolvedValueOnce({ + acquired: false, + reason: "capacity", + }); + mockAiInterviewRepo.save.mockResolvedValueOnce({ + interview_id: "new-id", + }); + + await service.createInterview( + { + type: "Technical", + language: "French", + jobContext: "Backend role at Acme", + } as any, + "user-1", + ); + + expect(mockAiInterviewRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + job_context: "Backend role at Acme", + }), + ); + }); + it("releases slot and marks interview expired when prepareReadySession fails", async () => { mockAiInterviewRepo.findOne.mockResolvedValueOnce(null); mockAiInterviewRepo.save.mockResolvedValueOnce({ diff --git a/server/src/modules/ai/ai.service.ts b/server/src/modules/ai/ai.service.ts index cab72cfb..9f8338cc 100644 --- a/server/src/modules/ai/ai.service.ts +++ b/server/src/modules/ai/ai.service.ts @@ -83,6 +83,7 @@ export class AiService { user_id: userId, type: dto.type, language: dto.language, + job_context: dto.jobContext?.trim() || null, status: AiInterviewStatus.QUEUED, }); @@ -222,6 +223,25 @@ export class AiService { }; } + async heartbeatSimulation( + interviewId: string, + userId: string, + ): Promise<{ ok: true }> { + const interview = await this.getInterviewById(interviewId, userId); + + if ( + interview.status !== AiInterviewStatus.ASKED && + interview.status !== AiInterviewStatus.IN_PROGRESS + ) { + throw new ConflictException( + "Heartbeat is only allowed for active simulation sessions.", + ); + } + + await this.capacity.touchHeartbeat(interviewId, userId); + return { ok: true }; + } + async cancelInterview(interviewId: string, userId: string): Promise { const interview = await this.getInterviewById(interviewId, userId); diff --git a/server/src/modules/simulation/simulation-context.service.ts b/server/src/modules/simulation/simulation-context.service.ts index d3888638..f68d1d9d 100644 --- a/server/src/modules/simulation/simulation-context.service.ts +++ b/server/src/modules/simulation/simulation-context.service.ts @@ -7,6 +7,7 @@ import { REDIS_CLIENT } from "@common/redis/redis.constants"; import { CreateAiInterviewDto } from "../ai/dto/createAiInterview.dto"; import { loadSimulationConfig } from "./simulation.config"; import { SimRedisKeys } from "./simulation.redis-keys"; +import { SimulationCapacityService } from "./simulation-capacity.service"; export type SimulationChatTurn = { role: "user" | "assistant"; @@ -26,7 +27,10 @@ const BASE_RECRUITER_PERSONA = `Tu es Sophie Martin, recruteuse senior IT chez u export class SimulationContextService { private readonly logger = new Logger(SimulationContextService.name); - constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {} + constructor( + @Inject(REDIS_CLIENT) private readonly redis: Redis, + private readonly capacity: SimulationCapacityService, + ) {} buildSystemPrompt(dto: CreateAiInterviewDto): string { const language = dto.language?.trim() || "French"; @@ -99,6 +103,8 @@ export class SimulationContextService { "EX", contextTtlSec, ); + + await this.capacity.touchHeartbeat(interviewId, ctx.userId); } async deleteContext(interviewId: string): Promise { diff --git a/server/src/modules/simulation/simulation-promotion.service.spec.ts b/server/src/modules/simulation/simulation-promotion.service.spec.ts new file mode 100644 index 00000000..e9949601 --- /dev/null +++ b/server/src/modules/simulation/simulation-promotion.service.spec.ts @@ -0,0 +1,84 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { HttpService } from "@nestjs/axios"; + +import { REDIS_CLIENT } from "@common/redis/redis.constants"; +import { ai_interview } from "@entities/aiInterview.entity"; +import { AiInterviewStatus } from "@common/enums/AiInterviewStatus"; + +import { SimulationPromotionService } from "./simulation-promotion.service"; +import { SimulationCapacityService } from "./simulation-capacity.service"; +import { SimulationContextService } from "./simulation-context.service"; +import { SimulationWsTokenService } from "./simulation-ws-token.service"; + +describe("SimulationPromotionService", () => { + let service: SimulationPromotionService; + + const mockInterview = { + interview_id: "queued-1", + user_id: "user-1", + type: "technical", + language: "French", + job_context: "CV summary and job offer details", + status: AiInterviewStatus.QUEUED, + } as ai_interview; + + let mockCapacity: { + dequeueNext: jest.Mock; + tryAcquireSlot: jest.Mock; + enqueue: jest.Mock; + releaseSlot: jest.Mock; + }; + let mockContext: { deleteContext: jest.Mock }; + let prepareReadySessionSpy: jest.SpyInstance; + + beforeEach(async () => { + mockCapacity = { + dequeueNext: jest.fn().mockResolvedValue("queued-1"), + tryAcquireSlot: jest.fn().mockResolvedValue({ acquired: true }), + enqueue: jest.fn(), + releaseSlot: jest.fn(), + }; + mockContext = { deleteContext: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SimulationPromotionService, + { provide: REDIS_CLIENT, useValue: { set: jest.fn(), del: jest.fn() } }, + { provide: HttpService, useValue: { axiosRef: { post: jest.fn() } } }, + { provide: SimulationCapacityService, useValue: mockCapacity }, + { provide: SimulationContextService, useValue: mockContext }, + { + provide: SimulationWsTokenService, + useValue: { buildEntrypoint: jest.fn() }, + }, + { + provide: getRepositoryToken(ai_interview), + useValue: { + findOne: jest.fn().mockResolvedValue(mockInterview), + update: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(SimulationPromotionService); + prepareReadySessionSpy = jest + .spyOn(service, "prepareReadySession") + .mockResolvedValue({ entrypoint: "ws://test" }); + }); + + it("passes persisted jobContext when promoting a queued interview", async () => { + const promotedId = await service.promoteNextFromQueue(); + + expect(promotedId).toBe("queued-1"); + expect(prepareReadySessionSpy).toHaveBeenCalledWith( + mockInterview, + expect.objectContaining({ + type: "technical", + language: "French", + jobContext: "CV summary and job offer details", + }), + ); + }); +}); diff --git a/server/src/modules/simulation/simulation-promotion.service.ts b/server/src/modules/simulation/simulation-promotion.service.ts index c2ea9e77..22b6b379 100644 --- a/server/src/modules/simulation/simulation-promotion.service.ts +++ b/server/src/modules/simulation/simulation-promotion.service.ts @@ -20,6 +20,15 @@ import { SimulationWsTokenService } from "./simulation-ws-token.service"; import { SimRedisKeys } from "./simulation.redis-keys"; import { loadSimulationConfig } from "./simulation.config"; +function createDtoFromInterview(interview: ai_interview): CreateAiInterviewDto { + return { + type: interview.type, + language: interview.language, + jobContext: interview.job_context ?? undefined, + status: AiInterviewStatus.ASKED, + }; +} + export type ReadySessionPayload = { entrypoint: string; }; @@ -107,11 +116,7 @@ export class SimulationPromotionService { return null; } - const dto: CreateAiInterviewDto = { - type: interview.type, - language: interview.language, - status: AiInterviewStatus.ASKED, - }; + const dto = createDtoFromInterview(interview); try { await this.prepareReadySession(interview, dto); diff --git a/server/src/modules/simulation/simulation.config.ts b/server/src/modules/simulation/simulation.config.ts index e320c699..3c66058a 100644 --- a/server/src/modules/simulation/simulation.config.ts +++ b/server/src/modules/simulation/simulation.config.ts @@ -7,6 +7,7 @@ export type SimulationConfig = { estimatedTurnSec: number; historyMaxTurns: number; contextTtlSec: number; + heartbeatIntervalSec: number; }; export function loadSimulationConfig(): SimulationConfig { @@ -19,6 +20,10 @@ export function loadSimulationConfig(): SimulationConfig { estimatedTurnSec: parseInt(process.env.SIM_ESTIMATED_TURN_SEC ?? "90", 10), historyMaxTurns: parseInt(process.env.SIM_HISTORY_MAX_TURNS ?? "30", 10), contextTtlSec: parseInt(process.env.SIM_CONTEXT_TTL_SEC ?? "7200", 10), + heartbeatIntervalSec: parseInt( + process.env.SIM_HEARTBEAT_INTERVAL_SEC ?? "60", + 10, + ), }; } diff --git a/web/src/hooks/simulation/useAudioStreaming.ts b/web/src/hooks/simulation/useAudioStreaming.ts index 2e6be3ed..a81cbb5d 100644 --- a/web/src/hooks/simulation/useAudioStreaming.ts +++ b/web/src/hooks/simulation/useAudioStreaming.ts @@ -56,6 +56,7 @@ export function useAudioStreaming({ const mediaRecorderRef = useRef(null); const onAudioPacketRef = useRef(onAudioPacket); const interviewIDRef = useRef(interviewID); + interviewIDRef.current = interviewID; const vadIntervalRef = useRef | null>(null); const audioContextRef = useRef(null); const sourceRef = useRef(null); @@ -74,10 +75,6 @@ export function useAudioStreaming({ onAudioPacketRef.current = onAudioPacket; }, [onAudioPacket]); - useEffect(() => { - interviewIDRef.current = interviewID; - }, [interviewID]); - const getSupportedMimeType = useCallback((): string | null => { if (mimeType && MediaRecorder.isTypeSupported(mimeType)) { return mimeType; diff --git a/web/src/hooks/simulation/useInterviewSession.ts b/web/src/hooks/simulation/useInterviewSession.ts index 9c04a48b..81a9eb54 100644 --- a/web/src/hooks/simulation/useInterviewSession.ts +++ b/web/src/hooks/simulation/useInterviewSession.ts @@ -2,6 +2,7 @@ import { cancelInterview, createInterview, getInterviewSession, + heartbeatInterview, updateInterview, } from '@/services/ai/http'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -17,6 +18,7 @@ const WEBSOCKET_CLOSE_CODE_NORMAL = 1000; const STREAM_RESUME_DELAY_MS = 100; const QUEUE_POLL_INTERVAL_MS = 3000; const QUEUE_POLL_MAX_MS = 20 * 60 * 1000; +const HEARTBEAT_INTERVAL_MS = 60 * 1000; /** * Props for the useInterviewSession hook. @@ -124,6 +126,25 @@ export function useInterviewSession({ }; }, []); + useEffect(() => { + if (!isCallActive || !interviewID) { + return; + } + + const sendHeartbeat = () => { + heartbeatInterview(interviewID).catch((error) => { + console.warn('Simulation heartbeat failed:', error); + }); + }; + + sendHeartbeat(); + const intervalId = window.setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS); + + return () => { + window.clearInterval(intervalId); + }; + }, [isCallActive, interviewID]); + const handleStreamToggle = useCallback( async (streaming: boolean) => { if (processingRef.current) return; diff --git a/web/src/hooks/simulation/useSimulationWebSocket.ts b/web/src/hooks/simulation/useSimulationWebSocket.ts index afae6bd0..00a5efed 100644 --- a/web/src/hooks/simulation/useSimulationWebSocket.ts +++ b/web/src/hooks/simulation/useSimulationWebSocket.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import useWebSocket, { ReadyState } from 'react-use-websocket'; export interface WebSocketPacket { @@ -105,6 +105,8 @@ export function useSimulationWebSocket( } = props; const [socketUrl, setSocketUrl] = useState(null); + const interviewIDRef = useRef(interviewID); + interviewIDRef.current = interviewID; const { sendMessage, @@ -144,7 +146,7 @@ export function useSimulationWebSocket( const pingMessage: WebSocketPacket = { key: import.meta.env.VITE_WEBSOCKET_KEY, - stream_id: interviewID ?? 'unknown', + stream_id: interviewIDRef.current ?? 'unknown', format: '', data: '', type: 'ping', @@ -152,7 +154,7 @@ export function useSimulationWebSocket( }; sendJsonMessage(pingMessage); - }, [readyState, sendJsonMessage, interviewID]); + }, [readyState, sendJsonMessage]); return { isConnected: readyState === ReadyState.OPEN, diff --git a/web/src/routes/simulations.tsx b/web/src/routes/simulations.tsx index 07ccc6aa..b824f460 100644 --- a/web/src/routes/simulations.tsx +++ b/web/src/routes/simulations.tsx @@ -26,8 +26,31 @@ function Simulations() { const [mediaStream, setMediaStream] = useState(null); const [wsError, setWsError] = useState(null); const [connectionAttempts, setConnectionAttempts] = useState(0); - const [interviewID, setInterviewID] = useState(null); const videoStreamToggleRef = useRef<(() => void) | null>(null); + const connectRef = useRef<(url?: string) => void>(() => {}); + const disconnectRef = useRef<(code?: number, reason?: string) => void>( + () => {}, + ); + + const handleResumeStream = useCallback(() => { + if (videoStreamToggleRef.current) { + videoStreamToggleRef.current(); + } + }, []); + + const { + isCallActive, + isQueued, + queuePosition, + estimatedWaitSec, + inputUrl, + interviewID, + handleStreamToggle, + } = useInterviewSession({ + onConnect: (url) => connectRef.current(url), + onDisconnect: (code, reason) => disconnectRef.current(code, reason), + onResumeStream: handleResumeStream, + }); const { sendMessage, @@ -61,29 +84,8 @@ function Simulations() { }, }); - const handleResumeStream = useCallback(() => { - if (videoStreamToggleRef.current) { - videoStreamToggleRef.current(); - } - }, []); - - const { - isCallActive, - isQueued, - queuePosition, - estimatedWaitSec, - inputUrl, - interviewID: sessionInterviewID, - handleStreamToggle, - } = useInterviewSession({ - onConnect: connect, - onDisconnect: disconnect, - onResumeStream: handleResumeStream, - }); - - useEffect(() => { - setInterviewID(sessionInterviewID); - }, [sessionInterviewID]); + connectRef.current = connect; + disconnectRef.current = disconnect; const sendJsonMessageRef = useRef(sendJsonMessage); const readyStateRef = useRef(readyState); diff --git a/web/src/services/ai/http.ts b/web/src/services/ai/http.ts index 96fbab44..f5aef263 100644 --- a/web/src/services/ai/http.ts +++ b/web/src/services/ai/http.ts @@ -64,6 +64,10 @@ export const cancelInterview = async (interviewId: string): Promise => { await axiosInstance.post(`${API_ROUTES.ai}/interviews/${interviewId}/cancel`); }; +export const heartbeatInterview = async (interviewId: string): Promise => { + await axiosInstance.post(`${API_ROUTES.ai}/interviews/${interviewId}/heartbeat`); +}; + export const updateInterview = async ( interviewId: string, dto: UpdateAiInterviewDto,