File size: 6,317 Bytes
9ffaa46
 
 
 
 
 
e0567f6
 
9ffaa46
0d43de6
9ffaa46
 
d8ea863
0d43de6
e0567f6
 
9ffaa46
e0567f6
0d43de6
9ffaa46
 
 
 
 
 
e0567f6
9ffaa46
 
 
 
 
 
0d43de6
 
e0567f6
9ffaa46
 
 
 
 
 
 
0d43de6
9ffaa46
 
 
 
 
0d43de6
 
 
 
9ffaa46
 
0d43de6
9ffaa46
 
 
0d43de6
9ffaa46
0d43de6
 
9ffaa46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0d43de6
 
 
 
2f666f7
0d43de6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e0567f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9ffaa46
 
 
 
 
d8ea863
0d43de6
 
 
 
 
 
 
 
 
 
d8ea863
 
 
 
 
 
 
 
9ffaa46
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
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);
  }
}