SmartfolioBackend / src /modules /mock-interview /mock-interview.controller.ts
ibrahimgulbutt's picture
Restore AI: Groq retired the Llama models
2f666f7
Raw History Blame Contribute Delete
6.32 kB
import {
Controller,
Post,
Get,
Body,
UseGuards,
UseInterceptors,
UploadedFile,
Req,
Res,
HttpCode,
HttpStatus,
Param,
Logger,
BadRequestException,
ServiceUnavailableException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiOkResponse,
ApiUnauthorizedResponse,
ApiConsumes,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AuthenticatedRequest } from '../../common/interfaces/authenticated-request.interface';
import { MockInterviewService } from './mock-interview.service';
import { GenerateTestDto } from './dto/generate-test.dto';
import { SubmitAnswersDto } from './dto/submit-answers.dto';
import { FollowUpDto } from './dto/follow-up.dto';
import { TtsDto } from './dto/tts.dto';
import { TranscribeDto } from './dto/transcribe.dto';
@ApiTags('Mock Interview')
@ApiBearerAuth()
@ApiUnauthorizedResponse({ description: 'Missing or invalid JWT token' })
@Controller('mock-interview')
@UseGuards(JwtAuthGuard)
export class MockInterviewController {
private readonly logger = new Logger(MockInterviewController.name);
constructor(private readonly service: MockInterviewService) {}
@Post('generate')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Generate a 3-round mock interview from a job description',
description:
'Length depends on lengthTier: quick (~5 min, 5 Q), standard (~10 min, 10 Q), full (~15 min, 15 Q). ' +
'Returns the questions (no answer keys) plus a sessionId to use in /submit.',
})
@ApiOkResponse({
description: 'Interview generated successfully',
schema: {
example: {
sessionId: 'uuid',
lengthTier: 'full',
questions: [
{ id: 1, round: 'hr', type: 'behavioral', question: '...' },
{ id: 6, round: 'technical', type: 'mcq', question: '...', options: ['A', 'B', 'C', 'D'] },
],
},
},
})
async generate(
@Req() req: AuthenticatedRequest,
@Body() dto: GenerateTestDto,
) {
return this.service.generateTest(req.user.id, dto);
}
@Post('submit')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Submit answers and get evaluation',
description: 'Returns overall score, per-question feedback, and improvement tips.',
})
async submit(
@Req() req: AuthenticatedRequest,
@Body() dto: SubmitAnswersDto,
) {
return this.service.submitAnswers(req.user.id, dto);
}
@Post('follow-up')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Ask for an adaptive follow-up to an open-ended answer',
description: 'Returns { followUp }. A one-sentence probing question, or null if none is warranted.',
})
async followUp(
@Req() req: AuthenticatedRequest,
@Body() dto: FollowUpDto,
) {
return this.service.requestFollowUp(req.user.id, dto);
}
@Post('tts')
@ApiOperation({
summary: 'Synthesize a natural voice for interviewer speech (Phase 3.4, Kokoro via Python)',
description:
'Streams audio (wav or ogg/opus per the format field) piped straight from the Python service. ' +
'On failure returns 503 so the client falls back to browser TTS.',
})
async tts(@Body() dto: TtsDto, @Res() res: Response) {
try {
const { stream, contentType } = await this.service.synthesizeSpeech(
dto.text,
dto.voice,
dto.format,
);
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'no-store');
res.status(HttpStatus.OK);
stream.on('error', (err: Error) => {
// Upstream died mid-stream — close what we have; headers are already sent.
this.logger.warn(`TTS stream error: ${String(err).slice(0, 160)}`);
res.end();
});
stream.pipe(res);
} catch (err) {
// TTS is optional — never surface a hard error; let the client fall back.
this.logger.warn(`TTS unavailable: ${String(err).slice(0, 160)}`);
res.status(HttpStatus.SERVICE_UNAVAILABLE).json({ message: 'TTS unavailable' });
}
}
@Post('transcribe')
@HttpCode(HttpStatus.OK)
@UseInterceptors(FileInterceptor('audio', { limits: { fileSize: 25 * 1024 * 1024 } }))
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'Transcribe a spoken answer (Whisper large-v3 via Groq)',
description:
'Accepts recorded answer audio (webm/ogg/mp4/wav, field name "audio", ≤25MB) plus an ' +
'optional "context" field (the question) used as a vocabulary hint. Returns { text }. ' +
'On failure returns 503 so the client falls back to the browser transcript.',
})
@ApiOkResponse({ schema: { example: { text: 'I led the migration to NestJS…' } } })
async transcribe(
@UploadedFile() audio: Express.Multer.File,
@Body() dto: TranscribeDto,
) {
if (!audio?.buffer?.length) throw new BadRequestException('No audio uploaded.');
try {
const text = await this.service.transcribeAnswer(
audio.buffer,
audio.originalname || 'answer.webm',
dto.context,
);
return { text };
} catch (err) {
// STT is an enhancement — the client keeps the browser transcript on 503.
this.logger.warn(`Transcription unavailable: ${String(err).slice(0, 160)}`);
throw new ServiceUnavailableException('Transcription unavailable');
}
}
@Get('sessions')
@ApiOperation({ summary: 'Get current user past interview sessions' })
async listSessions(@Req() req: AuthenticatedRequest) {
return this.service.getUserSessions(req.user.id);
}
// Declared before the ':id' route so "progress" isn't captured as an id.
@Get('progress')
@ApiOperation({
summary: 'Get the user\'s score trend across submitted interviews',
description: 'Returns { points: [...], summary: { attempts, best, latest, average, delta } }.',
})
async progress(@Req() req: AuthenticatedRequest) {
return this.service.getProgress(req.user.id);
}
@Get(':id')
@ApiOperation({ summary: 'Get details of a specific interview session' })
async getSession(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
) {
return this.service.getSessionDetail(req.user.id, id);
}
}