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:
wlt
2026-06-16 18:32:25 +08:00
parent 96c367e0f8
commit 4cd889c081
16 changed files with 1771 additions and 80 deletions
@@ -0,0 +1,302 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { InterviewReview, InterviewReviewDocument } from './interview-review.schema'
import { AiService } from '../ai/ai.service'
import { AsrService } from './asr.service'
import { analyzeSpeech } from '../../common/utils/filler-words'
import * as fs from 'fs'
import * as path from 'path'
@Injectable()
export class InterviewReviewService {
private readonly logger = new Logger(InterviewReviewService.name)
constructor(
@InjectModel(InterviewReview.name) private reviewModel: Model<InterviewReviewDocument>,
private aiService: AiService,
private asrService: AsrService,
) {}
async create(
userId: string,
position: string,
company?: string,
audioFile?: any,
) {
let audioInfo: any = undefined
if (audioFile) {
const crypto = await import('crypto')
const hash = crypto.createHash('md5').update(audioFile.buffer).digest('hex')
audioInfo = {
hash,
filePath: audioFile.path,
duration: 0,
size: audioFile.size,
mimeType: audioFile.mimetype,
}
}
const review = new this.reviewModel({
userId,
position,
company: company || '',
status: 'processing',
audioFile: audioInfo,
})
const saved = await review.save()
// Start async processing (non-blocking)
this.processReview(saved._id.toString()).catch((err) => {
this.logger.error(`Review ${saved._id} processing failed: ${err.message}`)
})
return {
id: saved._id.toString(),
status: 'processing',
estimatedTime: 120,
}
}
/** Create from text transcript (skip ASR, go straight to analysis) */
async createFromText(
userId: string,
position: string,
text: string,
company?: string,
) {
const review = new this.reviewModel({
userId,
position,
company: company || '',
status: 'processing',
transcript: {
fullText: text,
segments: [{
startTime: 0,
endTime: Math.max(text.length / 3.5, 10),
speaker: 'candidate',
text,
}],
},
})
const saved = await review.save()
this.processReview(saved._id.toString()).catch((err) => {
this.logger.error(`Review ${saved._id} processing failed: ${err.message}`)
})
return {
id: saved._id.toString(),
status: 'processing',
estimatedTime: 60,
}
}
async processReview(reviewId: string) {
const review = await this.reviewModel.findById(reviewId)
if (!review) {
throw new Error('Review not found')
}
try {
// Step 1: ASR (if audio file exists and no transcript yet)
let transcript = review.transcript
if (!transcript && review.audioFile?.filePath) {
const asrResult = await this.asrService.transcribe(
review.audioFile.filePath,
review.audioFile.mimeType,
)
transcript = {
fullText: asrResult.fullText,
segments: asrResult.segments.map(s => ({
startTime: s.startTime,
endTime: s.endTime,
speaker: s.speaker as 'interviewer' | 'candidate',
text: s.text,
})),
}
await this.reviewModel.findByIdAndUpdate(reviewId, { transcript })
}
const transcriptText = transcript?.fullText || ''
// Step 2: Speech analysis (filler words)
const speechResult = analyzeSpeech(transcriptText)
let pace = '适中'
const rate = speechResult.speechRate
if (rate > 5) pace = '偏快'
else if (rate < 2.5) pace = '偏慢'
const speechAnalysis = {
fillerWords: speechResult.fillerWords,
fillerScore: speechResult.fillerScore,
fillerDensity: speechResult.fillerDensity,
pace,
totalDuration: speechResult.estimatedDurationSec,
totalChars: speechResult.totalChars,
}
// Step 3: AI analysis
const analysis = await this.runAiAnalysis(transcriptText, review.position, review.company)
// Save results
await this.reviewModel.findByIdAndUpdate(reviewId, {
status: 'completed',
analysis,
speechAnalysis,
'audioFile.duration': speechResult.estimatedDurationSec,
})
} catch (err: any) {
this.logger.error(`Processing failed for review ${reviewId}: ${err.message}`)
await this.reviewModel.findByIdAndUpdate(reviewId, {
status: 'failed',
$inc: { retryCount: 1 },
})
}
}
private async runAiAnalysis(transcriptText: string, position: string, company: string) {
if (!transcriptText.trim()) {
return this.emptyAnalysis()
}
const systemPrompt = `你是一位资深的校招面试评估专家。分析以下面试转录内容,输出评估报告。
评估维度(0-100分):
1. 逻辑思维(logic):回答是否结构化、层次分明、有因果关系
2. 表达能力(expression):语言是否流畅、用词是否准确、表达是否清晰
3. 专业度(professionalism):技术栈掌握程度、行业认知深度、术语使用是否准确
4. 临场稳定性(stability):面对问题是否沉着、反应速度、抗压能力
输出格式(严格的 JSON,不要多余内容):
{
"overallScore": 0-100,
"dimensions": { "logic": 0-100, "expression": 0-100, "professionalism": 0-100, "stability": 0-100 },
"strengths": ["亮点1", "亮点2"],
"weaknesses": ["不足1", "不足2"],
"suggestions": ["改进建议1", "改进建议2"],
"questionBreakdown": [
{
"question": "面试官的问题",
"answer": "用户的回答摘要",
"score": 0-100,
"comment": "简短评语",
"suggestedAnswer": "参考回答思路"
}
]
}`
const companyStr = company ? `面试公司: ${company}\n` : ''
const userMessage = `面试岗位: ${position}\n${companyStr}\n面试转录:\n${transcriptText}\n\n请评估并输出 JSON 报告。`
try {
const result = await this.aiService.call({
systemPrompt,
userMessage,
temperature: 0.5,
maxTokens: 2048,
})
const parsed = JSON.parse(result)
// Validate required fields
if (!parsed.overallScore || !parsed.dimensions) {
return this.emptyAnalysis()
}
return {
overallScore: Math.min(100, Math.max(0, Math.round(parsed.overallScore))),
dimensions: {
logic: Math.min(100, Math.max(0, Math.round(parsed.dimensions.logic || 0))),
expression: Math.min(100, Math.max(0, Math.round(parsed.dimensions.expression || 0))),
professionalism: Math.min(100, Math.max(0, Math.round(parsed.dimensions.professionalism || 0))),
stability: Math.min(100, Math.max(0, Math.round(parsed.dimensions.stability || 0))),
},
strengths: Array.isArray(parsed.strengths) ? parsed.strengths : [],
weaknesses: Array.isArray(parsed.weaknesses) ? parsed.weaknesses : [],
suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions : [],
questionBreakdown: Array.isArray(parsed.questionBreakdown) ? parsed.questionBreakdown.slice(0, 10) : [],
}
} catch {
return this.emptyAnalysis()
}
}
private emptyAnalysis() {
return {
overallScore: 60,
dimensions: { logic: 60, expression: 60, professionalism: 60, stability: 60 },
strengths: ['转录文本为空或 AI 分析异常'],
weaknesses: ['请检查音频文件或重新上传'],
suggestions: ['确保录音清晰完整后重新提交'],
questionBreakdown: [],
}
}
async getDetail(reviewId: string, userId: string) {
const review = await this.reviewModel.findById(reviewId).lean()
if (!review) {
throw new HttpException('复盘记录不存在', HttpStatus.NOT_FOUND)
}
if (review.userId.toString() !== userId) {
throw new HttpException('无权访问', HttpStatus.FORBIDDEN)
}
return this.sanitize(review)
}
async listByUser(userId: string, page = 1, limit = 20) {
const skip = (page - 1) * limit
const [items, total] = await Promise.all([
this.reviewModel
.find({ userId })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.select('-transcript.fullText -transcript.segments')
.lean(),
this.reviewModel.countDocuments({ userId }),
])
return {
items: items.map(i => this.sanitize(i)),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
}
}
async delete(reviewId: string, userId: string) {
const review = await this.reviewModel.findById(reviewId)
if (!review) {
throw new HttpException('复盘记录不存在', HttpStatus.NOT_FOUND)
}
if (review.userId.toString() !== userId) {
throw new HttpException('无权删除', HttpStatus.FORBIDDEN)
}
// Delete audio file if exists
if (review.audioFile?.filePath) {
try {
fs.unlinkSync(review.audioFile.filePath)
} catch {
// ignore
}
}
await this.reviewModel.findByIdAndDelete(reviewId)
return { success: true }
}
private sanitize(item: any) {
if (!item) return item
const obj = { ...item }
// Remove sensitive fields
if (obj.audioFile?.filePath) {
obj.audioFile = { ...obj.audioFile }
delete obj.audioFile.filePath
}
return obj
}
}