Files
aurak/server/src/assessment/assessment.service.ts
T
Developer 8686d101cd Initial commit: AuraK人才测评系统基础框架
## 已实现功能
- 题库管理后端API完整实现
- 模板管理页面(Settings-测评模板)
- 评估统计页面
- 人才测评页面(AssessmentView)
- QuestionBank前端服务层

## 技术栈
- 后端: Node.js + NestJS + TypeORM
- 前端: React + TypeScript
- 容器化: Docker Compose

## 已知待完善
- 题库列表页缺少删除按钮
- 题库详情页未实现(题目管理/AI生成/审核)
2026-05-13 21:32:41 +08:00

1442 lines
53 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,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DeepPartial, In } 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 } 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 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 calculateScores(
questions: any[],
scores: Record<string, number>,
weightConfig: { prompt: number; other: number },
): { finalScore: number; dimensionScores: Record<string, number>; radarData: Record<string, number> } {
console.log('[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 = 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;
console.log('[calculateScores] Scoring debug:', { promptAvg, otherDimsWithScores, otherAvg, workCapability: dimensionAverages.workCapability });
const finalScore = promptAvg * (weightConfig.prompt / 100) + otherAvg * (weightConfig.other / 100);
const radarData: Record<string, number> = {};
Object.keys(dimensionAverages).forEach(dim => {
radarData[dim] = Math.round(dimensionAverages[dim] * 10) / 10;
});
console.log('[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}`,
);
}
// Use kbId if provided, otherwise fall back to template's group ID
const activeKbId = kbId || template?.knowledgeGroupId;
this.logger.log(`[startSession] activeKbId resolved to: ${activeKbId}`);
if (!activeKbId) {
this.logger.error(`[startSession] No knowledge source resolved`);
throw new Error('Knowledge source (ID or Template) must be provided.');
}
// Try to determine if it's a KB or Group and check permissions
let isKb = false;
try {
await this.kbService.findOne(activeKbId, userId, tenantId);
isKb = true;
} catch (kbError) {
if (kbError instanceof NotFoundException) {
// Try finding it as a Group
try {
await this.groupService.findOne(activeKbId, userId, tenantId);
} catch (groupError) {
this.logger.error(
`[startSession] Knowledge source ${activeKbId} not found as KB or Group`,
);
throw new NotFoundException(
this.i18nService.getMessage('knowledgeSourceNotFound') ||
'Knowledge source not found',
);
}
} else {
throw kbError; // e.g. ForbiddenException
}
}
this.logger.debug(`[startSession] isKb: ${isKb}`);
const templateData = 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,
linkedGroupIds: template.linkedGroupIds,
}
: undefined;
let questionsFromBank: any[] = [];
let questionSource: 'bank' | 'generator' = 'generator';
if (templateId) {
try {
const targetCount = template?.questionCount || 5;
const publishedBanks = await this.questionBankRepository.find({
where: { templateId, status: QuestionBankStatus.PUBLISHED },
});
if (publishedBanks.length > 0) {
const bankIds = publishedBanks.map(b => b.id);
const questionCount = await this.questionBankItemRepository.count({
where: { bankId: In(bankIds) },
});
this.logger.log(
`[startSession] Found ${publishedBanks.length} published banks with ${questionCount} questions, target: ${targetCount}`,
);
if (questionCount >= targetCount) {
const bankId = publishedBanks[0].id;
const selectedItems = await this.questionBankService.selectQuestions(
bankId,
targetCount,
);
questionsFromBank = selectedItems.map(item => ({
id: item.id,
questionText: item.questionText,
questionType: item.questionType,
keyPoints: item.keyPoints,
difficulty: item.difficulty,
dimension: item.dimension,
basis: item.basis,
}));
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,
};
const content = await this.getSessionContent(sessionData);
if (!content || content.trim().length < 10) {
this.logger.error(
`[startSession] Insufficient content length: ${content?.length || 0}`,
);
throw new Error(
'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`,
);
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);
const content = await this.getSessionContent(session);
// Check if questions already exist in session (from question bank)
const existingQuestions = session.questions_json || [];
const hasExistingQuestions = existingQuestions.length > 0;
// 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 = { ...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,
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) {
console.log(
`[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 || 90;
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 = { ...finalData };
mappedData.messages = this.mapMessages(finalData.messages);
mappedData.feedbackHistory = this.mapMessages(
finalData.feedbackHistory || [],
);
mappedData.status = session.status;
mappedData.report = session.finalReport;
mappedData.finalScore = session.finalScore;
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');
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 || 90;
// 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 || 90;
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> {
console.log('[submitAnswerStream] START - sessionId:', sessionId, 'answer length:', answer?.length);
let emittedNextQuestion = false;
let hasEmittedNodes = false;
return new Observable((observer) => {
(async () => {
try {
console.log('[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;
}
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;
console.log(
`[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++;
console.log('[submitAnswerStream] Stream event:', streamCount, mode, Object.keys(data || {}));
console.log('[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) {
console.log('[submitAnswerStream] Skipping empty interrupt node');
continue;
}
console.log('[submitAnswerStream] Node update:', node, {
hasMessages: !!updateData.messages,
messageCount: updateData.messages?.length,
currentIndex: updateData.currentQuestionIndex,
dataKeys: Object.keys(updateData).join(',')
});
console.log('[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') {
console.log('[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)
console.log('[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 || '';
console.log('[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) {
console.log(
`[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 || 90;
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 = { ...finalData };
mappedData.messages = this.mapMessages(finalData.messages);
mappedData.feedbackHistory = this.mapMessages(
finalData.feedbackHistory || [],
);
mappedData.status = session.status;
mappedData.report = session.finalReport;
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);
}
return values;
}
/**
* 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';
const deleteCondition: any = { id: sessionId };
if (!isAdmin) {
deleteCondition.userId = userId;
}
const result = await this.sessionRepository.delete(deleteCondition);
if (result.affected === 0) {
throw new NotFoundException(
'Session not found or you do not have permission to delete it',
);
}
}
/**
* 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;
if (hasQuestionsFromBank) {
this.logger.log(
`[ensureGraphState] Using ${existingQuestions.length} questions from question bank`,
);
await this.graph.updateState(
{ configurable: { thread_id: sessionId } },
{
assessmentSessionId: sessionId,
knowledgeBaseId:
session.knowledgeBaseId || session.knowledgeGroupId || '',
messages: historicalMessages,
feedbackHistory: this.hydrateMessages(
session.feedbackHistory || [],
),
questions: existingQuestions,
currentQuestionIndex: session.currentQuestionIndex || 0,
followUpCount: session.followUpCount || 0,
questionCount: session.templateJson?.questionCount || 5,
difficultyDistribution:
session.templateJson?.difficultyDistribution,
style: session.templateJson?.style,
keywords: session.templateJson?.keywords,
},
'grader',
);
} else {
await this.graph.updateState(
{ configurable: { thread_id: sessionId } },
{
assessmentSessionId: sessionId,
knowledgeBaseId:
session.knowledgeBaseId || session.knowledgeGroupId || '',
messages: historicalMessages,
feedbackHistory: this.hydrateMessages(
session.feedbackHistory || [],
),
questions: session.questions_json || [],
currentQuestionIndex: session.currentQuestionIndex || 0,
followUpCount: session.followUpCount || 0,
questionCount: session.templateJson?.questionCount || 5,
difficultyDistribution:
session.templateJson?.difficultyDistribution,
style: session.templateJson?.style,
keywords: session.templateJson?.keywords,
},
'grader',
);
}
} else {
this.logger.log(`Initializing new state for session ${sessionId}`);
const content = await this.getSessionContent(session);
const model = await this.getModel(session.tenantId);
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,
language: session.language || 'en',
};
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);
});
}
/**
* 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 Error('Session not completed');
}
const existing = await this.certificateRepository.findOne({
where: { sessionId },
});
if (existing) {
return existing;
}
const level = this.determineLevel(session.finalScore || 0);
const qrCode = `cert://${sessionId}-${Date.now()}`;
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,
});
return this.certificateRepository.save(certificate);
}
private determineLevel(score: number): string {
if (score >= 90) return 'Expert';
if (score >= 75) return 'Advanced';
if (score >= 60) return 'Proficient';
return 'Novice';
}
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,
};
}
}