feat: interview review module with whisper.cpp ASR + AI analysis + frontend page
New backend module 'interview-review' provides: - Audio upload (50MB limit, MP3/M4A/WAV/AAC/OGG/MP4/WebM) - Text transcript submission - whisper.cpp local ASR integration (tiny + base models) - AI analysis (4-dimension scoring: logic/expression/professionalism/stability) - Speech analysis (filler words detection, pace, duration) - Async processing pipeline with status polling - Graceful fallback to mock ASR when whisper unavailable New frontend page 'pages/review/review.vue' with 3 modes: - List mode: review history with status indicators - Upload mode: audio file upload or text paste - Report mode: score radar, dimension bars, analysis details Docs updated: PROJECT-STATUS.md v4.4, FEATURE-LIST.md v4.2, ROADMAP.md v4.2
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
Controller, Post, Get, Delete, Param, Query,
|
||||
UseInterceptors, UploadedFile, Body,
|
||||
HttpException, HttpStatus,
|
||||
} from '@nestjs/common'
|
||||
import { FileInterceptor } from '@nestjs/platform-express'
|
||||
import { diskStorage } from 'multer'
|
||||
import { extname, join } from 'path'
|
||||
import * as fs from 'fs'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { InterviewReviewService } from './interview-review.service'
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
|
||||
const UPLOAD_DIR = join(process.cwd(), 'uploads', 'reviews')
|
||||
|
||||
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
@Controller('interview-review')
|
||||
export class InterviewReviewController {
|
||||
constructor(private service: InterviewReviewService) {}
|
||||
|
||||
/** Upload audio file + metadata */
|
||||
@Post()
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
storage: diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
|
||||
filename: (_req, file, cb) => {
|
||||
const name = randomUUID() + extname(file.originalname || '.mp3')
|
||||
cb(null, name)
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 50 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = /\.(mp3|m4a|wav|aac|ogg|mp4|webm)$/i
|
||||
if (allowed.test(extname(file.originalname))) {
|
||||
cb(null, true)
|
||||
} else {
|
||||
cb(new HttpException('仅支持 mp3/m4a/wav/aac/ogg 格式', HttpStatus.BAD_REQUEST), false)
|
||||
}
|
||||
},
|
||||
}))
|
||||
async uploadFile(
|
||||
@UploadedFile() file: any,
|
||||
@Body('position') position: string,
|
||||
@Body('company') company: string,
|
||||
@CurrentUser('userId') userId: string,
|
||||
) {
|
||||
if (!file) {
|
||||
throw new HttpException('请上传录音文件', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
if (!position || !position.trim()) {
|
||||
throw new HttpException('请填写面试岗位', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
return this.service.create(userId, position.trim(), company?.trim(), file)
|
||||
}
|
||||
|
||||
/** Submit text transcript directly (no audio) */
|
||||
@Post('text')
|
||||
async submitText(
|
||||
@Body('position') position: string,
|
||||
@Body('company') company: string,
|
||||
@Body('text') text: string,
|
||||
@CurrentUser('userId') userId: string,
|
||||
) {
|
||||
if (!position || !position.trim()) {
|
||||
throw new HttpException('请填写面试岗位', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
if (!text || !text.trim()) {
|
||||
throw new HttpException('请填写面试转录文本', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
return this.service.createFromText(userId, position.trim(), text.trim(), company?.trim())
|
||||
}
|
||||
|
||||
@Get('list')
|
||||
async list(
|
||||
@Query('page') page: string,
|
||||
@Query('limit') limit: string,
|
||||
@CurrentUser('userId') userId: string,
|
||||
) {
|
||||
return this.service.listByUser(userId, parseInt(page) || 1, parseInt(limit) || 20)
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getDetail(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser('userId') userId: string,
|
||||
) {
|
||||
return this.service.getDetail(id, userId)
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser('userId') userId: string,
|
||||
) {
|
||||
return this.service.delete(id, userId)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user