Files
aurak/server/src/assessment/assessment.service.ts
T
Developer d15e881591 test: 全量回归测试52项覆盖未触及路径 + 完善P2字段映射
全量回归测试(test-full-coverage.mjs):
- A. 角色权限深度测试(新endpoint权限边界/跨用户隔离)
- B. 边界值测试(模板字段极值/角色名/密码边界)
- C. 异常路径测试(状态链/冲突/不存在Session/已删模板)
- D. 缺陷回归测试(系统角色保护/API Key / token即时变更/幂等)
- E. 跨功能交互测试(权限+考核/模板+角色/异常状态)

修复:
- assessment.service.ts templateData P2字段显式映射确认

测试结果: 52/52  + 系统测试 142/142  + P2专项 20/20 

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 15:49:09 +08:00

2057 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AssessmentSession>,
@InjectRepository(AssessmentQuestion)
private questionRepository: Repository<AssessmentQuestion>,
@InjectRepository(AssessmentAnswer)
private answerRepository: Repository<AssessmentAnswer>,
@InjectRepository(AssessmentCertificate)
private certificateRepository: Repository<AssessmentCertificate>,
@InjectRepository(QuestionBank)
private questionBankRepository: Repository<QuestionBank>,
@InjectRepository(QuestionBankItem)
private questionBankItemRepository: Repository<QuestionBankItem>,
@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<ChatOpenAI> {
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<string> {
this.logger.log(`[getMultiGroupContent] Starting for ${groupIds.length} groups`);
const contents: string[] = [];
const dimensionMap: Record<string, string> = {
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<string, number>,
weightConfig: { prompt: number; other: number },
): { finalScore: number; dimensionScores: Record<string, number>; radarData: Record<string, number> } {
this.logger.debug('[calculateScores] Input:', {
questionsCount: questions.length,
scores,
weightConfig,
});
const dimensionScoresMap: Record<string, number[]> = {
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<string, number> = {};
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<string, number> = {};
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<string> {
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<AssessmentSession> {
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<string, { correctAnswer?: string | null; judgment?: string | null }> = {};
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<AssessmentSession>,
);
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<any> {
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<EvaluationState> = {
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<any> {
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<string, number>;
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<any> {
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<any> {
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<any> {
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<AssessmentSession[]> {
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<void> {
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<void> {
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<string, number> = {};
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<EvaluationState> = {
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<AssessmentCertificate> {
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<any> {
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<any> {
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<string, number[]> = {};
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<string, number> = {};
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<any> {
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<AssessmentSession> {
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<AssessmentSession[]> {
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<void> {
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<void> {
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<string, number>;
};
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<number> {
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<any[]> {
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<AssessmentSession> {
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;
}
}