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,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user