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, 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 } }