317 lines
11 KiB
TypeScript
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,
|
|
}
|
|
}
|
|
} |