import { Injectable, Logger, NotFoundException, Inject, forwardRef, ForbiddenException, BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DeepPartial, In, DataSource } from 'typeorm'; import { ConfigService } from '@nestjs/config'; import { ChatOpenAI } from '@langchain/openai'; import { HumanMessage, BaseMessage, AIMessage, SystemMessage, } from '@langchain/core/messages'; import { Observable, from, map, mergeMap, concatMap } from 'rxjs'; import { AssessmentSession, AssessmentStatus, } from './entities/assessment-session.entity'; import { AssessmentQuestion } from './entities/assessment-question.entity'; import { AssessmentAnswer } from './entities/assessment-answer.entity'; import { AssessmentTemplate } from './entities/assessment-template.entity'; import { AssessmentCertificate } from './entities/assessment-certificate.entity'; import { QuestionBank, QuestionBankStatus } from './entities/question-bank.entity'; import { QuestionBankItem, QuestionBankItemStatus } from './entities/question-bank-item.entity'; import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service'; import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service'; import { ModelConfigService } from '../model-config/model-config.service'; import { ModelType } from '../types'; import { ElasticsearchService } from '../elasticsearch/elasticsearch.service'; import { RagService } from '../rag/rag.service'; import { ChatService } from '../chat/chat.service'; import { createEvaluationGraph } from './graph/builder'; import { EvaluationState } from './graph/state'; import { TemplateService } from './services/template.service'; import { ContentFilterService } from './services/content-filter.service'; import { QuestionOutlineService } from './services/question-outline.service'; import { QuestionBankService } from './services/question-bank.service'; import { I18nService } from '../i18n/i18n.service'; import { TenantService } from '../tenant/tenant.service'; @Injectable() export class AssessmentService { private readonly logger = new Logger(AssessmentService.name); private readonly graph = createEvaluationGraph(); constructor( @InjectRepository(AssessmentSession) private sessionRepository: Repository, @InjectRepository(AssessmentQuestion) private questionRepository: Repository, @InjectRepository(AssessmentAnswer) private answerRepository: Repository, @InjectRepository(AssessmentCertificate) private certificateRepository: Repository, @InjectRepository(QuestionBank) private questionBankRepository: Repository, @InjectRepository(QuestionBankItem) private questionBankItemRepository: Repository, @Inject(forwardRef(() => KnowledgeBaseService)) private kbService: KnowledgeBaseService, @Inject(forwardRef(() => KnowledgeGroupService)) private groupService: KnowledgeGroupService, @Inject(forwardRef(() => ModelConfigService)) private modelConfigService: ModelConfigService, private configService: ConfigService, private templateService: TemplateService, private contentFilterService: ContentFilterService, private questionOutlineService: QuestionOutlineService, private questionBankService: QuestionBankService, private ragService: RagService, @Inject(forwardRef(() => ChatService)) private chatService: ChatService, private i18nService: I18nService, private tenantService: TenantService, private dataSource: DataSource, ) {} private async getModel(tenantId: string): Promise { const config = await this.modelConfigService.findDefaultByType( tenantId, ModelType.LLM, ); this.logger.debug(`[getModel] config: modelId=${config.modelId}, baseUrl=${config.baseUrl}, hasApiKey=${!!config.apiKey}`); return new ChatOpenAI({ apiKey: config.apiKey || 'ollama', modelName: config.modelId, temperature: 0.7, configuration: { baseURL: config.baseUrl || 'https://api.deepseek.com/v1', }, }); } private async getMultiGroupContent( groupIds: string[], userId: string, tenantId: string, templateJson?: any, ): Promise { this.logger.log(`[getMultiGroupContent] Starting for ${groupIds.length} groups`); const contents: string[] = []; const dimensionMap: Record = { prompt: '技术能力-提示词', llm: '技术能力-LLM', ide: 'IDE协作能力', devPattern: 'AI开发范式', workCapability: '工作能力-安全', }; for (let i = 0; i < groupIds.length; i++) { const groupId = groupIds[i]; try { const files = await this.groupService.getGroupFiles(groupId, userId, tenantId); const groupContent = files .filter((f: any) => f.content) .map((f: any) => { const dimension = Object.keys(dimensionMap)[i] || '工作能力-安全'; return `=== [${dimension}] ===\n${f.content}`; }) .join('\n\n'); if (groupContent) { contents.push(groupContent); } } catch (err) { this.logger.warn(`[getMultiGroupContent] Failed to get files for group ${groupId}: ${err.message}`); } } const result = contents.join('\n\n'); this.logger.log(`[getMultiGroupContent] Total content length: ${result.length}`); return result; } private normalizeDimension(dim: string): string { const lower = dim.toLowerCase(); if (lower === 'dev_pattern') return 'devPattern'; if (lower === 'work_capability') return 'workCapability'; return lower; } private calculateScores( questions: any[], scores: Record, weightConfig: { prompt: number; other: number }, ): { finalScore: number; dimensionScores: Record; radarData: Record } { this.logger.debug('[calculateScores] Input:', { questionsCount: questions.length, scores, weightConfig, }); const dimensionScoresMap: Record = { prompt: [], llm: [], ide: [], devPattern: [], workCapability: [], }; questions.forEach((q: any, idx: number) => { const dimension = this.normalizeDimension(q.dimension || 'workCapability'); const score = scores[q.id || idx.toString()] || 0; if (dimensionScoresMap[dimension]) { dimensionScoresMap[dimension].push(score); } else { dimensionScoresMap.workCapability.push(score); } }); const dimensionAverages: Record = {}; Object.keys(dimensionScoresMap).forEach(dim => { const arr = dimensionScoresMap[dim]; dimensionAverages[dim] = arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0; }); const promptAvg = dimensionAverages.prompt || 0; // 只计算有题目的维度,不要把0分算进去 const otherDims = ['llm', 'ide', 'devPattern', 'workCapability']; const otherDimsWithScores = otherDims.filter(dim => dimensionScoresMap[dim]?.length > 0); const otherAvg = otherDimsWithScores.length > 0 ? otherDimsWithScores.reduce((sum, dim) => sum + (dimensionAverages[dim] || 0), 0) / otherDimsWithScores.length : 0; this.logger.debug('[calculateScores] Scoring debug:', { promptAvg, otherDimsWithScores, otherAvg, workCapability: dimensionAverages.workCapability }); // Weighted final score using weightConfig let finalScore: number; if (promptAvg > 0 && otherAvg > 0) { const totalWeight = (weightConfig?.prompt ?? 50) + (weightConfig?.other ?? 50); finalScore = totalWeight > 0 ? (promptAvg * (weightConfig?.prompt ?? 50) + otherAvg * (weightConfig?.other ?? 50)) / totalWeight : (promptAvg + otherAvg) / 2; } else { finalScore = promptAvg || otherAvg || 0; } const radarData: Record = {}; Object.keys(dimensionAverages).forEach(dim => { radarData[dim] = Math.round(dimensionAverages[dim] * 10) / 10; }); this.logger.debug('[calculateScores] Result:', { finalScore: Math.round(finalScore * 10) / 10, dimensionScores: dimensionAverages, promptAvg, otherAvg, }); return { finalScore: Math.round(finalScore * 10) / 10, dimensionScores: dimensionAverages, radarData, }; } /** * Starts a new assessment session. */ private async getSessionContent(session: { knowledgeBaseId?: string | null; knowledgeGroupId?: string | null; userId: string; tenantId: string; templateJson?: any; }): Promise { const linkedGroupIds = session.templateJson?.linkedGroupIds; if (linkedGroupIds && linkedGroupIds.length > 0) { return this.getMultiGroupContent(linkedGroupIds, session.userId, session.tenantId, session.templateJson); } const kbId = session.knowledgeBaseId || session.knowledgeGroupId; this.logger.log(`[getSessionContent] Starting for KB/Group ID: ${kbId}`); if (!kbId) { this.logger.warn(`[getSessionContent] No KB/Group ID provided`); return ''; } const keywords = session.templateJson?.keywords || []; // If keywords are provided, use RagService (Hybrid Search) to find relevant content if (keywords.length > 0) { this.logger.log( `[getSessionContent] Keywords detected, performing hybrid search via RagService: ${keywords.join(', ')}`, ); try { // 1. Determine file IDs to include in search let fileIds: string[] = []; if (session.knowledgeBaseId) { fileIds = [session.knowledgeBaseId]; } else if (session.knowledgeGroupId) { fileIds = await this.groupService.getFileIdsByGroups( [session.knowledgeGroupId], session.userId, session.tenantId, ); } if (fileIds.length > 0) { const query = keywords.join(' '); this.logger.log( `[getSessionContent] Performing high-fidelity grounded search (streamChat-style). Keywords: "${query}"`, ); // 1. Get default embedding model (strict logic from streamChat) const embeddingModel = await this.modelConfigService.findDefaultByType( session.tenantId || 'default', ModelType.EMBEDDING, ); // 2. Perform advanced RAG search const ragResults = await this.ragService.searchKnowledge( query, session.userId, 20, // Increased topK to 20 for broader question coverage 0.1, // Lenient similarityThreshold (Chat/Rag defaults are 0.3) embeddingModel?.id, true, // enableFullTextSearch true, // enableRerank undefined, // selectedRerankId undefined, // selectedGroups fileIds, 0.3, // Lenient rerankSimilarityThreshold (Chat/Rag defaults are 0.5) session.tenantId, ); // 3. Format context using localized labels (equivalent to buildContext) const language = session.templateJson?.language || 'zh'; const searchContent = ragResults .map((result, index) => { // this.logger.debug(`[getSessionContent] Found chunk [${index + 1}]: score=${result.score.toFixed(4)}, file=${result.fileName}, contentPreview=${result.content}...`); return `[${index + 1}] ${this.i18nService.getMessage('file', language)}:${result.fileName}\n${this.i18nService.getMessage('content', language)}:${result.content}\n`; }) .join('\n'); if (searchContent && searchContent.trim().length > 0) { this.logger.log( `[getSessionContent] SUCCESS: Found ${ragResults.length} relevant chunks. Total length: ${searchContent.length}`, ); // this.logger.log(`[getSessionContent] --- AI Context Start ---\n${searchContent}\n[getSessionContent] --- AI Context End ---`); return searchContent; } else { this.logger.warn( `[getSessionContent] High-fidelity search returned no results for query: "${query}".`, ); } } else { this.logger.warn( `[getSessionContent] No files found for search scope (KB: ${session.knowledgeBaseId}, Group: ${session.knowledgeGroupId})`, ); } } catch (err) { this.logger.error( `[getSessionContent] Grounded search failed unexpectedly: ${err.message}`, err.stack, ); } this.logger.warn( `[getSessionContent] Grounded search failed or returned nothing. One common reason is that the keywords are not present in the indexed documents.`, ); } // Fallback or No Keywords: Original behavior (full content retrieval) let content = ''; if (session.knowledgeBaseId) { this.logger.debug( `[getSessionContent] Fetching content for KnowledgeBase: ${kbId}`, ); const kb = await (this.kbService as any).kbRepository.findOne({ where: { id: kbId, tenantId: session.tenantId }, }); if (kb) { content = kb.content || ''; this.logger.debug( `[getSessionContent] Found KB content, length: ${content.length}`, ); } else { this.logger.warn( `[getSessionContent] KnowledgeBase not found: ${kbId}`, ); } } else { try { this.logger.debug( `[getSessionContent] Fetching content for KnowledgeGroup: ${kbId}`, ); const groupFiles = await this.groupService.getGroupFiles( kbId, session.userId, session.tenantId, ); this.logger.debug( `[getSessionContent] Found ${groupFiles.length} files in group`, ); content = groupFiles .filter((f) => f.content) .map((f) => { this.logger.debug( `[getSessionContent] Including file: ${f.title || f.originalName}, content length: ${f.content?.length || 0}`, ); return `--- Document: ${f.title || f.originalName} ---\n${f.content}`; }) .join('\n\n'); this.logger.debug( `[getSessionContent] Total group content length: ${content.length}`, ); } catch (err) { this.logger.error( `[getSessionContent] Failed to get group files: ${err.message}`, ); } } // Apply keyword filter (regex based) as an extra layer if still using full content if (content && keywords.length > 0) { this.logger.debug( `[getSessionContent] Applying fallback keyword filters: ${keywords.join(', ')}`, ); const prevLen = content.length; content = this.contentFilterService.filterContent(content, keywords); this.logger.debug( `[getSessionContent] After filtering, content length: ${content.length} (was ${prevLen})`, ); } this.logger.log( `[getSessionContent] Final content for AI generation (Length: ${content.length})`, ); this.logger.debug( `[getSessionContent] Content Preview: ${content.substring(0, 500)}...`, ); return content; } /** * Starts a new assessment session. * kbId can be a KnowledgeBase ID or a KnowledgeGroup ID. */ async startSession( userId: string, kbId: string | undefined, tenantId: string, language: string = 'en', templateId?: string, ): Promise { this.logger.log( `[startSession] Starting session for user ${userId}, templateId: ${templateId}, kbId: ${kbId}`, ); let template: AssessmentTemplate | null = null; if (templateId) { template = await this.templateService.findOne( templateId, userId, tenantId, ); this.logger.debug( `[startSession] Found template: ${template?.name}, linked group: ${template?.knowledgeGroupId}`, ); // P2: Check attempt limit if (template.attemptLimit > 0) { const attemptCount = await this.sessionRepository.count({ where: { userId, templateId, status: AssessmentStatus.COMPLETED }, }); if (attemptCount >= template.attemptLimit) { throw new BadRequestException( `已达到最大尝试次数 ${template.attemptLimit}/${template.attemptLimit}`, ); } } // P2: Check scheduled window if (template.scheduledStart) { const start = new Date(template.scheduledStart); if (Date.now() < start.getTime()) { throw new BadRequestException( `考试尚未开始,预定时间: ${start.toLocaleString()}`, ); } } if (template.scheduledEnd) { const end = new Date(template.scheduledEnd); if (Date.now() > end.getTime()) { throw new BadRequestException( '考试已结束,超过预定截止时间', ); } } } // Use kbId if provided, otherwise fall back to template's group ID const activeKbId = kbId || template?.knowledgeGroupId; // If no knowledge source, check if template has a question bank first let hasBankQuestions = false; if (!activeKbId && templateId && template) { try { const targetCount = template.questionCount || 5; const linkedBanks = await this.questionBankRepository.find({ where: { templateId }, }); if (linkedBanks.length > 0) { const bankIds = linkedBanks.map(b => b.id); const count = await this.questionBankItemRepository.count({ where: { bankId: In(bankIds), status: QuestionBankItemStatus.PUBLISHED }, }); if (count >= targetCount) { hasBankQuestions = true; this.logger.log(`[startSession] Template has ${count} published questions, skipping KB check`); } } } catch (e) { this.logger.warn(`[startSession] Bank pre-check failed: ${e.message}`); } } if (!activeKbId && !hasBankQuestions) { this.logger.error(`[startSession] No knowledge source resolved`); throw new BadRequestException('Knowledge source (ID or Template) must be provided.'); } // Determine if it's a KB or Group (only when activeKbId exists) let isKb = false; if (activeKbId) { try { await this.kbService.findOne(activeKbId, userId, tenantId); isKb = true; } catch (kbError) { if (kbError instanceof NotFoundException) { try { await this.groupService.findOne(activeKbId, userId, tenantId); } catch (groupError) { this.logger.error(`[startSession] Knowledge source ${activeKbId} not found`); throw new NotFoundException( this.i18nService.getMessage('knowledgeSourceNotFound') || 'Knowledge source not found', ); } } else { throw kbError; } } } this.logger.debug(`[startSession] isKb: ${isKb}`); const templateData: any = template ? { name: template.name, keywords: template.keywords, questionCount: template.questionCount, questionCountMin: template.questionCountMin, questionCountMax: template.questionCountMax, difficultyDistribution: template.difficultyDistribution, difficultyConfig: template.difficultyConfig, weightConfig: template.weightConfig, passingScore: template.passingScore, style: template.style, dimensions: template.dimensions, linkedGroupIds: template.linkedGroupIds, // P2: must explicitly set these — TypeORM entity may not enumerate new columns attemptLimit: template.attemptLimit, reviewMode: template.reviewMode, shuffleQuestions: template.shuffleQuestions, scheduledStart: template.scheduledStart, scheduledEnd: template.scheduledEnd, } : undefined; let questionsFromBank: any[] = []; let questionSource: 'bank' | 'generator' = 'generator'; if (templateId) { try { const targetCount = template?.questionCount || 5; const linkedBanks = await this.questionBankRepository.find({ where: { templateId }, }); if (linkedBanks.length > 0) { const bankIds = linkedBanks.map(b => b.id); const questionCount = await this.questionBankItemRepository.count({ where: { bankId: In(bankIds), status: QuestionBankItemStatus.PUBLISHED }, }); this.logger.log( `[startSession] Found ${linkedBanks.length} banks with ${questionCount} published questions, target: ${targetCount}`, ); if (questionCount >= targetCount) { const bankId = linkedBanks[0].id; const selectedItems = await this.questionBankService.selectQuestions( bankId, targetCount, template?.dimensions, ); questionsFromBank = selectedItems.map(item => { let options = item.options; let correctAnswer = item.correctAnswer; if (item.questionType === 'MULTIPLE_CHOICE' && options && options.length > 0 && correctAnswer) { const labels = ['A', 'B', 'C', 'D']; const optTexts = options.map((o: string) => o.replace(/^[A-D][.)、]\s*/, '')); const correctIdx = correctAnswer.charCodeAt(0) - 65; const correctText = correctIdx >= 0 && correctIdx < optTexts.length ? optTexts[correctIdx] : null; const indices = optTexts.map((_: any, i: number) => i); for (let i = indices.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [indices[i], indices[j]] = [indices[j], indices[i]]; } options = indices.map((origIdx: number, newPos: number) => `${labels[newPos]}${optTexts[origIdx]}`); correctAnswer = correctText ? labels[indices.indexOf(correctIdx)] : correctAnswer; } return { id: item.id, questionText: item.questionText, questionType: item.questionType, options, correctAnswer, judgment: item.judgment, keyPoints: item.keyPoints, difficulty: item.difficulty, dimension: item.dimension, basis: item.basis, maxFollowUps: item.followupHints?.length || 0, }; }); const answerKey: Record = {}; selectedItems.forEach(item => { if (item.correctAnswer || item.judgment) { answerKey[item.id] = { correctAnswer: item.correctAnswer, judgment: item.judgment, }; } }); if (Object.keys(answerKey).length > 0 && templateData) { templateData.questionAnswerKey = answerKey; } // P2: Shuffle questions per candidate if (template?.shuffleQuestions !== false && questionsFromBank.length > 1) { for (let i = questionsFromBank.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [questionsFromBank[i], questionsFromBank[j]] = [questionsFromBank[j], questionsFromBank[i]]; } } questionSource = 'bank'; this.logger.log( `[startSession] Selected ${questionsFromBank.length} questions from question bank`, ); } else { this.logger.log( `[startSession] Question bank has insufficient questions (${questionCount} < ${targetCount}), will use LLM generation`, ); } } else { this.logger.log( `[startSession] No published question banks found for template ${templateId}, will use LLM generation`, ); } } catch (err: any) { this.logger.warn(`Bank query failed: ${err.message}, falling back to LLM generation`); } } const sessionData: any = { userId, tenantId, knowledgeBaseId: isKb ? activeKbId : undefined, knowledgeGroupId: isKb ? undefined : activeKbId, templateId, templateJson: templateData, status: AssessmentStatus.IN_PROGRESS, language, questions_json: questionsFromBank.length > 0 ? questionsFromBank : [], questionSource, startedAt: new Date(), currentQuestionStartedAt: new Date(), totalTimeLimit: template?.totalTimeLimit || 1800, perQuestionTimeLimit: template?.perQuestionTimeLimit || 300, }; // Skip content check if questions are loaded from the question bank const hasBankContent = questionsFromBank.length > 0; if (!hasBankContent) { const content = await this.getSessionContent(sessionData); if (!content || content.trim().length < 10) { this.logger.error( `[startSession] Insufficient content length: ${content?.length || 0}`, ); throw new BadRequestException( 'Selected knowledge source has no sufficient content for evaluation.', ); } } const session = this.sessionRepository.create( sessionData as DeepPartial, ); const savedSession = (await this.sessionRepository.save( session as any, )) as AssessmentSession; // Thread ID for LangGraph is the session ID savedSession.threadId = savedSession.id; await this.sessionRepository.save(savedSession); this.logger.log( `[startSession] Session ${savedSession.id} created and saved`, ); // cleanupOldSessions permanently destroys data - disabled to preserve history. // Admins can use batch-delete endpoint for manual cleanup. // this.cleanupOldSessions(userId); return savedSession; } /** * Specialized streaming start for initial generation. */ startSessionStream(sessionId: string, userId: string): Observable { return new Observable((observer) => { (async () => { try { const session = await this.sessionRepository.findOne({ where: { id: sessionId, userId }, }); if (!session) { observer.error(new NotFoundException('Session not found')); return; } const model = await this.getModel(session.tenantId); // Check if questions already exist in session (from question bank) const existingQuestions = session.questions_json || []; const hasExistingQuestions = existingQuestions.length > 0; // Skip content retrieval when bank questions exist (prevents generator errors) const content = hasExistingQuestions ? '' : await this.getSessionContent(session); // Check if we already have state const existingState = await this.graph.getState({ configurable: { thread_id: sessionId }, }); if ( existingState && existingState.values && existingState.values.questions?.length > 0 ) { this.logger.log( `Session ${sessionId} already has state, skipping generation.`, ); const mappedData = this.sanitizeStateForClient({ ...existingState.values }); mappedData.messages = this.mapMessages(mappedData.messages || []); mappedData.feedbackHistory = this.mapMessages( mappedData.feedbackHistory || [], ); observer.next({ type: 'final', data: mappedData }); observer.complete(); return; } const initialState: Partial = { assessmentSessionId: sessionId, knowledgeBaseId: session.knowledgeBaseId || session.knowledgeGroupId || '', messages: [], questionCount: session.templateJson?.questionCount, difficultyDistribution: session.templateJson?.difficultyDistribution, style: session.templateJson?.style, keywords: session.templateJson?.keywords, questionAnswerKey: session.templateJson?.questionAnswerKey, currentQuestionIndex: 0, }; const isZh = (session.language || 'en') === 'zh'; const isJa = session.language === 'ja'; const hasQuestionsFromBank = hasExistingQuestions; if (hasQuestionsFromBank) { this.logger.log( `[startSessionStream] Using ${existingQuestions.length} questions from question bank`, ); initialState.questions = existingQuestions; initialState.messages = [ new HumanMessage( isZh ? '我已准备好回答问题。' : isJa ? '質問への回答準備ができています。' : 'I am ready to answer the questions.', ), ]; } const initialMsg = isZh ? '现在生成评估问题。请务必使用中文。' : isJa ? '今すぐアセスメント問題を生成してください。必ず日本語で回答してください。' : 'Generate the assessment questions now. Please strictly respond in English.'; this.logger.log( `[startSessionStream] Starting stream for session ${sessionId}`, ); const stream = await this.graph.stream( { ...initialState, language: session.language || 'en', messages: hasQuestionsFromBank ? initialState.messages : [new HumanMessage(initialMsg)], }, { configurable: { thread_id: sessionId, model, knowledgeBaseContent: content, language: session.language || 'en', targetCount: session.templateJson?.questionCount || 5, questionCount: session.templateJson?.questionCount, difficultyDistribution: session.templateJson?.difficultyDistribution, style: session.templateJson?.style, keywords: session.templateJson?.keywords, }, streamMode: ['values', 'updates'], }, ); this.logger.debug(`[startSessionStream] Graph stream started`); let hasEmittedQuestion = false; for await (const [mode, data] of stream) { if (mode === 'updates') { const node = Object.keys(data)[0]; const updateData = { ...data[node] }; if (updateData.messages) { updateData.messages = this.mapMessages(updateData.messages); } if (updateData.feedbackHistory) { updateData.feedbackHistory = this.mapMessages( updateData.feedbackHistory, ); } if (node === 'interviewer' && !hasEmittedQuestion && hasQuestionsFromBank) { updateData.questions = existingQuestions; hasEmittedQuestion = true; } observer.next({ type: 'node', node, data: updateData }); } } // After stream, get the latest authoritative state from checkpointer const fullState = await this.graph.getState({ configurable: { thread_id: sessionId }, }); const finalData = fullState.values as EvaluationState; if (finalData && finalData.messages) { this.logger.debug( `[AssessmentService] startSessionStream Final Authoritative State messages:`, finalData.messages.length, ); session.messages = finalData.messages; session.feedbackHistory = finalData.feedbackHistory || []; session.questions_json = hasQuestionsFromBank && existingQuestions.length > 0 ? existingQuestions : finalData.questions; session.currentQuestionIndex = finalData.currentQuestionIndex; session.followUpCount = finalData.followUpCount || 0; if (finalData.report) { session.status = AssessmentStatus.COMPLETED; session.finalReport = finalData.report; const scores = finalData.scores; const questions = finalData.questions || []; const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 }; const passingScore = (session.templateJson?.passingScore ?? 60) / 10; if (questions.length > 0 && Object.keys(scores).length > 0) { const { finalScore, dimensionScores, radarData } = this.calculateScores( questions, scores, weightConfig, ); session.finalScore = finalScore; (session as any).dimensionScores = dimensionScores; (session as any).radarData = radarData; (session as any).passed = finalScore >= passingScore; } } await this.sessionRepository.save(session); const mappedData: any = this.sanitizeStateForClient( { ...finalData }, session.status !== AssessmentStatus.COMPLETED, ); mappedData.messages = this.mapMessages(finalData.messages); mappedData.feedbackHistory = this.mapMessages( finalData.feedbackHistory || [], ); mappedData.status = session.status; mappedData.report = session.finalReport; mappedData.finalScore = session.finalScore; mappedData.passed = (session as any).passed; observer.next({ type: 'final', data: mappedData }); } observer.complete(); } catch (err) { observer.error(err); } })(); }); } /** * Submits a user's answer and continues the assessment. */ async submitAnswer( sessionId: string, userId: string, answer: string, language: string = 'en', ): Promise { const session = await this.sessionRepository.findOne({ where: { id: sessionId, userId }, relations: ['template'], }); if (!session) throw new NotFoundException('Session not found'); if (session.status === AssessmentStatus.IN_PROGRESS) { const now = new Date(); const startTime = session.startedAt ? new Date(session.startedAt) : now; const questionStartTime = session.currentQuestionStartedAt ? new Date(session.currentQuestionStartedAt) : now; const totalElapsed = Math.floor((now.getTime() - startTime.getTime()) / 1000); const questionElapsed = Math.floor((now.getTime() - questionStartTime.getTime()) / 1000); if (totalElapsed >= session.totalTimeLimit || questionElapsed >= session.perQuestionTimeLimit) { session.status = AssessmentStatus.COMPLETED; session.finalReport = totalElapsed >= session.totalTimeLimit ? '评测总时间已用尽,评估已自动结束' : '单题答题时间已用尽,评估已自动结束'; if (session.finalScore === null || session.finalScore === undefined) { session.finalScore = 0; } await this.sessionRepository.save(session); this.logger.log(`[submitAnswer] Session ${sessionId} auto-ended due to timeout`); return { assessmentSessionId: sessionId, status: 'COMPLETED', timeout: true, finalScore: session.finalScore, finalReport: session.finalReport, }; } } const model = await this.getModel(session.tenantId); await this.ensureGraphState(sessionId, session); const content = await this.getSessionContent(session); // Update state with human message first to ensure it's in history before resumption await this.graph.updateState( { configurable: { thread_id: sessionId } }, { messages: [new HumanMessage(answer)] }, ); this.logger.debug(`[submitAnswer] Resuming graph for session ${sessionId}`); let finalResult: any = null; const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 }; const passingScore = (session.templateJson?.passingScore ?? 60) / 10; // Resume from the last interrupt (typically after interviewer) const stream = await this.graph.stream(null, { configurable: { thread_id: sessionId, model, knowledgeBaseContent: content, language: session.language || language, targetCount: session.templateJson?.questionCount || 5, questionCount: session.templateJson?.questionCount, difficultyDistribution: session.templateJson?.difficultyDistribution, weightConfig: weightConfig, passingScore: passingScore, style: session.templateJson?.style, keywords: session.templateJson?.keywords, }, streamMode: ['values', 'updates'], }); for await (const [mode, data] of stream) { if (mode === 'values') { // This might be the interrupt info if interrupted finalResult = data; } else if (mode === 'updates') { const nodeName = Object.keys(data)[0]; this.logger.debug(`[submitAnswer] Node completed: ${nodeName}`); } } // Always get the latest authoritative state from checkpointer after the stream const fullState = await this.graph.getState({ configurable: { thread_id: sessionId }, }); finalResult = fullState.values as EvaluationState; this.logger.log( `[submitAnswer] Stream finished. State Index: ${finalResult.currentQuestionIndex}, Questions: ${finalResult.questions?.length}, HasReport: ${!!finalResult.report}`, ); if (finalResult && (finalResult.messages || finalResult.questions)) { session.messages = finalResult.messages; session.questions_json = finalResult.questions; session.currentQuestionIndex = finalResult.currentQuestionIndex; session.followUpCount = finalResult.followUpCount || 0; if (finalResult.report) { session.status = AssessmentStatus.COMPLETED; session.finalReport = finalResult.report; const scores = finalResult.scores as Record; const questions = finalResult.questions || []; const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 }; const passingScore = (session.templateJson?.passingScore ?? 60) / 10; if (questions.length > 0 && Object.keys(scores).length > 0) { const { finalScore, dimensionScores, radarData } = this.calculateScores( questions, scores, weightConfig, ); session.finalScore = finalScore; (session as any).dimensionScores = dimensionScores; (session as any).radarData = radarData; (session as any).passed = finalScore >= passingScore; } } session.feedbackHistory = finalResult.feedbackHistory || []; await this.sessionRepository.save(session); // Map result for return finalResult.messages = this.mapMessages(finalResult.messages); finalResult.feedbackHistory = this.mapMessages( finalResult.feedbackHistory || [], ); finalResult.report = session.finalReport; finalResult.finalScore = session.finalScore; finalResult.dimensionScores = (session as any).dimensionScores; finalResult.radarData = (session as any).radarData; finalResult.passed = (session as any).passed; this.logger.log( `[submitAnswer] session saved. DB Status: ${session.status}, Index: ${session.currentQuestionIndex}`, ); this.logger.log( `[submitAnswer] finalResult check: hasQuestions=${!!finalResult.questions}, questionsLen=${finalResult.questions?.length}, hasReport=${!!finalResult.report}`, ); this.logger.debug( `[submitAnswer] finalResult keys: ${Object.keys(finalResult).join(', ')}`, ); this.logger.log( `[submitAnswer] session updated: status=${session.status}, index=${session.currentQuestionIndex}`, ); } else { this.logger.warn( `[submitAnswer] finalResult has no usable data! Keys: ${Object.keys(finalResult || {}).join(', ')}`, ); } return finalResult; } /** * Streaming version of submitAnswer. */ submitAnswerStream( sessionId: string, userId: string, answer: string, language: string = 'en', ): Observable { this.logger.debug('[submitAnswerStream] START - sessionId:', sessionId, 'answer length:', answer?.length); let emittedNextQuestion = false; let hasEmittedNodes = false; return new Observable((observer) => { (async () => { try { this.logger.debug('[submitAnswerStream] After Observable - sessionId:', sessionId); const session = await this.sessionRepository.findOne({ where: { id: sessionId, userId }, }); if (!session) { observer.error(new NotFoundException('Session not found')); return; } if (session.status === AssessmentStatus.IN_PROGRESS) { const now = new Date(); const startTime = session.startedAt ? new Date(session.startedAt) : now; const questionStartTime = session.currentQuestionStartedAt ? new Date(session.currentQuestionStartedAt) : now; const totalElapsed = Math.floor((now.getTime() - startTime.getTime()) / 1000); const questionElapsed = Math.floor((now.getTime() - questionStartTime.getTime()) / 1000); if (totalElapsed >= session.totalTimeLimit || questionElapsed >= session.perQuestionTimeLimit) { session.status = AssessmentStatus.COMPLETED; session.finalReport = totalElapsed >= session.totalTimeLimit ? '评测总时间已用尽,评估已自动结束' : '单题答题时间已用尽,评估已自动结束'; if (session.finalScore === null || session.finalScore === undefined) { session.finalScore = 0; } await this.sessionRepository.save(session); this.logger.log(`[submitAnswerStream] Session ${sessionId} auto-ended due to timeout`); observer.next({ type: 'final', assessmentSessionId: sessionId, status: 'COMPLETED', timeout: true, finalScore: session.finalScore, finalReport: session.finalReport, }); observer.complete(); return; } } const model = await this.getModel(session.tenantId); const content = await this.getSessionContent(session); await this.ensureGraphState(sessionId, session); const graphState = await this.graph.getState({ configurable: { thread_id: sessionId }, }); const hasState = graphState && graphState.values && Object.keys(graphState.values).length > 0; this.logger.debug( `[AssessmentService] submitAnswerStream: sessionId=${sessionId}, hasState=${hasState}, nextNodes=[${graphState.next || ''}]`, ); // Update state with human message first to ensure it's in history await this.graph.updateState( { configurable: { thread_id: sessionId } }, { messages: [new HumanMessage(answer)] }, ); // Resume from the last interrupt const stream = await this.graph.stream(null, { configurable: { thread_id: sessionId, model, knowledgeBaseContent: content, language: session.language || language, targetCount: session.templateJson?.questionCount || 5, }, streamMode: ['values', 'updates'], }); let streamCount = 0; let hasEmittedNodes = false; for await (const [mode, data] of stream) { streamCount++; this.logger.debug('[submitAnswerStream] Stream event:', streamCount, mode, Object.keys(data || {})); this.logger.debug('[submitAnswerStream] Data detail:', JSON.stringify(data).substring(0, 500)); if (mode === 'updates') { hasEmittedNodes = true; const node = Object.keys(data)[0]; const updateData = { ...data[node] }; // Skip interrupt nodes - they have no useful data if (node === '__interrupt__' || !updateData || Object.keys(updateData).length === 0) { this.logger.debug('[submitAnswerStream] Skipping empty interrupt node'); continue; } this.logger.debug('[submitAnswerStream] Node update:', node, { hasMessages: !!updateData.messages, messageCount: updateData.messages?.length, currentIndex: updateData.currentQuestionIndex, dataKeys: Object.keys(updateData).join(',') }); this.logger.debug('[submitAnswerStream] Sending to frontend:', JSON.stringify(updateData).substring(0, 500)); if (updateData.messages) { updateData.messages = this.mapMessages(updateData.messages); } if (updateData.feedbackHistory) { updateData.feedbackHistory = this.mapMessages( updateData.feedbackHistory, ); } observer.next({ type: 'node', node, data: updateData }); } else if (mode === 'values') { this.logger.debug('[submitAnswerStream] Values update - keys:', Object.keys(data || {})); } } // After stream, get authoritative state const fullState = await this.graph.getState({ configurable: { thread_id: sessionId }, }); const finalData = fullState.values as EvaluationState; // Force emit the next question if stream didn't emit updates (hasEmittedNodes is false) this.logger.debug('[submitAnswerStream] Force check:', { hasEmittedNodes, hasFinalData: !!finalData, hasQuestions: !!finalData?.questions, qLen: finalData?.questions?.length, emittedNextQuestion }); if (!hasEmittedNodes && finalData && finalData.questions && finalData.questions.length > 0 && !emittedNextQuestion) { const currentIndex = finalData.currentQuestionIndex || 0; const nextQuestion = finalData.questions[currentIndex]; if (nextQuestion) { const questionText = nextQuestion.questionText || ''; this.logger.debug('[submitAnswerStream] Forcing emit next question:', { currentIndex, questionPreview: questionText.substring(0, 50) }); const { HumanMessage, AIMessage } = await import('@langchain/core/messages'); observer.next({ type: 'node', node: 'interviewer', data: { messages: [new AIMessage(`问题 ${currentIndex + 1}: ${questionText}\n\n请提供您的回答。`)], currentQuestionIndex: currentIndex, questions: finalData.questions, shouldFollowUp: false, } }); emittedNextQuestion = true; } } if (finalData && finalData.messages) { this.logger.debug( `[AssessmentService] submitAnswerStream Final Authoritative State messages:`, finalData.messages.length, ); session.messages = finalData.messages; session.feedbackHistory = finalData.feedbackHistory || []; session.questions_json = finalData.questions; session.currentQuestionIndex = finalData.currentQuestionIndex; session.followUpCount = finalData.followUpCount || 0; if (finalData.report) { session.status = AssessmentStatus.COMPLETED; session.finalReport = finalData.report; const scores = finalData.scores; const questions = finalData.questions || []; const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 }; const passingScore = (session.templateJson?.passingScore ?? 60) / 10; if (questions.length > 0 && Object.keys(scores).length > 0) { const { finalScore, dimensionScores, radarData } = this.calculateScores( questions, scores, weightConfig, ); session.finalScore = finalScore; (session as any).dimensionScores = dimensionScores; (session as any).radarData = radarData; (session as any).passed = finalScore >= passingScore; this.logger.log( `[DimensionScoring] Session ${sessionId} Final Score: ${finalScore}, Passed: ${finalScore >= passingScore}`, ); } } await this.sessionRepository.save(session); const mappedData: any = this.sanitizeStateForClient( { ...finalData }, session.status !== AssessmentStatus.COMPLETED, ); mappedData.messages = this.mapMessages(finalData.messages); mappedData.feedbackHistory = this.mapMessages( finalData.feedbackHistory || [], ); mappedData.status = session.status; mappedData.report = session.finalReport; mappedData.finalScore = session.finalScore; mappedData.passed = (session as any).passed; observer.next({ type: 'final', data: mappedData }); } observer.complete(); } catch (err) { observer.error(err); } })(); }); } /** * Retrieves the current state of a session. */ async getSessionState(sessionId: string, userId: string): Promise { this.logger.log( `Retrieving state for session ${sessionId} for user ${userId}`, ); const session = await this.sessionRepository.findOne({ where: { id: sessionId, userId }, relations: ['template'], }); if (!session) throw new NotFoundException('Session not found'); // Ensure graph has state (lazy init or recovery) await this.ensureGraphState(sessionId, session); const state = await this.graph.getState({ configurable: { thread_id: sessionId }, }); const values = { ...state.values }; if (values.messages) { values.messages = this.mapMessages(values.messages); } if (values.feedbackHistory) { values.feedbackHistory = this.mapMessages(values.feedbackHistory); } // Determine stripAnswers: strip if in-progress, or if completed but reviewMode is 'none' let stripAnswers = session.status !== AssessmentStatus.COMPLETED; if (session.status === AssessmentStatus.COMPLETED) { const templateData = session.templateJson as any; const reviewMode = templateData?.reviewMode || 'none'; if (reviewMode === 'none') { stripAnswers = true; } } return this.sanitizeStateForClient(values, stripAnswers); } /** * P2: Get completed session review with correct answers. * Requires reviewMode != 'none' on the template. */ async getSessionReview(sessionId: string, userId: string): Promise { this.logger.log(`getSessionReview: session=${sessionId}, user=${userId}`); const session = await this.sessionRepository.findOne({ where: { id: sessionId, userId }, }); if (!session) throw new NotFoundException('Session not found'); if (session.status !== AssessmentStatus.COMPLETED) { throw new BadRequestException('只能在考核完成后查看回顾'); } const templateData = session.templateJson as any; const reviewMode = templateData?.reviewMode || 'none'; if (reviewMode === 'none') { throw new BadRequestException('当前模板未开启答题回顾功能'); } // Return state with answers visible await this.ensureGraphState(sessionId, session); const state = await this.graph.getState({ configurable: { thread_id: sessionId }, }); const values = { ...state.values }; if (values.messages) values.messages = this.mapMessages(values.messages); if (values.feedbackHistory) values.feedbackHistory = this.mapMessages(values.feedbackHistory); return this.sanitizeStateForClient(values, false); } /** * Retrieves assessment session history for a user. */ async getHistory( userId: string, tenantId: string, ): Promise { const history = await this.sessionRepository.find({ where: { userId, tenantId }, order: { createdAt: 'DESC' }, relations: ['knowledgeBase', 'knowledgeGroup'], }); // Map questions_json to questions for frontend compatibility const mappedHistory = history.map((session) => ({ ...session, questions: session.questions_json || [], })) as any; this.logger.log(`Found ${history.length} historical sessions`); return mappedHistory; } /** * Deletes an assessment session. */ async deleteSession(sessionId: string, user: any): Promise { this.logger.log( `Deleting session ${sessionId} for user ${user.id} (role: ${user.role})`, ); const userId = user.id; const isAdmin = user.role === 'super_admin' || user.role === 'admin'; await this.dataSource.transaction(async (manager) => { const deleteCondition: any = { id: sessionId }; if (!isAdmin) { deleteCondition.userId = userId; } const session = await manager.findOne(AssessmentSession, { where: deleteCondition }); if (!session) { throw new NotFoundException('Session not found or you do not have permission to delete it'); } await manager.delete(AssessmentCertificate, { sessionId }); await manager.delete(AssessmentSession, { id: sessionId }); }); try { await this.graph.getState({ configurable: { thread_id: sessionId } }); } catch { this.logger.debug(`[deleteSession] No graph state to clean up for ${sessionId}`); } } /** * Ensures the graph checkpointer has the state for the given session. * Useful for lazy initialization and recovery after server restarts. */ private async ensureGraphState( sessionId: string, session: AssessmentSession, ): Promise { const state = await this.graph.getState({ configurable: { thread_id: sessionId }, }); if ( !state.values || Object.keys(state.values).length === 0 || !state.values.messages || state.values.messages.length === 0 ) { const hasHistory = session.messages && session.messages.length > 0; if (hasHistory) { this.logger.log( `[ensureGraphState] Recovering state from DB for session ${sessionId}`, ); const historicalMessages = this.hydrateMessages(session.messages); const existingQuestions = session.questions_json || []; const hasQuestionsFromBank = existingQuestions.length > 0; const scoresRecord: Record = {}; if (session.feedbackHistory) { for (const fh of session.feedbackHistory) { if (fh.score && fh.questionId) scoresRecord[fh.questionId] = fh.score; } } const recoveredState: any = { assessmentSessionId: sessionId, knowledgeBaseId: session.knowledgeBaseId || session.knowledgeGroupId || '', messages: historicalMessages, feedbackHistory: this.hydrateMessages( session.feedbackHistory || [], ), questions: existingQuestions, currentQuestionIndex: session.currentQuestionIndex || 0, followUpCount: session.followUpCount || 0, shouldFollowUp: false, scores: scoresRecord, questionCount: session.templateJson?.questionCount || 5, difficultyDistribution: session.templateJson?.difficultyDistribution, style: session.templateJson?.style, keywords: session.templateJson?.keywords, questionAnswerKey: session.templateJson?.questionAnswerKey, language: session.language || 'zh', report: session.finalReport || undefined, }; if (hasQuestionsFromBank) { this.logger.log( `[ensureGraphState] Using ${existingQuestions.length} questions from question bank`, ); } await this.graph.updateState( { configurable: { thread_id: sessionId } }, recoveredState, 'interviewer', ); } else { this.logger.log(`Initializing new state for session ${sessionId}`); const content = await this.getSessionContent(session); const model = await this.getModel(session.tenantId); const existingQuestions = session.questions_json || []; const hasQuestionsFromBank = existingQuestions.length > 0; const isZh = (session.language || 'en') === 'zh'; const isJa = session.language === 'ja'; const initialState: Partial = { assessmentSessionId: sessionId, knowledgeBaseId: session.knowledgeBaseId || session.knowledgeGroupId || '', messages: hasQuestionsFromBank ? [new HumanMessage( isZh ? '我已准备好回答问题。' : isJa ? '質問への回答準備ができています。' : 'I am ready to answer the questions.', )] : [], questionCount: session.templateJson?.questionCount, difficultyDistribution: session.templateJson?.difficultyDistribution, style: session.templateJson?.style, keywords: session.templateJson?.keywords, questionAnswerKey: session.templateJson?.questionAnswerKey, language: session.language || 'en', questions: hasQuestionsFromBank ? existingQuestions : undefined, }; this.logger.log( `[ensureGraphState] Initializing with questionCount=${initialState.questionCount}, keywords=${initialState.keywords?.join(',')}, style=${initialState.style}`, ); const resultStream = await this.graph.stream(initialState, { configurable: { thread_id: sessionId, model, knowledgeBaseContent: content, language: session.language || 'en', targetCount: session.templateJson?.questionCount || 5, keywords: session.templateJson?.keywords, questionCount: session.templateJson?.questionCount, difficultyDistribution: session.templateJson?.difficultyDistribution, style: session.templateJson?.style, }, streamMode: ['values', 'updates'], }); let finalInvokeResult: any = null; const nodes: string[] = []; for await (const [mode, data] of resultStream) { if (mode === 'values') finalInvokeResult = data; else if (mode === 'updates') nodes.push(...Object.keys(data)); } if (finalInvokeResult.messages) { session.messages = finalInvokeResult.messages; session.feedbackHistory = finalInvokeResult.feedbackHistory || []; session.questions_json = finalInvokeResult.questions; session.currentQuestionIndex = finalInvokeResult.currentQuestionIndex; session.followUpCount = finalInvokeResult.followUpCount || 0; await this.sessionRepository.save(session); } } } } /** * Re-hydrates plain objects from DB into LangChain message instances. */ private hydrateMessages(messages: any[]): BaseMessage[] { if (!messages) return []; return messages.map((m) => { if (m instanceof BaseMessage) return m; const content = m.content || m.text || (typeof m === 'string' ? m : ''); const type = m.role || m.type || m._getType?.() || 'ai'; if (type === 'human' || type === 'user') { return new HumanMessage(content); } else if (type === 'ai' || type === 'assistant') { return new AIMessage(content); } else if (type === 'system') { return new SystemMessage(content); } return new AIMessage(content); }); } /** * Strips sensitive fields before sending state to frontend. */ private sanitizeStateForClient(data: any, stripAnswers = true): any { if (!data) return data; const sanitized = { ...data }; if (stripAnswers) { delete sanitized.questionAnswerKey; } if (Array.isArray(sanitized.questions)) { sanitized.questions = sanitized.questions.map((q: any) => { if (stripAnswers) { const { correctAnswer, judgment, followupHints, ...rest } = q; return rest; } return q; }); } return sanitized; } /** * Maps LangChain messages to a simple format for the frontend and storage. */ private mapMessages(messages: BaseMessage[]): any[] { if (!messages) return []; return messages.map((msg) => { const type = msg._getType(); let role: 'user' | 'assistant' | 'system' = 'system'; if (type === 'human') role = 'user'; else if (type === 'ai') role = 'assistant'; else if (type === 'system') role = 'system'; return { role, content: msg.content, type, // Also store the LangChain type for easier hydration timestamp: (msg as any).timestamp || Date.now(), }; }); } async generateCertificate( sessionId: string, userId: string, tenantId: string, ): Promise { const session = await this.sessionRepository.findOne({ where: { id: sessionId, userId }, }); if (!session) { throw new NotFoundException('Session not found'); } if (session.status !== AssessmentStatus.COMPLETED) { throw new BadRequestException('Session not completed yet'); } const existing = await this.certificateRepository.findOne({ where: { sessionId }, }); if (existing) { return existing; } const passingThreshold = (session.templateJson?.passingScore ?? 60) / 10; const level = this.determineLevel(session.finalScore || 0, !!(session as any).passed, passingThreshold); const qrCode = `cert://${sessionId}-${Date.now()}`; const questionDetails = (session.questions_json || []).map((q: any, i: number) => ({ index: i + 1, questionText: q.questionText?.substring(0, 100) || '', questionType: q.questionType || 'SHORT_ANSWER', dimension: q.dimension || '', })); const certificate = this.certificateRepository.create({ userId, sessionId, templateId: session.templateId || '', level, totalScore: session.finalScore || 0, qrCode, dimensionScores: (session as any).dimensionScores, radarData: (session as any).radarData, passed: (session as any).passed || false, }); const saved = await this.certificateRepository.save(certificate); return { ...saved, templateName: session.template?.name || session.templateJson?.name || '-', userName: session.user?.displayName || session.user?.username || '', questionDetails, } as any; } private determineLevel(score: number, passed: boolean, passingThreshold: number): string { if (!passed) return 'Novice'; if (score >= 9) return 'Expert'; if (score >= 7) return 'Advanced'; return 'Proficient'; } async getStats( userId: string, tenantId: string, role: string, startDate?: string, endDate?: string, templateId?: string, knowledgeGroupId?: string, ): Promise { const isAdmin = role === 'super_admin' || role === 'admin'; const qb = this.sessionRepository.createQueryBuilder('session'); qb.where('session.tenantId = :tenantId', { tenantId }); if (!isAdmin) { qb.andWhere('session.userId = :userId', { userId }); } if (startDate) { qb.andWhere('session.createdAt >= :startDate', { startDate: new Date(startDate) }); } if (endDate) { qb.andWhere('session.createdAt <= :endDate', { endDate: new Date(endDate) }); } if (templateId) { qb.andWhere('session.templateId = :templateId', { templateId }); } if (knowledgeGroupId) { qb.andWhere('session.knowledgeGroupId = :knowledgeGroupId', { knowledgeGroupId }); } const sessions = await qb .leftJoinAndSelect('session.template', 'template') .leftJoinAndSelect('session.knowledgeGroup', 'knowledgeGroup') .orderBy('session.createdAt', 'DESC') .take(100) .getMany(); const totalAttempts = sessions.length; const completedSessions = sessions.filter(s => s.status === AssessmentStatus.COMPLETED); const completedCount = completedSessions.length; const scores = completedSessions .map(s => s.finalScore) .filter((score): score is number => score !== null && score !== undefined); const highestScore = scores.length > 0 ? Math.max(...scores) : 0; const averageScore = scores.length > 0 ? Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 10) / 10 : 0; const completionRate = totalAttempts > 0 ? Math.round((completedCount / totalAttempts) * 1000) / 10 : 0; const recentRecords = sessions.slice(0, 20).map(session => ({ id: session.id, userId: session.userId, knowledgeBase: session.knowledgeBase?.title || session.knowledgeBase?.originalName || session.knowledgeGroup?.name || '-', template: session.template?.name || '-', score: session.finalScore || null, status: session.status, createdAt: session.createdAt, user: isAdmin ? { id: session.userId } : undefined, })); return { totalAttempts, highestScore, averageScore, completionRate, recentRecords, }; } async getRadarStats(userId: string, tenantId: string, role: string, templateId?: string): Promise { const isAdmin = role === 'super_admin' || role === 'admin'; const qb = this.sessionRepository.createQueryBuilder('session'); qb.where('session.tenantId = :tenantId', { tenantId }); qb.andWhere('session.status = :status', { status: AssessmentStatus.COMPLETED }); if (!isAdmin) { qb.andWhere('session.userId = :userId', { userId }); } if (templateId) { qb.andWhere('session.templateId = :templateId', { templateId }); } const sessions = await qb.take(100).getMany(); const dimensionScores: Record = {}; for (const session of sessions) { const scores = (session as any).dimensionScores || {}; for (const [dim, score] of Object.entries(scores)) { if (dimensionScores[dim]) { dimensionScores[dim].push(score as number); } else { dimensionScores[dim] = [score as number]; } } } const radarData: Record = {}; for (const [dim, scores] of Object.entries(dimensionScores)) { if (scores.length > 0) { radarData[dim] = Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 10) / 10; } else { radarData[dim] = 0; } } return { radarData, sampleCount: sessions.length }; } async getTrendStats(userId: string, tenantId: string, role: string, startDate?: string, endDate?: string): Promise { const isAdmin = role === 'super_admin' || role === 'admin'; const qb = this.sessionRepository.createQueryBuilder('session'); qb.where('session.tenantId = :tenantId', { tenantId }); qb.andWhere('session.status = :status', { status: AssessmentStatus.COMPLETED }); if (!isAdmin) { qb.andWhere('session.userId = :userId', { userId }); } if (startDate) { qb.andWhere('session.createdAt >= :startDate', { startDate: new Date(startDate) }); } if (endDate) { qb.andWhere('session.createdAt <= :endDate', { endDate: new Date(endDate) }); } const sessions = await qb .orderBy('session.createdAt', 'ASC') .take(50) .getMany(); const trendData = sessions.map(session => ({ date: session.createdAt, score: session.finalScore || 0, template: session.template?.name || '-', })); return { trendData, count: sessions.length }; } async reviewAssessment( sessionId: string, newScore: number, comment: string | undefined, reviewerId: string, tenantId: string, ): Promise { return this.dataSource.transaction(async (manager) => { const session = await manager.findOne(AssessmentSession, { where: { id: sessionId }, }); if (!session) { throw new NotFoundException('Assessment session not found'); } if (session.status !== AssessmentStatus.COMPLETED) { throw new ForbiddenException('Can only review completed assessments'); } const reviewRecord = { reviewedBy: reviewerId, reviewedAt: new Date().toISOString(), originalScore: session.finalScore, newScore: newScore, comment: comment || '', }; const reviewHistory = session.reviewHistory || []; reviewHistory.push(reviewRecord); if (!session.originalScore) { session.originalScore = session.finalScore; } session.finalScore = newScore; const passingScore = (session.templateJson?.passingScore ?? 60) / 10; (session as any).passed = newScore >= passingScore; session.reviewedBy = reviewerId; session.reviewedAt = new Date(); session.reviewComment = comment || null; session.reviewHistory = reviewHistory; await manager.save(session); this.logger.log( `[reviewAssessment] Session ${sessionId} reviewed by ${reviewerId}, score changed from ${reviewRecord.originalScore} to ${newScore}`, ); return session; }); } async getUserHistory(userId: string): Promise { const sessions = await this.sessionRepository.find({ where: { userId, status: AssessmentStatus.COMPLETED }, order: { createdAt: 'DESC' }, take: 3, relations: ['template'], }); return sessions; } private async cleanupOldSessions(userId: string): Promise { const sessions = await this.sessionRepository.find({ where: { userId }, order: { createdAt: 'DESC' }, }); if (sessions.length > 3) { const toDelete = sessions.slice(3); for (const session of toDelete) { await this.sessionRepository.remove(session); } this.logger.log(`[cleanupOldSessions] Deleted ${toDelete.length} old sessions for user ${userId}`); } } async checkTimeLimits(sessionId: string): Promise<{ totalTimeRemaining: number; questionTimeRemaining: number; isTotalTimeout: boolean; isQuestionTimeout: boolean; }> { const session = await this.sessionRepository.findOne({ where: { id: sessionId }, }); if (!session || session.status === AssessmentStatus.COMPLETED) { return { totalTimeRemaining: 0, questionTimeRemaining: 0, isTotalTimeout: true, isQuestionTimeout: true, }; } const now = new Date(); const startTime = session.startedAt ? new Date(session.startedAt) : now; const questionStartTime = session.currentQuestionStartedAt ? new Date(session.currentQuestionStartedAt) : now; const totalElapsed = Math.floor((now.getTime() - startTime.getTime()) / 1000); const questionElapsed = Math.floor((now.getTime() - questionStartTime.getTime()) / 1000); const totalTimeRemaining = Math.max(0, session.totalTimeLimit - totalElapsed); const questionTimeRemaining = Math.max(0, session.perQuestionTimeLimit - questionElapsed); return { totalTimeRemaining, questionTimeRemaining, isTotalTimeout: totalElapsed >= session.totalTimeLimit, isQuestionTimeout: questionElapsed >= session.perQuestionTimeLimit, }; } async updateQuestionStartTime(sessionId: string): Promise { const session = await this.sessionRepository.findOne({ where: { id: sessionId }, }); if (session) { session.currentQuestionStartedAt = new Date(); await this.sessionRepository.save(session); } } async verifyCertificate(certificateId: string): Promise<{ valid: boolean; certificate?: { id: string; level: string; totalScore: number; passed: boolean; issuedAt: Date; }; message?: string; }> { const certificate = await this.certificateRepository.findOne({ where: { id: certificateId }, relations: ['user'], }); if (!certificate) { return { valid: false, message: 'Certificate not found' }; } return { valid: true, certificate: { id: certificate.id, level: certificate.level, totalScore: certificate.totalScore, passed: certificate.passed, issuedAt: certificate.issuedAt, }, }; } async getPublicCertificateInfo(sessionId: string): Promise<{ exists: boolean; certificate?: { level: string; totalScore: number; passed: boolean; issuedAt: Date; dimensionScores: Record; }; message?: string; }> { const certificate = await this.certificateRepository.findOne({ where: { sessionId }, }); if (!certificate) { return { exists: false, message: 'Certificate not found for this session' }; } return { exists: true, certificate: { level: certificate.level, totalScore: certificate.totalScore, passed: certificate.passed, issuedAt: certificate.issuedAt, dimensionScores: certificate.dimensionScores || {}, }, }; } async batchDeleteSessions(ids: string[], user: any): Promise { const isAdmin = user.role === 'super_admin' || user.role === 'admin'; return this.dataSource.transaction(async (manager) => { const query: any = { id: In(ids) }; if (!isAdmin) { query.userId = user.id; } const sessions = await manager.find(AssessmentSession, { where: query }); const sessionIds = sessions.map((s) => s.id); if (sessionIds.length === 0) { return 0; } await manager.delete(AssessmentCertificate, { sessionId: In(sessionIds) }); const result = await manager.delete(AssessmentSession, { id: In(sessionIds) }); this.logger.log(`[batchDeleteSessions] Deleted ${sessionIds.length} sessions`); return result.affected || 0; }); } async batchExportSessions(ids: string[], userId: string): Promise { const sessions = await this.sessionRepository.find({ where: { id: In(ids), userId }, relations: ['questions'], }); return sessions.map((s) => ({ id: s.id, status: s.status, finalScore: s.finalScore, startedAt: s.startedAt, createdAt: s.createdAt, totalTimeLimit: s.totalTimeLimit, questionCount: s.questions?.length || 0, })); } async forceEndAssessment(sessionId: string): Promise { const session = await this.sessionRepository.findOne({ where: { id: sessionId }, }); if (!session) { throw new NotFoundException('Assessment session not found'); } if (session.status === AssessmentStatus.COMPLETED) { return session; } session.status = AssessmentStatus.COMPLETED; session.finalReport = '评估已被管理员强制结束'; session.finalScore = 0; await this.sessionRepository.save(session); this.logger.log(`[forceEndAssessment] Session ${sessionId} force ended by admin`); return session; } }