Files
zhiyin/backend/src/modules/interview/interview.service.ts
T

317 lines
11 KiB
TypeScript

import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { Interview, InterviewDocument } from './interview.schema'
import { Progress, ProgressDocument } from '../schemas/progress.schema'
import { AiService } from '../ai/ai.service'
import { UserService } from '../user/user.service'
import { QuotaService } from '../user/quota.service'
import { TtsService } from '../tts/tts.service'
import { analyzeSpeech } from '../../common/utils/filler-words'
@Injectable()
export class InterviewService {
constructor(
@InjectModel(Interview.name) private interviewModel: Model<InterviewDocument>,
@InjectModel(Progress.name) private progressModel: Model<ProgressDocument>,
private aiService: AiService,
private userService: UserService,
private quotaService: QuotaService,
private ttsService: TtsService,
) {}
async create(userId: string, position: string) {
await this.quotaService.checkAndDeductInterview(userId)
const firstQuestion = await this.aiService.call({
systemPrompt: `你是一位专业的${position}面试官。请针对校招该岗位提出第一个面试问题,要求具体且有针对性。直接输出问题,不要多余内容。`,
userMessage: `请为${position}岗位的校招候选人提出第一个面试问题。`,
temperature: 0.8,
})
const interview = await this.interviewModel.create({
userId,
position,
messages: [{ role: 'ai', content: firstQuestion }],
questionCount: 1,
})
return {
id: interview._id.toString(),
position: interview.position,
messages: interview.messages,
questionCount: interview.questionCount,
}
}
async answer(interviewId: string, userId: string, answer: string) {
const interview = await this.interviewModel.findOne({ _id: interviewId, userId }).exec()
if (!interview) throw new HttpException('面试不存在', HttpStatus.NOT_FOUND)
if (interview.status === 'completed') throw new HttpException('面试已结束', HttpStatus.BAD_REQUEST)
// 检查轮次限制
const user = await this.userService.getModel().findById(userId).exec()
const maxRounds = user?.plan !== 'free' ? 10 : 5
if (interview.questionCount >= maxRounds) {
throw new HttpException(
user?.plan !== 'free' ? '已达到每场面试最大轮次(10轮)' : '免费版每场最多5轮,升级会员可享10轮',
HttpStatus.FORBIDDEN
)
}
// Save user's answer
interview.messages.push({ role: 'user', content: answer })
interview.questionCount += 1
// Analyze filler words in user's answer
const speechAnalysis = analyzeSpeech(answer)
interview.fillerWords = speechAnalysis.fillerWords.map(f => ({ word: f.word, count: f.count }))
interview.fillerScore = speechAnalysis.fillerScore
interview.fillerDensity = speechAnalysis.fillerDensity
// AI evaluates answer and generates next question
const conversationHistory = interview.messages
.slice(-6)
.map(m => `${m.role === 'ai' ? '面试官' : '候选人'}: ${m.content}`)
.join('\n')
const aiResponse = await this.aiService.call({
systemPrompt: `你是一位专业的面试官。评估候选人的回答,然后提出下一个问题。
- 如果这已经是第5-8个问题,给出总结性评价并建议结束面试
- 评估要简短,然后立即问下一个问题
- 使用「回答评价:...新的问题:...」的格式。`,
userMessage: `岗位: ${interview.position}
对话历史:
${conversationHistory}
候选人的回答: ${answer}
请评估并问下一个问题。`,
temperature: 0.7,
maxTokens: 1024,
})
interview.messages.push({ role: 'ai', content: aiResponse })
await interview.save()
return {
id: interview._id.toString(),
messages: interview.messages.slice(-2),
questionCount: interview.questionCount,
}
}
async answerWithAvatar(interviewId: string, userId: string, answer: string) {
const base = await this.answer(interviewId, userId, answer)
const aiMsg = base.messages?.find(m => m.role === 'ai')
if (aiMsg?.content) {
try {
const tts = await this.ttsService.synthesize(aiMsg.content)
return { ...base, ttsHash: tts.hash, ttsDurationMs: tts.durationMs, ttsAmplitude: tts.amplitudeData }
} catch {
// TTS failure is non-critical, return without audio
}
}
return base
}
async complete(interviewId: string, userId: string) {
const interview = await this.interviewModel.findOne({ _id: interviewId, userId }).exec()
if (!interview) throw new HttpException('面试不存在', HttpStatus.NOT_FOUND)
if (interview.status === 'completed') throw new HttpException('面试已结束', HttpStatus.BAD_REQUEST)
// Generate final summary with dimension scores
const fullConversation = interview.messages
.map(m => `${m.role === 'ai' ? '面试官' : '候选人'}: ${m.content}`)
.join('\n')
const summary = await this.aiService.call({
systemPrompt: `你是一位专业的面试评估师。根据面试对话,生成评估报告。输出JSON格式:
{
"总体评分": 85,
"逻辑思维": 80,
"表达能力": 85,
"专业度": 90,
"稳定性": 82,
"优点": ["逻辑清晰", "举例充分"],
"不足": ["回答过长", "缺少数据支撑"],
"建议": ["使用STAR法则", "控制回答时间"]
}`,
userMessage: `岗位: ${interview.position}
面试记录:
${fullConversation}
请生成评估报告。`,
temperature: 0.5,
maxTokens: 2048,
})
// Parse summary
let totalScore = 0
let dimensions = { logic: 0, expression: 0, professionalism: 0, stability: 0 }
try {
const parsed = JSON.parse(summary)
totalScore = parsed. || parsed.score || 0
dimensions = {
logic: parsed.逻辑思维 || 0,
expression: parsed.表达能力 || 0,
professionalism: parsed.专业度 || 0,
stability: parsed.稳定性 || 0,
}
} catch {
const match = summary.match(/(\d{1,3})(?=\s*分)/)
totalScore = match ? parseInt(match[1]) : 0
}
interview.status = 'completed'
interview.totalScore = totalScore
interview.summary = summary
await interview.save()
// === PROGRESS TRACKING ===
await this.trackProgress(userId, interview._id.toString(), interview.position, totalScore, dimensions)
return {
id: interview._id.toString(),
totalScore,
summary,
dimensions,
questionCount: interview.questionCount,
position: interview.position,
contributionPrompt: '分享你的面试经验,帮助更多同学!是否愿意贡献面经?(选填公司名称 + 遇到的面试题)',
}
}
private async trackProgress(
userId: string,
interviewId: string,
position: string,
totalScore: number,
dimensions: { logic: number; expression: number; professionalism: number; stability: number },
) {
let progress = await this.progressModel.findOne({ userId }).exec()
if (!progress) {
progress = await this.progressModel.create({
userId,
totalInterviews: 0,
completedInterviews: 0,
recentScores: [],
streakHistory: [],
})
}
progress.totalInterviews += 1
progress.completedInterviews += 1
// Update rolling averages
const n = progress.completedInterviews
progress.avgLogic = Math.round(((progress.avgLogic * (n - 1)) + dimensions.logic) / n)
progress.avgExpression = Math.round(((progress.avgExpression * (n - 1)) + dimensions.expression) / n)
progress.avgProfessionalism = Math.round(((progress.avgProfessionalism * (n - 1)) + dimensions.professionalism) / n)
progress.avgStability = Math.round(((progress.avgStability * (n - 1)) + dimensions.stability) / n)
// Add to recent scores
progress.recentScores.push({
interviewId,
date: new Date(),
position,
totalScore,
dimensions,
})
// Keep only last 20
if (progress.recentScores.length > 20) {
progress.recentScores = progress.recentScores.slice(-20)
}
// === STREAK TRACKING ===
const today = new Date()
today.setHours(0, 0, 0, 0)
if (progress.lastInterviewDate) {
const lastDate = new Date(progress.lastInterviewDate)
lastDate.setHours(0, 0, 0, 0)
const diffDays = Math.floor((today.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays === 1) {
// Consecutive day
progress.streak += 1
} else if (diffDays > 1) {
// Streak broken
progress.streak = 1
}
// same day = no change
} else {
progress.streak = 1
}
progress.lastInterviewDate = today
progress.streakHistory.push(today)
await progress.save()
}
async getDetail(interviewId: string, userId: string) {
const interview = await this.interviewModel.findOne({ _id: interviewId, userId }).exec()
if (!interview) throw new HttpException('面试不存在', HttpStatus.NOT_FOUND)
// Compute aggregate speech analysis from user messages
const userAnswers = interview.messages
.filter(m => m.role === 'user')
.map(m => m.content)
.join('\n')
const speechAnalysis = userAnswers ? analyzeSpeech(userAnswers) : null
// Parse dimensions from summary JSON
let dimensions: Record<string, number> | null = null
if (interview.summary) {
try {
const parsed = JSON.parse(interview.summary)
dimensions = {
logic: parsed['逻辑思维'] || 0,
expression: parsed['表达能力'] || 0,
professionalism: parsed['专业度'] || 0,
stability: parsed['稳定性'] || 0,
}
} catch {}
}
return {
...interview.toObject(),
fillerWords: speechAnalysis?.fillerWords || interview.fillerWords,
fillerScore: speechAnalysis?.fillerScore || interview.fillerScore,
fillerDensity: speechAnalysis?.fillerDensity || interview.fillerDensity,
speechAnalysis,
dimensions,
}
}
async getList(userId: string) {
const interviews = await this.interviewModel
.find({ userId })
.sort({ createdAt: -1 })
.select('position status totalScore questionCount createdAt')
.exec()
return interviews.map(i => ({
id: i._id.toString(),
position: i.position,
status: i.status,
totalScore: i.totalScore,
questionCount: i.questionCount,
time: i.createdAt,
}))
}
async getStats(userId: string) {
const interviews = await this.interviewModel.find({ userId }).exec()
const completed = interviews.filter(i => i.status === 'completed')
const totalScore = completed.reduce((s, i) => s + i.totalScore, 0)
return {
interviewCount: interviews.length,
completedCount: completed.length,
avgScore: completed.length > 0 ? Math.round(totalScore / completed.length) : 0,
}
}
}