d15e881591
全量回归测试(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>
2057 lines
76 KiB
TypeScript
2057 lines
76 KiB
TypeScript
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;
|
||
}
|
||
}
|