feat: implement QuestionBank CRUD with pagination and template query

- Add pagination support to findAll (page, limit query params)
- Add findByTemplateId method to service
- Add GET /by-template/:templateId endpoint to controller
- Service already includes CRUD for QuestionBank and QuestionBankItem
This commit is contained in:
Developer
2026-04-23 17:19:11 +08:00
commit 0a9588abb7
492 changed files with 112453 additions and 0 deletions
@@ -0,0 +1,39 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Reflector } from '@nestjs/core';
import { AssessmentController } from './assessment.controller';
import { AssessmentService } from './assessment.service';
import { TenantService } from '../tenant/tenant.service';
import { UserService } from '../user/user.service';
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
describe('AssessmentController', () => {
let controller: AssessmentController;
const mockService = () => ({});
const mockReflector = () => ({
get: jest.fn(),
getAllAndOverride: jest.fn(),
});
const mockGuard = () => ({
canActivate: jest.fn().mockReturnValue(true),
});
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AssessmentController],
providers: [
{ provide: AssessmentService, useFactory: mockService },
{ provide: 'UserService', useFactory: mockService },
{ provide: TenantService, useFactory: mockService },
{ provide: Reflector, useFactory: mockReflector },
{ provide: CombinedAuthGuard, useFactory: mockGuard },
],
}).compile();
controller = module.get<AssessmentController>(AssessmentController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
@@ -0,0 +1,161 @@
import {
Controller,
Post,
Body,
Get,
Param,
UseGuards,
Request,
Sse,
MessageEvent,
Query,
Delete,
} from '@nestjs/common';
import { map } from 'rxjs/operators';
import { AssessmentService } from './assessment.service';
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
@ApiTags('Assessment')
@Controller('assessment')
@UseGuards(CombinedAuthGuard)
export class AssessmentController {
constructor(private readonly assessmentService: AssessmentService) {}
@Post('start')
@ApiOperation({ summary: 'Start a new assessment session' })
async startSession(
@Request() req: any,
@Body()
body: { knowledgeBaseId?: string; language?: string; templateId?: string },
) {
const { id: userId, tenantId } = req.user;
console.log(
`[AssessmentController] startSession: user=${userId}, tenant=${tenantId}, templateId=${body.templateId}, kbId=${body.knowledgeBaseId}`,
);
return this.assessmentService.startSession(
userId,
body.knowledgeBaseId,
tenantId,
body.language,
body.templateId,
);
}
@Post(':id/answer')
@ApiOperation({ summary: 'Submit an answer to the current question' })
async submitAnswer(
@Request() req: any,
@Param('id') sessionId: string,
@Body() body: { answer: string; language?: string },
) {
const { id: userId } = req.user;
console.log(
`[AssessmentController] >>> submitAnswer CALLED: user=${userId}, session=${sessionId}, answerLen=${body.answer?.length}`,
);
return this.assessmentService.submitAnswer(
sessionId,
userId,
body.answer,
body.language,
);
}
@Sse(':id/start-stream')
@ApiOperation({ summary: 'Stream initial session generation' })
startSessionStream(@Request() req: any, @Param('id') sessionId: string) {
const { id: userId } = req.user;
console.log(
`[AssessmentController] startSessionStream: user=${userId}, session=${sessionId}`,
);
return this.assessmentService
.startSessionStream(sessionId, userId)
.pipe(map((data) => ({ data }) as MessageEvent));
}
@Sse(':id/answer-stream')
@ApiOperation({
summary: 'Stream answer evaluation and next question generation',
})
submitAnswerStream(
@Request() req: any,
@Param('id') sessionId: string,
@Query('answer') answer: string,
@Query('language') language?: string,
) {
const { id: userId } = req.user;
console.log(
`[AssessmentController] >>> submitAnswerStream CALLED: user=${userId}, session=${sessionId}, answerLen=${answer?.length}, lang=${language}`,
);
return this.assessmentService
.submitAnswerStream(sessionId, userId, answer, language)
.pipe(map((data) => ({ data }) as MessageEvent));
}
@Get(':id/state')
@ApiOperation({ summary: 'Get the current state of an assessment session' })
async getSessionState(@Request() req: any, @Param('id') sessionId: string) {
const { id: userId } = req.user;
console.log(
`[AssessmentController] getSessionState: user=${userId}, session=${sessionId}`,
);
return this.assessmentService.getSessionState(sessionId, userId);
}
@Get()
@ApiOperation({ summary: 'Get assessment session history' })
async getHistory(@Request() req: any) {
const { id: userId, tenantId } = req.user;
console.log(
`[AssessmentController] getHistory: user=${userId}, tenant=${tenantId}`,
);
return this.assessmentService.getHistory(userId, tenantId);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete an assessment session' })
async deleteSession(@Request() req: any, @Param('id') sessionId: string) {
const user = req.user;
console.log(
`[AssessmentController] deleteSession: user=${user.id}, role=${user.role}, session=${sessionId}`,
);
return this.assessmentService.deleteSession(sessionId, user);
}
@Get(':id/certificate')
@ApiOperation({ summary: 'Get certificate for completed assessment' })
async getCertificate(
@Request() req: any,
@Param('id') sessionId: string,
) {
const { id: userId, tenantId } = req.user;
console.log(
`[AssessmentController] getCertificate: user=${userId}, session=${sessionId}`,
);
return this.assessmentService.generateCertificate(sessionId, userId, tenantId);
}
@Get('stats')
@ApiOperation({ summary: 'Get assessment statistics for admin' })
async getStats(
@Request() req: any,
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
@Query('templateId') templateId?: string,
@Query('knowledgeGroupId') knowledgeGroupId?: string,
) {
const { id: userId, tenantId, role } = req.user;
console.log(
`[AssessmentController] getStats: user=${userId}, role=${role}, tenant=${tenantId}`,
);
return this.assessmentService.getStats(
userId,
tenantId,
role,
startDate,
endDate,
templateId,
knowledgeGroupId,
);
}
}
@@ -0,0 +1,55 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssessmentService } from './assessment.service';
import { AssessmentController } from './assessment.controller';
import { AssessmentSession } 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 } from './entities/question-bank.entity';
import { QuestionBankItem } from './entities/question-bank-item.entity';
import { KnowledgeBaseModule } from '../knowledge-base/knowledge-base.module';
import { KnowledgeGroupModule } from '../knowledge-group/knowledge-group.module';
import { ModelConfigModule } from '../model-config/model-config.module';
import { ChatModule } from '../chat/chat.module';
import { ElasticsearchModule } from '../elasticsearch/elasticsearch.module';
import { RagModule } from '../rag/rag.module';
import { TenantModule } from '../tenant/tenant.module';
import { TemplateService } from './services/template.service';
import { TemplateController } from './controllers/template.controller';
import { QuestionBankController } from './controllers/question-bank.controller';
import { ContentFilterService } from './services/content-filter.service';
import { QuestionOutlineService } from './services/question-outline.service';
import { QuestionBankService } from './services/question-bank.service';
@Module({
imports: [
TypeOrmModule.forFeature([
AssessmentSession,
AssessmentQuestion,
AssessmentAnswer,
AssessmentTemplate,
AssessmentCertificate,
QuestionBank,
QuestionBankItem,
]),
forwardRef(() => KnowledgeBaseModule),
forwardRef(() => KnowledgeGroupModule),
forwardRef(() => ModelConfigModule),
forwardRef(() => ChatModule),
ElasticsearchModule,
RagModule,
TenantModule,
],
controllers: [AssessmentController, TemplateController, QuestionBankController],
providers: [
AssessmentService,
TemplateService,
ContentFilterService,
QuestionOutlineService,
QuestionBankService,
],
exports: [AssessmentService, TemplateService, QuestionOutlineService, QuestionBankService],
})
export class AssessmentModule {}
@@ -0,0 +1,78 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AssessmentService } from './assessment.service';
import { AssessmentSession } from './entities/assessment-session.entity';
import { AssessmentQuestion } from './entities/assessment-question.entity';
import { AssessmentAnswer } from './entities/assessment-answer.entity';
import { AssessmentCertificate } from './entities/assessment-certificate.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 { ConfigService } from '@nestjs/config';
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 { RagService } from '../rag/rag.service';
import { ChatService } from '../chat/chat.service';
import { I18nService } from '../i18n/i18n.service';
import { TenantService } from '../tenant/tenant.service';
import { NotFoundException } from '@nestjs/common';
describe('AssessmentService', () => {
let service: AssessmentService;
let sessionRepository: any;
const mockRepository = () => ({
delete: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
});
const mockService = () => ({});
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AssessmentService,
{ provide: getRepositoryToken(AssessmentSession), useFactory: mockRepository },
{ provide: getRepositoryToken(AssessmentQuestion), useFactory: mockRepository },
{ provide: getRepositoryToken(AssessmentAnswer), useFactory: mockRepository },
{ provide: getRepositoryToken(AssessmentCertificate), useFactory: mockRepository },
{ provide: KnowledgeBaseService, useFactory: mockService },
{ provide: KnowledgeGroupService, useFactory: mockService },
{ provide: ModelConfigService, useFactory: mockService },
{ provide: ConfigService, useFactory: mockService },
{ provide: TemplateService, useFactory: mockService },
{ provide: ContentFilterService, useFactory: mockService },
{ provide: QuestionOutlineService, useFactory: mockService },
{ provide: QuestionBankService, useFactory: mockService },
{ provide: RagService, useFactory: mockService },
{ provide: ChatService, useFactory: mockService },
{ provide: I18nService, useFactory: mockService },
{ provide: TenantService, useFactory: mockService },
],
}).compile();
service = module.get<AssessmentService>(AssessmentService);
sessionRepository = module.get(getRepositoryToken(AssessmentSession));
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('deleteSession', () => {
it('should delete a session if it exists and belongs to the user', async () => {
sessionRepository.delete.mockResolvedValue({ affected: 1 });
await expect(service.deleteSession('session-id', 'user-id')).resolves.not.toThrow();
expect(sessionRepository.delete).toHaveBeenCalledWith({ id: 'session-id', userId: 'user-id' });
});
it('should throw NotFoundException if no session was affected', async () => {
sessionRepository.delete.mockResolvedValue({ affected: 0 });
await expect(service.deleteSession('non-existent', 'user-id')).rejects.toThrow(NotFoundException);
});
});
});
+1441
View File
@@ -0,0 +1,1441 @@
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?.name || 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,
};
}
}
@@ -0,0 +1,130 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
UsePipes,
ValidationPipe,
Req,
Logger,
} from '@nestjs/common';
import { QuestionBankService } from '../services/question-bank.service';
import {
CreateQuestionBankDto,
UpdateQuestionBankDto,
CreateQuestionBankItemDto,
UpdateQuestionBankItemDto,
ReviewDto,
} from '../services/question-bank.service';
import { CombinedAuthGuard } from '../../auth/combined-auth.guard';
@Controller('question-banks')
@UseGuards(CombinedAuthGuard)
@UsePipes(ValidationPipe)
export class QuestionBankController {
private readonly logger = new Logger(QuestionBankController.name);
constructor(private readonly questionBankService: QuestionBankService) {}
@Post()
create(@Body() createDto: CreateQuestionBankDto, @Req() req: any) {
this.logger.log(`Creating question bank: ${createDto.name}`);
return this.questionBankService.create(createDto, req.user.id, req.user.tenantId);
}
@Get()
findAll(
@Req() req: any,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
const pageNum = page ? parseInt(page, 10) : undefined;
const limitNum = limit ? parseInt(limit, 10) : undefined;
return this.questionBankService.findAll(
req.user.id,
req.user.tenantId,
pageNum,
limitNum,
);
}
@Get('by-template/:templateId')
async findByTemplateId(@Param('templateId') templateId: string) {
return this.questionBankService.findByTemplateId(templateId);
}
@Get(':id')
async findOne(@Param('id') id: string) {
return this.questionBankService.findOne(id);
}
@Put(':id')
async update(@Param('id') id: string, @Body() updateDto: UpdateQuestionBankDto) {
return this.questionBankService.update(id, updateDto);
}
@Delete(':id')
async remove(@Param('id') id: string) {
return this.questionBankService.remove(id);
}
@Put(':id/submit')
async submitForReview(@Param('id') id: string, @Req() req: any) {
return this.questionBankService.submitForReview(id, req.user.id);
}
@Put(':id/review')
async review(@Param('id') id: string, @Body() reviewDto: ReviewDto, @Req() req: any) {
return this.questionBankService.review(id, reviewDto, req.user.id);
}
@Put(':id/publish')
async publish(@Param('id') id: string) {
return this.questionBankService.publish(id);
}
@Post(':bankId/items')
async addItem(
@Param('bankId') bankId: string,
@Body() createDto: CreateQuestionBankItemDto,
) {
return this.questionBankService.addItem(bankId, createDto);
}
@Put(':bankId/items/:id')
async updateItem(
@Param('bankId') bankId: string,
@Param('id') id: string,
@Body() updateDto: UpdateQuestionBankItemDto,
) {
return this.questionBankService.updateItem(bankId, id, updateDto);
}
@Delete(':bankId/items/:id')
async removeItem(
@Param('bankId') bankId: string,
@Param('id') id: string,
) {
return this.questionBankService.removeItem(bankId, id);
}
@Post(':bankId/generate')
async generate(
@Param('bankId') bankId: string,
@Body() body: { count: number; knowledgeBaseContent?: string },
@Req() req: any,
) {
this.logger.log(`[generate] Generating ${body.count} questions for bank ${bankId}`);
return this.questionBankService.generateQuestions(
bankId,
body.count,
body.knowledgeBaseContent || '',
req.user.tenantId,
);
}
}
@@ -0,0 +1,59 @@
import {
Controller,
Get,
Post,
Body,
Put,
Param,
Delete,
UseGuards,
Req,
} from '@nestjs/common';
import { TemplateService } from '../services/template.service';
import { CreateTemplateDto } from '../dto/create-template.dto';
import { UpdateTemplateDto } from '../dto/update-template.dto';
import { CombinedAuthGuard } from '../../auth/combined-auth.guard';
@Controller('assessment/templates')
@UseGuards(CombinedAuthGuard)
export class TemplateController {
constructor(private readonly templateService: TemplateService) {}
@Post()
create(@Body() createDto: CreateTemplateDto, @Req() req: any) {
return this.templateService.create(
createDto,
req.user.id,
req.user.tenantId,
);
}
@Get()
findAll(@Req() req: any) {
return this.templateService.findAll(req.user.tenantId);
}
@Get(':id')
async findOne(@Param('id') id: string, @Req() req: any) {
return this.templateService.findOne(id, req.user.id, req.user.tenantId);
}
@Put(':id')
async update(
@Param('id') id: string,
@Body() updateDto: UpdateTemplateDto,
@Req() req: any,
) {
return this.templateService.update(
id,
updateDto,
req.user.id,
req.user.tenantId,
);
}
@Delete(':id')
async remove(@Param('id') id: string, @Req() req: any) {
return this.templateService.remove(id, req.user.id, req.user.tenantId);
}
}
@@ -0,0 +1,94 @@
import {
IsString,
IsNotEmpty,
IsOptional,
IsArray,
IsInt,
Min,
Max,
IsObject,
IsBoolean,
} from 'class-validator';
export class CreateTemplateDto {
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsOptional()
description?: string;
@IsArray()
@IsString({ each: true })
@IsOptional()
keywords?: string[];
@IsInt()
@Min(1)
@Max(20)
@IsOptional()
questionCount?: number = 5;
@IsObject()
@IsOptional()
difficultyDistribution?: {
standard: number;
advanced: number;
specialist: number;
};
@IsString()
@IsOptional()
style?: string = 'technical';
@IsString()
@IsOptional()
knowledgeBaseId?: string;
@IsString()
@IsOptional()
knowledgeGroupId?: string;
@IsBoolean()
@IsOptional()
isActive?: boolean = true;
@IsArray()
@IsString({ each: true })
@IsOptional()
linkedGroupIds?: string[];
@IsObject()
@IsOptional()
weightConfig?: {
prompt: number;
other: number;
};
@IsObject()
@IsOptional()
difficultyConfig?: {
standard: number;
advanced: number;
specialist: number;
};
@IsInt()
@Min(1)
@Max(20)
@IsOptional()
questionCountMin?: number;
@IsInt()
@Min(1)
@Max(20)
@IsOptional()
questionCountMax?: number;
@IsInt()
@Min(0)
@Max(100)
@IsOptional()
passingScore?: number;
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTemplateDto } from './create-template.dto';
export class UpdateTemplateDto extends PartialType(CreateTemplateDto) {}
@@ -0,0 +1,45 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import type { AssessmentQuestion } from './assessment-question.entity';
@Entity('assessment_answers')
export class AssessmentAnswer {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'question_id' })
questionId: string;
@ManyToOne(
'AssessmentQuestion',
(question: AssessmentQuestion) => question.answers,
{ onDelete: 'CASCADE' },
)
@JoinColumn({ name: 'question_id' })
question: AssessmentQuestion;
@Column({ type: 'text', name: 'user_answer' })
userAnswer: string;
@Column({ type: 'float', nullable: true })
score: number;
@Column({ type: 'text', nullable: true })
feedback: string;
@Column({ type: 'boolean', name: 'is_follow_up', default: false })
isFollowUp: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
@@ -0,0 +1,49 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../user/user.entity';
@Entity('assessment_certificates')
export class AssessmentCertificate {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id' })
userId: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ name: 'session_id' })
sessionId: string;
@Column({ name: 'template_id' })
templateId: string;
@Column()
level: string;
@Column({ type: 'float', name: 'total_score' })
totalScore: number;
@Column({ name: 'qr_code', nullable: true })
qrCode: string;
@Column({ name: 'dimension_scores', type: 'simple-json', nullable: true })
dimensionScores: Record<string, number>;
@Column({ name: 'radar_data', type: 'simple-json', nullable: true })
radarData: Record<string, number>;
@Column({ name: 'passed', type: 'boolean', default: false })
passed: boolean;
@CreateDateColumn({ name: 'issued_at' })
issuedAt: Date;
}
@@ -0,0 +1,47 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
import type { AssessmentSession } from './assessment-session.entity';
import type { AssessmentAnswer } from './assessment-answer.entity';
@Entity('assessment_questions')
export class AssessmentQuestion {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'session_id' })
sessionId: string;
@ManyToOne(
'AssessmentSession',
(session: AssessmentSession) => session.questions,
{ onDelete: 'CASCADE' },
)
@JoinColumn({ name: 'session_id' })
session: AssessmentSession;
@Column({ type: 'text', name: 'question_text' })
questionText: string;
@Column({ type: 'simple-json', name: 'key_points', nullable: true })
keyPoints: string[];
@Column({ type: 'varchar', nullable: true })
difficulty: string;
@OneToMany('AssessmentAnswer', (answer: AssessmentAnswer) => answer.question)
answers: AssessmentAnswer[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
@@ -0,0 +1,106 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
import { User } from '../../user/user.entity';
import { KnowledgeBase } from '../../knowledge-base/knowledge-base.entity';
import { KnowledgeGroup } from '../../knowledge-group/knowledge-group.entity';
import type { AssessmentQuestion } from './assessment-question.entity';
import { AssessmentTemplate } from './assessment-template.entity';
export enum AssessmentStatus {
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
}
@Entity('assessment_sessions')
export class AssessmentSession {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id' })
userId: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ name: 'tenant_id', nullable: true })
tenantId: string;
@Column({ name: 'knowledge_base_id', nullable: true })
knowledgeBaseId: string | null;
@ManyToOne(() => KnowledgeBase, { nullable: true })
@JoinColumn({ name: 'knowledge_base_id' })
knowledgeBase: KnowledgeBase;
@Column({ name: 'knowledge_group_id', nullable: true })
knowledgeGroupId: string | null;
@ManyToOne(() => KnowledgeGroup, { nullable: true })
@JoinColumn({ name: 'knowledge_group_id' })
knowledgeGroup: KnowledgeGroup;
@Column({ name: 'thread_id', nullable: true })
threadId: string;
@Column({
type: 'varchar',
enum: AssessmentStatus,
default: AssessmentStatus.IN_PROGRESS,
})
status: AssessmentStatus;
@Column({ type: 'float', name: 'final_score', nullable: true })
finalScore: number;
@Column({ type: 'text', name: 'final_report', nullable: true })
finalReport: string;
@Column({ type: 'simple-json', nullable: true })
messages: any[];
@Column({ type: 'simple-json', name: 'feedback_history', nullable: true })
feedbackHistory: any[];
@Column({ type: 'int', name: 'current_question_index', default: 0 })
currentQuestionIndex: number;
@Column({ type: 'int', name: 'follow_up_count', default: 0 })
followUpCount: number;
@Column({ type: 'simple-json', nullable: true })
questions_json: any[];
@Column({ type: 'varchar', length: 10, default: 'zh' })
language: string;
@Column({ name: 'template_id', nullable: true })
templateId: string;
@ManyToOne(() => AssessmentTemplate, { nullable: true })
@JoinColumn({ name: 'template_id' })
template: AssessmentTemplate;
@Column({ type: 'simple-json', name: 'template_json', nullable: true })
templateJson: any;
@OneToMany(
'AssessmentQuestion',
(question: AssessmentQuestion) => question.session,
)
questions: AssessmentQuestion[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
@@ -0,0 +1,105 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Tenant } from '../../tenant/tenant.entity';
import { KnowledgeBase } from '../../knowledge-base/knowledge-base.entity';
import { KnowledgeGroup } from '../../knowledge-group/knowledge-group.entity';
@Entity('assessment_templates')
export class AssessmentTemplate {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', nullable: true })
tenantId: string;
@ManyToOne(() => Tenant, { nullable: true, onDelete: 'CASCADE' })
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@Column()
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'simple-json', nullable: true })
keywords: string[];
@Column({ type: 'int', name: 'question_count', default: 5 })
questionCount: number;
@Column({
type: 'simple-json',
name: 'difficulty_distribution',
nullable: true,
})
difficultyDistribution: {
standard: number;
advanced: number;
specialist: number;
};
@Column({ type: 'varchar', default: 'technical' })
style: string;
@Column({ name: 'knowledge_base_id', nullable: true })
knowledgeBaseId: string | null;
@ManyToOne(() => KnowledgeBase, { nullable: true })
@JoinColumn({ name: 'knowledge_base_id' })
knowledgeBase: KnowledgeBase;
@Column({ name: 'knowledge_group_id', nullable: true })
knowledgeGroupId: string | null;
@ManyToOne(() => KnowledgeGroup, { nullable: true })
@JoinColumn({ name: 'knowledge_group_id' })
knowledgeGroup: KnowledgeGroup;
@Column({ type: 'boolean', name: 'is_active', default: true })
isActive: boolean;
@Column({ type: 'int', default: 1 })
version: number;
@Column({ name: 'created_by', nullable: true })
createdBy: string;
@Column({ type: 'simple-json', name: 'linked_group_ids', nullable: true })
linkedGroupIds: string[];
@Column({ type: 'simple-json', name: 'weight_config', nullable: true })
weightConfig: {
prompt: number;
other: number;
};
@Column({ type: 'simple-json', name: 'difficulty_config', nullable: true })
difficultyConfig: {
standard: number;
advanced: number;
specialist: number;
};
@Column({ type: 'int', name: 'question_count_min', default: 8 })
questionCountMin: number;
@Column({ type: 'int', name: 'question_count_max', default: 10 })
questionCountMax: number;
@Column({ type: 'int', name: 'passing_score', default: 90 })
passingScore: number;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
@@ -0,0 +1,104 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { QuestionBank } from './question-bank.entity';
export enum QuestionBankItemStatus {
PENDING_REVIEW = 'PENDING_REVIEW',
PUBLISHED = 'PUBLISHED',
}
export enum QuestionType {
SHORT_ANSWER = 'SHORT_ANSWER',
MULTIPLE_CHOICE = 'MULTIPLE_CHOICE',
TRUE_FALSE = 'TRUE_FALSE',
}
export enum QuestionDifficulty {
STANDARD = 'STANDARD',
ADVANCED = 'ADVANCED',
SPECIALIST = 'SPECIALIST',
}
export enum QuestionDimension {
PROMPT = 'PROMPT',
LLM = 'LLM',
IDE = 'IDE',
DEV_PATTERN = 'DEV_PATTERN',
WORK_CAPABILITY = 'WORK_CAPABILITY',
}
@Entity('question_bank_items')
export class QuestionBankItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'bank_id' })
bankId: string;
@ManyToOne(
() => QuestionBank,
(bank: QuestionBank) => bank.items,
{ onDelete: 'CASCADE' },
)
@JoinColumn({ name: 'bank_id' })
bank: QuestionBank;
@Column({ type: 'text', name: 'question_text' })
questionText: string;
@Column({
type: 'enum',
enum: QuestionType,
default: QuestionType.SHORT_ANSWER,
})
questionType: QuestionType;
@Column({ type: 'simple-json', nullable: true })
options: string[] | null;
@Column({ type: 'text', nullable: true })
correctAnswer: string | null;
@Column({ type: 'simple-json', name: 'key_points' })
keyPoints: string[];
@Column({
type: 'enum',
enum: QuestionDifficulty,
default: QuestionDifficulty.STANDARD,
})
difficulty: QuestionDifficulty;
@Column({
type: 'enum',
enum: QuestionDimension,
default: QuestionDimension.PROMPT,
})
dimension: QuestionDimension;
@Column({ type: 'text', nullable: true })
basis: string | null;
@Column({ name: 'created_by', nullable: true })
createdBy: string | null;
@Column({
type: 'enum',
enum: QuestionBankItemStatus,
default: QuestionBankItemStatus.PENDING_REVIEW,
})
status: QuestionBankItemStatus;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
@@ -0,0 +1,80 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToOne,
JoinColumn,
OneToMany,
Unique,
} from 'typeorm';
import { Tenant } from '../../tenant/tenant.entity';
import { AssessmentTemplate } from './assessment-template.entity';
import type { QuestionBankItem } from './question-bank-item.entity';
export enum QuestionBankStatus {
DRAFT = 'DRAFT',
PENDING_REVIEW = 'PENDING_REVIEW',
PUBLISHED = 'PUBLISHED',
REJECTED = 'REJECTED',
}
@Entity('question_banks')
@Unique(['templateId'])
export class QuestionBank {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', nullable: true })
tenantId: string | null;
@ManyToOne(() => Tenant, { nullable: true, onDelete: 'CASCADE' })
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@Column({ name: 'template_id', nullable: true })
templateId: string | null;
@OneToOne(() => AssessmentTemplate, { nullable: true })
@JoinColumn({ name: 'template_id' })
template: AssessmentTemplate;
@Column()
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({
type: 'enum',
enum: QuestionBankStatus,
default: QuestionBankStatus.DRAFT,
})
status: QuestionBankStatus;
@Column({ name: 'created_by', nullable: true })
createdBy: string | null;
@Column({ name: 'reviewed_by', nullable: true })
reviewedBy: string | null;
@Column({ name: 'reviewed_at', nullable: true })
reviewedAt: Date | null;
@Column({ name: 'review_comment', nullable: true })
reviewComment: string | null;
@OneToMany(
'QuestionBankItem',
(item: QuestionBankItem) => item.bank,
)
items: QuestionBankItem[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
+77
View File
@@ -0,0 +1,77 @@
import { StateGraph, MemorySaver } from '@langchain/langgraph';
import { EvaluationAnnotation } from './state';
import { questionGeneratorNode } from './nodes/generator.node';
import { interviewerNode } from './nodes/interviewer.node';
import { graderNode } from './nodes/grader.node';
import { reportAnalyzerNode } from './nodes/analyzer.node';
/**
* Conditional routing logic for the Grader node.
*/
const routeAfterGrading = (state: typeof EvaluationAnnotation.State) => {
const targetCount = state.questionCount || 5;
const questionsLen = state.questions?.length || 0;
console.log('[Router] Evaluation Result:', {
currentIndex: state.currentQuestionIndex,
shouldFollowUp: state.shouldFollowUp,
numQuestions: questionsLen,
targetCount,
});
if (state.shouldFollowUp) {
console.log('[Router] Routing to follow-up interviewer');
return 'interviewer';
}
if (state.currentQuestionIndex < targetCount) {
// If the next question isn't generated yet, go back to generator
if (state.currentQuestionIndex >= questionsLen) {
console.log('[Router] Index >= Questions, routing to generator');
return 'generator';
}
// If it is generated, go to interviewer
console.log('[Router] Index < Questions, routing to interviewer');
return 'interviewer';
}
console.log('[Router] Assessment complete, routing to analyzer');
return 'analyzer';
};
/**
* Builds and compiles the Evaluation Graph.
*/
export const createEvaluationGraph = () => {
const workflow = new StateGraph(EvaluationAnnotation)
.addNode('generator', questionGeneratorNode)
.addNode('interviewer', interviewerNode)
.addNode('grader', graderNode)
.addNode('analyzer', reportAnalyzerNode)
// Flow definition
.addEdge('__start__', 'generator')
.addEdge('generator', 'interviewer')
// After interviewer, the graph will naturally pause for user input
// if we use it in a thread-safe way with interrupts or simple invocation.
.addEdge('interviewer', 'grader')
// After grading, decide where to go
.addConditionalEdges('grader', routeAfterGrading, {
interviewer: 'interviewer',
generator: 'generator',
analyzer: 'analyzer',
})
.addEdge('analyzer', '__end__');
// Using MemorySaver for thread-based persistence
const checkpointer = new MemorySaver();
return workflow.compile({
checkpointer,
// We want the graph to stop after the interviewer presents the question
interruptAfter: ['interviewer'],
});
};
@@ -0,0 +1,162 @@
import { ChatOpenAI } from '@langchain/openai';
import { SystemMessage, HumanMessage } from '@langchain/core/messages';
import { RunnableConfig } from '@langchain/core/runnables';
import { EvaluationState } from '../state';
/**
* Node responsible for generating the final mastery report at the end of the session.
*/
export const reportAnalyzerNode = async (
state: EvaluationState,
config?: RunnableConfig,
): Promise<Partial<EvaluationState>> => {
const { model } = (config?.configurable as any) || {};
const { scores, messages } = state;
const questionList = state.questions || [];
console.log('[AnalyzerNode] Entering node...', {
numScores: Object.keys(scores || {}).length,
numMessages: messages?.length,
scores,
});
if (!model) {
throw new Error('Missing model in node configuration');
}
const scoreSummary = Object.entries(scores)
.map(([qId, score]) => {
const displayId = isNaN(parseInt(qId))
? qId
: (parseInt(qId) + 1).toString();
return `Question ${displayId}: Score ${score}/10`;
})
.join('\n');
const dimensionSummary = questionList.reduce((acc: Record<string, number[]>, q: any) => {
const dim = q.dimension || 'workCapability';
const score = scores[q.id] || 0;
if (!acc[dim]) acc[dim] = [];
acc[dim].push(score);
return acc;
}, {});
const dimensionAvg = Object.entries(dimensionSummary).map(([dim, arr]: [string, any]) => {
const avg = arr.reduce((a: number, b: number) => a + b, 0) / arr.length;
return `${dim}: ${avg.toFixed(1)}/10`;
}).join('\n');
const isZh = state.language === 'zh';
const isJa = state.language === 'ja';
const systemPromptZh = `你是一位客观且严谨的高级教育顾问。
请审查以下评估结果,并为员工提供一份严谨的掌握程度报告。
重要提示:
1. **你必须使用以下语言生成报告:中文 (Simplified Chinese)**。
2. **严禁夹杂日文**。即使对话记录中包含日文,报告内容也必须全中文。
3. 报告的第一行必须严格遵守此格式:"LEVEL: [Novice/Proficient/Advanced/Expert]"。
4. 必须保持客观。如果用户没有提供有效的回答或得分为 0,你必须将其识别为 'Novice',并明确指出他们尚未证明其掌握程度。
5. 不要虚构或幻想优点(如"潜力"或"好奇心"),如果用户明确表示"不知道"或未提供实质内容。
6. 专注于对话记录中已证明的事实。
各维度得分:
${dimensionAvg}
问题与得分:
${scoreSummary}
对话记录:
${messages
.filter((m: any) => m._getType() !== 'system')
.map((m: any) => `${m.role || m._getType()}: ${m.content}`)
.join('\n')}
报告结构:
1. 总体级别(已在顶部指定)
2. 各维度得分分析(提示词、LLM、IDE、开发范式、工作能力)
3. 薄弱环节识别
4. 针对性改进建议
5. 推荐的学习路径。`;
const systemPromptJa = `あなたは客観的で厳格なシニア教育コンサルタントです。
以下の評価結果をレビューし、従業員に対して厳格な習熟度レポートを提供してください。
重要事項:
1. **レポートは必ず次の言語で生成してください:日本語**。
2. **中国語を混ぜないでください**。会話ログに中国語が含まれていても、レポートの内容はすべて日本語で記述してください。
3. レポートの最初の行は, 必ず次の形式に従ってください:"LEVEL: [Novice/Proficient/Advanced/Expert]"。
4. 客観的であること。ユーザーが有効な回答を提供しなかった場合、またはスコアが 0 の場合、'Novice' と判定し、習熟度が証明されていないことを明示してください。
5. ユーザーが「わからない」と言ったり、内容を提供しなかった場合に、長所(「ポテンシャル」や「好奇心」など)を捏造しないでください。
6. 会話ログで証明された事実に集中してください。
各ディメンションスコア:
${dimensionAvg}
質問とスコア:
${scoreSummary}
会話ログ:
${messages
.filter((m: any) => m._getType() !== 'system')
.map((m: any) => `${m.role || m._getType()}: ${m.content}`)
.join('\n')}
レポート構成:
1. 総合レベル(一番上に指定済み)
2. 各ディメンション分析(提示詞、LLM、IDE、開発範式、工作能力)
3. 薄弱环节识别
4. 推奨される学習パス。`;
const systemPromptEn = `You are an objective and critical seniority education consultant.
Review the following assessment results and provide a rigorous mastery report for the employee.
IMPORTANT:
1. **You MUST generate the report strictly in English.**
2. START the report with exactly this format: "LEVEL: [Novice/Proficient/Advanced/Expert]" on the first line.
3. Be OBJECTIVE. If the user provided no valid answers or scores are 0, you MUST identify them as 'Novice' and explicitly state they have NOT demonstrated mastery.
4. DO NOT invent or hallucinate strengths (like 'potential' or 'curiosity') if the user explicitly said "I don't know" or provided no content.
5. Focus on what was PROVEN in the conversation logs.
DIMENSION SCORES:
${dimensionAvg}
QUESTIONS AND SCORES:
${scoreSummary}
CONVERSATION LOGS:
${messages
.filter((m: any) => m._getType() !== 'system')
.map((m: any) => `${m.role || m._getType()}: ${m.content}`)
.join('\n')}
REPORT STRUCTURE:
1. Overall Level (Already specified at top)
2. Dimension Analysis (Prompt, LLM, IDE, DevPattern, WorkCapability)
3. Weak Areas Identification
4. Targeted Learning Recommendations.`;
const systemPrompt = isZh
? systemPromptZh
: isJa
? systemPromptJa
: systemPromptEn;
const humanMsg = isZh
? '生成最终掌握程度报告。'
: isJa
? '最終的な習熟度レポートを生成してください。'
: 'Generate the final mastery report.';
const response = await model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(humanMsg),
]);
console.log(
'[AnalyzerNode] Report generated successfully. Length:',
response.content?.toString().length,
);
return {
report: response.content as string,
};
};
@@ -0,0 +1,246 @@
import { ChatOpenAI } from '@langchain/openai';
import { SystemMessage, HumanMessage } from '@langchain/core/messages';
import { RunnableConfig } from '@langchain/core/runnables';
import { EvaluationState } from '../state';
import { safeParseJson } from '../../../common/json-utils';
/**
* Node responsible for generating assessment questions based on the knowledge base content.
*/
export const questionGeneratorNode = async (
state: EvaluationState,
config?: RunnableConfig,
): Promise<Partial<EvaluationState>> => {
const { model, knowledgeBaseContent, targetCount } = (config?.configurable as any) || {};
const limitCount = targetCount || 5;
console.log('[GeneratorNode] Starting generation...', {
language: state.language,
hasModel: !!model,
contentLength: knowledgeBaseContent?.length,
keywords: state.keywords || [],
targetCount: limitCount,
});
if (!model || !knowledgeBaseContent) {
console.error('[GeneratorNode] Missing model or knowledgeBaseContent');
throw new Error(
'Missing model or knowledgeBaseContent in node configuration',
);
}
const isZh = state.language === 'zh';
const isJa = state.language === 'ja';
const style = state.style || 'technical';
const difficultyText = state.difficultyDistribution
? JSON.stringify(state.difficultyDistribution)
: isZh
? '随机分布'
: isJa
? 'ランダム分布'
: 'Random distribution';
const keywords = state.keywords || [];
const hasKeywords = keywords.length > 0;
const keywordText = hasKeywords ? keywords.join(', ') : '';
const rulesZh = [
`**禁止重复**:绝对禁止生成与下方“禁止重复列表”中相似的题目。`,
`**深度挖掘**:如果之前的题目考查了核心定义,新题目必须考查具体的应用案例、对比分析或隐藏的细节。`,
hasKeywords
? `**关键词权重**:必须围绕关键词 (${keywordText}) 展开,但要从关键词的不同侧面(如流程、限制、优缺点、具体参数等)进行挖掘。`
: null,
`**随机扰动**:即使对于相同的主题或关键词,也要尝试从不同的逻辑链条(如“因为...所以...” vs “如果没有...会怎样”)出发。`,
]
.filter(Boolean)
.map((r, i) => `${i + 1}. ${r}`)
.join('\n');
const rulesJa = [
`**重複禁止**:下記の「作成済み問題リスト」と類似した内容は絶対に避けてください。`,
`**多角的アプローチ**:前回が定義だった場合は、今回は応用方法、制限事項、具体的な数値などに焦点を当ててください。`,
hasKeywords
? `**キーワードの深掘り**:キーワード (${keywordText}) の異なる側面から出題してください。`
: null,
]
.filter(Boolean)
.map((r, i) => `${i + 1}. ${r}`)
.join('\n');
const rulesEn = [
`**NO REPETITION**: Strictly avoid any conceptual overlap with the "Previous Questions" list below.`,
`**New Facets**: If previous questions were about definitions, focus on applications, edge cases, or specific details.`,
hasKeywords
? `**Keyword Variety**: Center on (${keywordText}), but explore different aspects (process, pros/cons, requirements).`
: null,
]
.filter(Boolean)
.map((r, i) => `${i + 1}. ${r}`)
.join('\n');
const existingQuestions = state.questions || [];
const existingQuestionsText = existingQuestions
.map((q, i) => `Q${i + 1}: ${q.questionText}`)
.join('\n');
const systemPromptZh = `你是一位专业的知识评估专家。请根据提供的知识库片段生成 1 个唯一的测试题目。
### 强制性语言规则:
**必须使用中文 (Simplified Chinese) 进行回复**。即使知识库内容是英文或其他语言,问题(question_text)和关键点(key_points)也必须使用中文。
### 强制性多样性规则:
${rulesZh}
### 禁止重复列表(已出过):
${existingQuestionsText || '无'}
### 任务:
${hasKeywords ? `目标关键词:${keywordText}\n` : ''}出题风格:${style}
难度:${difficultyText}
请以 JSON 数组格式返回 1 个问题:
[
{
"question_text": "...",
"key_points": ["点1", "点2"],
"difficulty": "...",
"dimension": "prompt/llm/ide/devPattern/workCapability",
"basis": "[n] 引用原文..."
}
]`;
// dimension取值:prompt=提示词, llm=LLM原理, ide=IDE协作, devPattern=开发范式, workCapability=工作能力
const systemPromptJa = `あなたは専門的なアセスメントエキスパートです。提供されたナレッジベースに基づいて、ユニークな問題を 1 つ作成してください。
### 言語ルール(最重要):
**必ず日本語で作成してください**。提供されたナレッジベースが英語や中国語、その他の言語であっても、質問文(question_text)およびキーポイント(key_points)は必ず日本語で回答してください。中国語が混ざらないように厳格に注意してください。
### 多様性ルール:
${rulesJa}
### 作成済み問題リスト:
${existingQuestionsText || 'なし'}
### 任務:
${hasKeywords ? `目標キーワード:${keywordText}\n` : ''}出題スタイル:${style}
難易度:${difficultyText}
以下のJSON配列形式で問題を1つ返してください:
[
{
"question_text": "...",
"key_points": ["ポイント1", "ポイント2"],
"difficulty": "...",
"dimension": "prompt/llm/ide/devPattern/workCapability",
"basis": "[n] 引用箇所..."
}
]`;
const systemPromptEn = `You are an expert examiner. Generate 1 UNIQUE question based on the provided context.
### Language Rule:
**You MUST generate the question and key points in English.**
### Diversity Rules:
${rulesEn}
### Previous Questions (DO NOT REPEAT):
${existingQuestionsText || 'None'}
Return 1 question as a JSON array with format:
[
{
"question_text": "...",
"key_points": ["point1", "point2"],
"difficulty": "...",
"dimension": "prompt/llm/ide/devPattern/workCapability",
"basis": "[n] citation..."
}
]`;
// dimension values: prompt=prompt engineering, llm=LLM principles, ide=IDE collaboration, devPattern=development paradigm, workCapability=work capability
const systemPrompt = isZh
? systemPromptZh
: isJa
? systemPromptJa
: systemPromptEn;
const humanMsg = isZh
? `请使用中文基于以下内容生成题目:\n\n${knowledgeBaseContent}`
: isJa
? `以下の内容に基づいて、必ず日本語でアセスメント問題を作成してください:\n\n${knowledgeBaseContent}`
: `Generate evaluation question in English based on:\n\n${knowledgeBaseContent}`;
try {
const response = await model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(humanMsg),
]);
try {
let newQuestions = safeParseJson<any>(response.content as string);
if (!newQuestions) {
console.error('[GeneratorNode] Failed to parse JSON. Raw content:', response.content);
throw new Error('Invalid JSON format from AI');
}
// Handle both array and single object
if (!Array.isArray(newQuestions)) {
newQuestions = [newQuestions];
}
const dimensionMap: Record<string, string> = {
// 中文
'技术能力-提示词': 'prompt',
'提示词': 'prompt',
'技术能力-LLM': 'llm',
'LLM': 'llm',
'IDE协作能力': 'ide',
'IDE': 'ide',
'AI开发范式': 'devPattern',
'开发范式': 'devPattern',
'工作能力-安全': 'workCapability',
'工作能力': 'workCapability',
// 英文直接映射
'prompt': 'prompt',
'llm': 'llm',
'ide': 'ide',
'devPattern': 'devPattern',
'workCapability': 'workCapability',
};
const mappedNewQuestions = newQuestions.map((q: any) => {
let inferredDimension = 'workCapability';
const dimValue = q.dimension?.toString().toLowerCase().trim();
if (dimValue) {
inferredDimension = dimensionMap[dimValue] || 'workCapability';
console.log('[GeneratorNode] Dimension mapping:', { original: q.dimension, mapped: inferredDimension });
}
return {
id: (existingQuestions.length + 1).toString(),
questionText: q.question_text,
keyPoints: q.key_points,
difficulty: q.difficulty,
basis: q.basis,
dimension: inferredDimension,
};
});
const questionsToGenerate = Math.max(1, limitCount - existingQuestions.length);
const limitedNewQuestions = mappedNewQuestions.slice(0, questionsToGenerate);
console.log('[GeneratorNode] Generated questions:', mappedNewQuestions.length, 'Limit:', questionsToGenerate);
return {
questions: [...existingQuestions, ...limitedNewQuestions],
};
} catch (error) {
console.error('[GeneratorNode] Parse error:', error);
return { questions: existingQuestions };
}
} catch (invokeError) {
console.error('[GeneratorNode] Invoke error:', invokeError);
throw invokeError;
}
};
@@ -0,0 +1,252 @@
import { ChatOpenAI } from '@langchain/openai';
import {
SystemMessage,
HumanMessage,
AIMessage,
} from '@langchain/core/messages';
import { RunnableConfig } from '@langchain/core/runnables';
import { EvaluationState } from '../state';
import { safeParseJson } from '../../../common/json-utils';
/**
* Node responsible for grading the user's answer and deciding if a follow-up is needed.
*/
export const graderNode = async (
state: EvaluationState,
config?: RunnableConfig,
): Promise<Partial<EvaluationState>> => {
const { model } = (config?.configurable as any) || {};
const { questions, currentQuestionIndex, messages } = state;
const currentFollowUpCount = state.followUpCount || 0;
console.log('[GraderNode] Entering node...', {
currentIndex: currentQuestionIndex,
numMessages: messages?.length,
questionCount: state.questionCount,
hasQuestions: !!questions?.length,
});
if (!model) {
throw new Error('Missing model in node configuration');
}
const lastUserMessage = messages[messages.length - 1];
console.log('[GraderNode] Incoming Messages Count:', messages.length);
if (lastUserMessage) {
console.log(
'[GraderNode] Last Message Type:',
lastUserMessage.constructor.name,
);
// Safely extract content for logging
const logContent =
typeof lastUserMessage.content === 'string'
? lastUserMessage.content
: JSON.stringify(lastUserMessage.content);
console.log(
'[GraderNode] Last Message Content:',
logContent.substring(0, 50),
);
}
if (!(lastUserMessage instanceof HumanMessage)) {
console.log(
'[GraderNode] Last message is not HumanMessage, skipping grading.',
);
return {};
}
const isZh = state.language === 'zh';
const isJa = state.language === 'ja';
const currentQuestion = questions[currentQuestionIndex];
if (!currentQuestion) {
console.error(
`[GraderNode] Question at index ${currentQuestionIndex} not found!`,
);
return { currentQuestionIndex: currentQuestionIndex + 1 };
}
const systemPromptZh = `你是一位专业的考官。
请根据以下问题和关键点对用户的回答进行评分。
重要提示:
1. **你必须使用以下语言提供反馈:中文 (Simplified Chinese)**。
2. 即使用户的回答或知识库内容涉及其他语言,请确保你的反馈和解释依然严格使用中文。不要夹杂日文。
问题:${currentQuestion.questionText}
预期的关键点:${currentQuestion.keyPoints.join(', ')}
评估标准:
1. 准确性:他们是否正确覆盖了关键点?
2. 完整性:他们是否遗漏了任何重要内容?
3. 深度:解释是否充分?
请提供:
1. 0 到 10 的评分。
2. 建设性的反馈。
3. 如果回答不完整或不清晰,需要进一步解释,请将 'should_follow_up' 标志设为 true。
请以 JSON 格式返回响应:
{
"score": 8,
"feedback": "...",
"should_follow_up": false
}`;
const systemPromptJa = `あなたは専門的な試験官です。
以下の質問とキーポイントに基づいて、ユーザーの回答を採点してください。
重要事項:
1. **フィードバックは必ず次の言語で提供してください:日本語**。
2. ユーザーの回答やナレッジベースの内容に他の言語(中国語や英語など)が含まれている場合でも、フィードバックと説明は必ず日本語のみで行ってください。中国語が混ざらないよう厳格に注意してください。
質問:${currentQuestion.questionText}
期待されるキーポイント:${currentQuestion.keyPoints.join(', ')}
評価基準:
1. 正確性:キーポイントを正確に網羅していますか?
2. 網羅性:重要な内容が欠落していませんか?
3. 深さ:説明は十分ですか?
以下を提供してください:
1. 0 から 10 までのスコア。
2. 建設的なフィードバック。
3. 回答が不完全または不明確で、さらなる説明が必要な場合は、'should_follow_up' フラグを true に設定してください。
JSON 形式で回答してください:
{
"score": 8,
"feedback": "...",
"should_follow_up": false
}`;
const systemPromptEn = `You are an expert examiner.
Grade the user's answer based on the following question and key points.
IMPORTANT:
1. **You MUST provide the feedback in English.**
2. If the user's answer or knowledge base content references other languages, ensure your feedback and explanation remain strictly in English.
QUESTION: ${currentQuestion.questionText}
EXPECTED KEY POINTS: ${currentQuestion.keyPoints.join(', ')}
Evaluate:
1. Accuracy: Did they cover the key points correctly?
2. Completeness: Did they miss anything important?
3. Depth: Is the explanation sufficient?
Provide:
1. A score from 0 to 10.
2. Constructive feedback.
3. A boolean flag 'should_follow_up' if the answer is incomplete or unclear and needs further clarification.
Format your response as JSON:
{
"score": 8,
"feedback": "...",
"should_follow_up": false
}`;
const systemPrompt = isZh
? systemPromptZh
: isJa
? systemPromptJa
: systemPromptEn;
const userContentText =
typeof lastUserMessage.content === 'string'
? lastUserMessage.content
: JSON.stringify(lastUserMessage.content);
console.log('[GraderNode] === START GRADING ===');
console.log('[GraderNode] User answer length:', userContentText.length);
console.log('[GraderNode] Question:', currentQuestion?.questionText?.substring(0, 100));
console.log('[GraderNode] Target dimension:', currentQuestion?.dimension);
const response = await model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(userContentText),
]);
console.log('[GraderNode] LLM invoke completed');
try {
const rawContent = response.content as string;
console.log('[GraderNode] Raw AI response length:', rawContent.length);
console.log('[GraderNode] Raw AI response:', rawContent.substring(0, 800));
const result = safeParseJson<any>(rawContent);
if (!result) {
console.error('[GraderNode] Failed to parse JSON. Raw content:', rawContent);
throw new Error('Invalid JSON format from AI');
}
console.log('[GraderNode] === GRADING RESULT ===');
console.log('[GraderNode] Parsed result:', JSON.stringify(result, null, 2));
console.log('[GraderNode] Score value:', result.score);
console.log('[GraderNode] Feedback value:', result.feedback?.substring(0, 200));
const scoreLabel = isZh ? '得分' : isJa ? 'スコア' : 'Score';
const feedbackLabel = isZh ? '反馈' : isJa ? 'フィードバック' : 'Feedback';
const feedbackMessage = new AIMessage(
`${scoreLabel}: ${result.score}/10\n\n${feedbackLabel}: ${result.feedback}`,
);
const newScores = {
...state.scores,
[currentQuestion.id || currentQuestionIndex.toString()]: result.score,
};
let shouldFollowUp = result.should_follow_up === true;
// Breakout logic:
// 1. Max 1 follow-up per question
// 2. If score is decent (>= 8), don't follow up
// 3. If answer is short "don't know", don't follow up
const normalizedContent = userContentText.trim().toLowerCase();
const saysIDontKnow =
normalizedContent.length < 10 &&
(normalizedContent.includes('不知道') ||
normalizedContent.includes('不会') ||
normalizedContent.includes("don't know") ||
normalizedContent.includes('no idea') ||
normalizedContent.includes('不知') ||
normalizedContent.includes('わかりません') ||
normalizedContent.includes('わからん') ||
normalizedContent.includes('知らない') ||
normalizedContent.includes('不明') ||
normalizedContent.includes('わからない'));
if (currentFollowUpCount >= 2 || result.score >= 8 || saysIDontKnow) {
shouldFollowUp = false;
}
console.log('[GraderNode] Final State decision:', {
shouldFollowUp,
nextIndex: shouldFollowUp
? currentQuestionIndex
: currentQuestionIndex + 1,
score: result.score,
saysIDontKnow,
});
return {
feedbackHistory: [feedbackMessage],
scores: newScores,
shouldFollowUp: shouldFollowUp,
followUpCount: shouldFollowUp ? currentFollowUpCount + 1 : 0,
currentQuestionIndex: shouldFollowUp
? currentQuestionIndex
: currentQuestionIndex + 1,
} as any;
} catch (error) {
console.error('Failed to parse grade from AI response:', error);
return {
feedbackHistory: [
new AIMessage("I had some trouble grading that, but let's move on."),
],
currentQuestionIndex: currentQuestionIndex + 1,
shouldFollowUp: false,
} as any;
}
};
@@ -0,0 +1,99 @@
import { AIMessage } from '@langchain/core/messages';
import { RunnableConfig } from '@langchain/core/runnables';
import { EvaluationState } from '../state';
/**
* Node responsible for presenting the current question or follow-up to the user.
*/
export const interviewerNode = async (
state: EvaluationState,
config?: RunnableConfig,
): Promise<Partial<EvaluationState>> => {
const { questions, currentQuestionIndex, shouldFollowUp, messages } = state;
console.log('[InterviewerNode] Entering node...', {
numQuestions: questions?.length,
currentIndex: currentQuestionIndex,
shouldFollowUp,
numMessages: messages?.length,
});
if (!questions || questions.length === 0) {
const isZh = state.language === 'zh';
const isJa = state.language === 'ja';
const msg = isZh
? '很抱歉,我无法为此会话生成任何问题。'
: isJa
? '申し訳ありませんが、このセッションの問題を生成できませんでした。'
: "I'm sorry, I couldn't generate any questions for this session.";
return {
messages: [new AIMessage(msg)],
};
}
const currentQuestion = questions[currentQuestionIndex];
// If it's a follow-up, we add a prefix to the label later.
// If we've run out of questions and no follow-up requested, we shouldn't be here, but let's be safe.
if (currentQuestionIndex >= questions.length) {
return { shouldFollowUp: false };
}
const isZh = state.language === 'zh';
const isJa = state.language === 'ja';
let prompt = '';
if (
shouldFollowUp &&
state.feedbackHistory &&
state.feedbackHistory.length > 0
) {
// Construct a follow-up prompt based on last feedback
const lastFeedbackMsg =
state.feedbackHistory[state.feedbackHistory.length - 1];
const feedbackText = lastFeedbackMsg.content.toString();
// Extract the "Feedback: ..." part if possible, otherwise use whole text
const feedbackMatch = feedbackText.match(
/(?:Feedback|反馈|フィードバック): ([\s\S]*)/i,
);
const specificFeedback = feedbackMatch
? feedbackMatch[1].trim()
: feedbackText;
const followUpLabel = isZh
? '补充追问'
: isJa
? '追加の質問'
: 'Follow-up Clarification';
const followUpInstruction = isZh
? '根据以上反馈,请补充更具体的信息:'
: isJa
? '上記のフィードバックに基づき、より具体的な情報を追加してください:'
: 'Based on the feedback above, please provide more specific details:';
prompt = `${followUpLabel}\n\n${specificFeedback}\n\n${followUpInstruction}`;
} else {
// Standard question presentation
const label = isZh
? `问题 ${currentQuestionIndex + 1}`
: isJa
? `質問 ${currentQuestionIndex + 1}`
: `Question ${currentQuestionIndex + 1}`;
const instruction = isZh
? '请提供您的回答。'
: isJa
? '回答を入力してください。'
: 'Please provide your answer.';
prompt = `${label}: ${currentQuestion.questionText}\n\n${instruction}`;
}
console.log('[InterviewerNode] Returning question:', { currentQuestionIndex, questionText: currentQuestion?.questionText });
return {
messages: [new AIMessage(prompt)],
shouldFollowUp: false,
};
};
+124
View File
@@ -0,0 +1,124 @@
import { Annotation, MessagesAnnotation } from '@langchain/langgraph';
import { BaseMessage } from '@langchain/core/messages';
/**
* State representing the evaluation session using LangGraph Annotation.
*/
export const EvaluationAnnotation = Annotation.Root({
/**
* The message history of the conversation.
* Inherits from MessagesAnnotation to handle message merging.
*/
...MessagesAnnotation.spec,
/**
* Historical evaluation feedback from the grader.
* Separated from main messages to keep conversation context clean.
*/
feedbackHistory: Annotation<BaseMessage[]>({
reducer: (prev, next) => [...(prev || []), ...(next || [])],
default: () => [],
}),
/**
* The database ID of the current assessment session.
*/
assessmentSessionId: Annotation<string>(),
/**
* The knowledge base ID used as ground truth for this evaluation.
*/
knowledgeBaseId: Annotation<string>(),
/**
* List of questions generated for this session.
*/
questions: Annotation<any[]>({
reducer: (prev, next) => next ?? prev,
default: () => [],
}),
/**
* Index of the current question being discussed.
*/
currentQuestionIndex: Annotation<number>({
reducer: (prev, next) => next ?? prev,
default: () => 0,
}),
/**
* Flag indicating if the Grader believes a follow-up question is needed for clarity.
*/
shouldFollowUp: Annotation<boolean>({
reducer: (prev, next) => next ?? prev,
default: () => false,
}),
/**
* Map of scores for each question.
*/
scores: Annotation<Record<string, number>>({
reducer: (prev, next) => ({ ...prev, ...next }),
default: () => ({}),
}),
/**
* Final report generated by the ReportAnalyzer.
*/
report: Annotation<string | undefined>({
reducer: (prev, next) => next ?? prev,
}),
/**
* Context chunks retrieved from the knowledge base for grounding.
*/
context: Annotation<string[] | undefined>({
reducer: (prev, next) => next ?? prev,
}),
/**
* Preferred language for the assessment (zh, en, ja).
*/
language: Annotation<string>({
reducer: (prev, next) => next ?? prev,
default: () => 'zh',
}),
/**
* Number of times we have followed up on the current question.
*/
followUpCount: Annotation<number>({
reducer: (prev, next) => next ?? prev,
default: () => 0,
}),
/**
* Number of questions to generate.
*/
questionCount: Annotation<number | undefined>({
reducer: (prev, next) => next ?? prev,
}),
/**
* Desired difficulty distribution.
*/
difficultyDistribution: Annotation<any | undefined>({
reducer: (prev, next) => next ?? prev,
}),
/**
* Desired question style.
*/
style: Annotation<string | undefined>({
reducer: (prev, next) => next ?? prev,
}),
/**
* Target keywords for question generation.
*/
keywords: Annotation<string[] | undefined>({
reducer: (prev, next) => next ?? prev,
}),
});
export type EvaluationState = typeof EvaluationAnnotation.State;
@@ -0,0 +1,45 @@
-- Migration: Create Question Banks Tables
-- Run this SQL to create the question_banks and question_bank_items tables
-- Create QuestionBanks table
CREATE TABLE IF NOT EXISTS question_banks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(255),
template_id UUID,
name VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(50) DEFAULT 'DRAFT',
created_by VARCHAR(255),
reviewed_by VARCHAR(255),
reviewed_at TIMESTAMP,
review_comment TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT fk_template FOREIGN KEY (template_id) REFERENCES assessment_templates(id) ON DELETE SET NULL
);
-- Create QuestionBankItems table
CREATE TABLE IF NOT EXISTS question_bank_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bank_id UUID NOT NULL,
question_text TEXT NOT NULL,
question_type VARCHAR(50) DEFAULT 'SHORT_ANSWER',
options JSONB,
correct_answer TEXT,
key_points JSONB NOT NULL,
difficulty VARCHAR(50) DEFAULT 'STANDARD',
dimension VARCHAR(50) DEFAULT 'PROMPT',
basis TEXT,
created_by VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT fk_bank FOREIGN KEY (bank_id) REFERENCES question_banks(id) ON DELETE CASCADE
);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_question_banks_tenant_id ON question_banks(tenant_id);
CREATE INDEX IF NOT EXISTS idx_question_banks_status ON question_banks(status);
CREATE INDEX IF NOT EXISTS idx_question_banks_created_by ON question_banks(created_by);
CREATE INDEX IF NOT EXISTS idx_question_bank_items_bank_id ON question_bank_items(bank_id);
CREATE INDEX IF NOT EXISTS idx_question_bank_items_difficulty ON question_bank_items(difficulty);
CREATE INDEX IF NOT EXISTS idx_question_bank_items_dimension ON question_bank_items(dimension);
@@ -0,0 +1,60 @@
import { Injectable, Logger } from '@nestjs/common';
@Injectable()
export class ContentFilterService {
private readonly logger = new Logger(ContentFilterService.name);
/**
* Filters knowledge base content based on keywords.
* In a real implementation, this might use semantic search or simple keyword filtering.
* For now, we'll implement a simple relevance-based filtering.
*/
filterContent(content: string, keywords: string[]): string {
if (!keywords || keywords.length === 0) {
return content;
}
this.logger.log(
`Filtering content with ${keywords.length} keywords: ${keywords.join(', ')}`,
);
// Split content into paragraphs or sections
const sections = content.split(/\n\n+/);
// Score each section based on keyword matches (case-insensitive)
const scoredSections = sections.map((section) => {
let score = 0;
const lowerSection = section.toLowerCase();
keywords.forEach((keyword) => {
const lowerKeyword = keyword.toLowerCase();
const matches = lowerSection.split(lowerKeyword).length - 1;
score += matches;
});
return { section, score };
});
// Sort sections by score and take the most relevant ones
// If content is huge, we might want to limit the total length
const relevantSections = scoredSections
.filter((s) => s.score > 0)
.sort((a, b) => b.score - a.score)
.map((s) => s.section);
// If no sections matched, return a sample or the original content
if (relevantSections.length === 0) {
this.logger.warn(
'No sections matched keywords, returning first 5000 characters',
);
return content.substring(0, 5000);
}
this.logger.log(
`Found ${relevantSections.length} relevant sections out of ${sections.length}`,
);
// Return combined relevant sections (up to a reasonable limit)
return relevantSections.join('\n\n').substring(0, 50000);
}
}
@@ -0,0 +1,393 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { ChatOpenAI } from '@langchain/openai';
import { SystemMessage, HumanMessage } from '@langchain/core/messages';
import { QuestionBank, QuestionBankStatus } from '../entities/question-bank.entity';
import {
QuestionBankItem,
QuestionType,
QuestionDifficulty,
QuestionDimension,
} from '../entities/question-bank-item.entity';
import { ModelConfigService } from '../model-config/model-config.service';
import { ModelType } from '../types';
import { safeParseJson } from '../../common/json-utils';
export interface CreateQuestionBankDto {
templateId?: string;
name: string;
description?: string;
}
export interface UpdateQuestionBankDto {
name?: string;
description?: string;
status?: QuestionBankStatus;
}
export interface CreateQuestionBankItemDto {
questionText: string;
questionType?: QuestionType;
options?: string[];
correctAnswer?: string;
keyPoints: string[];
difficulty?: QuestionDifficulty;
dimension?: QuestionDimension;
basis?: string;
}
export interface UpdateQuestionBankItemDto {
questionText?: string;
questionType?: QuestionType;
options?: string[];
correctAnswer?: string;
keyPoints?: string[];
difficulty?: QuestionDifficulty;
dimension?: QuestionDimension;
basis?: string;
}
export interface ReviewDto {
approved: boolean;
comment?: string;
}
const DIMENSIONS = [
QuestionDimension.PROMPT,
QuestionDimension.LLM,
QuestionDimension.IDE,
QuestionDimension.DEV_PATTERN,
QuestionDimension.WORK_CAPABILITY,
];
@Injectable()
export class QuestionBankService {
private readonly logger = new Logger(QuestionBankService.name);
constructor(
@InjectRepository(QuestionBank)
private readonly bankRepository: Repository<QuestionBank>,
@InjectRepository(QuestionBankItem)
private readonly itemRepository: Repository<QuestionBankItem>,
private readonly modelConfigService: ModelConfigService,
private readonly configService: ConfigService,
) {}
async create(
createDto: CreateQuestionBankDto,
userId: string,
tenantId: string,
): Promise<QuestionBank> {
const bank = this.bankRepository.create({
...createDto,
createdBy: userId,
tenantId,
status: QuestionBankStatus.DRAFT,
});
return this.bankRepository.save(bank);
}
async findAll(
userId: string,
tenantId: string,
page?: number,
limit?: number,
): Promise<{ data: QuestionBank[]; total: number } | QuestionBank[]> {
const queryBuilder = this.bankRepository
.createQueryBuilder('bank')
.leftJoinAndSelect('bank.template', 'template')
.where('bank.tenantId = :tenantId', { tenantId })
.orderBy('bank.createdAt', 'DESC');
if (page !== undefined && limit !== undefined) {
const [data, total] = await queryBuilder
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
return { data, total };
}
return queryBuilder.getMany();
}
async findByTemplateId(templateId: string): Promise<QuestionBank | null> {
return this.bankRepository.findOne({
where: { templateId },
relations: ['template', 'items'],
});
}
async findOne(id: string): Promise<QuestionBank> {
const bank = await this.bankRepository.findOne({
where: { id },
relations: ['template', 'items'],
});
if (!bank) {
throw new NotFoundException(`QuestionBank with ID "${id}" not found`);
}
return bank;
}
async update(
id: string,
updateDto: UpdateQuestionBankDto,
): Promise<QuestionBank> {
const bank = await this.findOne(id);
Object.assign(bank, updateDto);
return this.bankRepository.save(bank);
}
async remove(id: string): Promise<void> {
const bank = await this.findOne(id);
await this.bankRepository.remove(bank);
}
async submitForReview(id: string, userId: string): Promise<QuestionBank> {
const bank = await this.findOne(id);
if (bank.status !== QuestionBankStatus.DRAFT) {
throw new ForbiddenException(
'Only DRAFT status can be submitted for review',
);
}
bank.status = QuestionBankStatus.PENDING_REVIEW;
return this.bankRepository.save(bank);
}
async review(
id: string,
reviewDto: ReviewDto,
reviewerId: string,
): Promise<QuestionBank> {
const bank = await this.findOne(id);
if (bank.status !== QuestionBankStatus.PENDING_REVIEW) {
throw new ForbiddenException(
'Only PENDING_REVIEW status can be reviewed',
);
}
bank.reviewedBy = reviewerId;
bank.reviewedAt = new Date();
bank.reviewComment = reviewDto.comment || null;
bank.status = reviewDto.approved
? QuestionBankStatus.PUBLISHED
: QuestionBankStatus.REJECTED;
return this.bankRepository.save(bank);
}
async publish(id: string): Promise<QuestionBank> {
const bank = await this.findOne(id);
if (bank.status === QuestionBankStatus.PUBLISHED) {
return bank;
}
bank.status = QuestionBankStatus.PUBLISHED;
this.logger.log(`QuestionBank ${id} published`);
return this.bankRepository.save(bank);
}
async addItem(
bankId: string,
createDto: CreateQuestionBankItemDto,
): Promise<QuestionBankItem> {
await this.findOne(bankId);
const item = this.itemRepository.create({
...createDto,
bankId,
questionType: createDto.questionType || QuestionType.SHORT_ANSWER,
difficulty: createDto.difficulty || QuestionDifficulty.STANDARD,
dimension: createDto.dimension || QuestionDimension.PROMPT,
});
return this.itemRepository.save(item);
}
async updateItem(
bankId: string,
itemId: string,
updateDto: UpdateQuestionBankItemDto,
): Promise<QuestionBankItem> {
await this.findOne(bankId);
const item = await this.itemRepository.findOne({
where: { id: itemId, bankId },
});
if (!item) {
throw new NotFoundException(`QuestionBankItem with ID "${itemId}" not found`);
}
Object.assign(item, updateDto);
return this.itemRepository.save(item);
}
async removeItem(bankId: string, itemId: string): Promise<void> {
await this.findOne(bankId);
const item = await this.itemRepository.findOne({
where: { id: itemId, bankId },
});
if (!item) {
throw new NotFoundException(`QuestionBankItem with ID "${itemId}" not found`);
}
await this.itemRepository.remove(item);
}
async generateQuestions(
bankId: string,
count: number,
knowledgeBaseContent: string,
tenantId: string,
): Promise<QuestionBankItem[]> {
const bank = await this.findOne(bankId);
this.logger.log(`[generateQuestions] Starting AI generation for bank ${bankId}, count: ${count}`);
const modelConfig = await this.modelConfigService.findDefaultByType(
tenantId,
ModelType.LLM,
);
const model = new ChatOpenAI({
apiKey: modelConfig.apiKey || 'ollama',
modelName: modelConfig.modelId,
temperature: 0.7,
configuration: {
baseURL: modelConfig.baseUrl || 'https://api.deepseek.com/v1',
},
});
const systemPrompt = `你是一位专业的知识评估专家。请根据提供的知识库片段生成 ${count} 个唯一的测试题目。
### 强制性语言规则:
**必须使用中文 (Simplified Chinese) 进行回复**。即使知识库内容是英文或其他语言,问题(question_text)和关键点(key_points)也必须使用中文。
### 多样性规则:
1. 禁止重复:绝对禁止生成相似的题目
2. 深度挖掘:从不同的角度出题,如流程、限制、优缺点、具体参数等
3. 随机扰动:从不同的逻辑链条出发
### 任务:
请以 JSON 数组格式返回 ${count} 个问题:
[
{
"question_text": "问题内容",
"key_points": ["要点1", "要点2"],
"difficulty": "STANDARD|ADVANCED|SPECIALIST",
"dimension": "prompt|llm|ide|devPattern|workCapability",
"basis": "[n] 引用原文..."
}
]`;
const humanMsg = `请使用中文基于以下内容生成题目:\n\n${knowledgeBaseContent}`;
try {
const response = await model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(humanMsg),
]);
let parsedQuestions = safeParseJson<any>(response.content as string);
if (!parsedQuestions) {
this.logger.error('[generateQuestions] Failed to parse JSON from AI response');
throw new Error('Invalid JSON format from AI');
}
if (!Array.isArray(parsedQuestions)) {
parsedQuestions = [parsedQuestions];
}
const dimensionMap: Record<string, string> = {
'prompt': 'PROMPT',
'llm': 'LLM',
'ide': 'IDE',
'devPattern': 'DEV_PATTERN',
'workCapability': 'WORK_CAPABILITY',
};
const difficultyMap: Record<string, string> = {
'STANDARD': 'STANDARD',
'ADVANCED': 'ADVANCED',
'SPECIALIST': 'SPECIALIST',
};
const items: QuestionBankItem[] = [];
for (const q of parsedQuestions) {
const dimension = dimensionMap[q.dimension?.toLowerCase()] || 'WORK_CAPABILITY';
const difficulty = difficultyMap[q.difficulty?.toUpperCase()] || 'STANDARD';
const item = this.itemRepository.create({
bankId,
questionText: q.question_text,
questionType: QuestionType.SHORT_ANSWER,
keyPoints: q.key_points || [],
difficulty: difficulty as QuestionDifficulty,
dimension: dimension as QuestionDimension,
basis: q.basis,
});
items.push(await this.itemRepository.save(item));
}
this.logger.log(`[generateQuestions] Generated ${items.length} questions for bank ${bankId}`);
return items;
} catch (error) {
this.logger.error('[generateQuestions] Error generating questions:', error);
throw error;
}
}
async selectQuestions(
bankId: string,
count: number,
): Promise<QuestionBankItem[]> {
const bank = await this.findOne(bankId);
if (bank.status !== QuestionBankStatus.PUBLISHED) {
throw new ForbiddenException(
'Only PUBLISHED banks can be used for selection',
);
}
const allItems = await this.itemRepository.find({
where: { bankId },
});
if (allItems.length === 0) {
this.logger.warn(`[selectQuestions] No items found for bank ${bankId}`);
return [];
}
const usedIds = new Set<string>();
const selected: QuestionBankItem[] = [];
let dimIdx = 0;
while (selected.length < count && usedIds.size < allItems.length) {
const dim = DIMENSIONS[dimIdx % DIMENSIONS.length];
dimIdx++;
if (selected.length >= count) break;
const available = allItems.filter(
(i) => i.dimension === dim && !usedIds.has(i.id),
);
if (available.length > 0) {
const idx = Math.floor(Math.random() * available.length);
const item = available[idx];
selected.push(item);
usedIds.add(item.id);
}
}
if (selected.length < count) {
const remaining = allItems.filter((i) => !usedIds.has(i.id));
const shuffled = remaining.sort(() => Math.random() - 0.5);
for (const item of shuffled) {
if (selected.length >= count) break;
selected.push(item);
usedIds.add(item.id);
}
}
this.logger.log(
`[selectQuestions] Selected ${selected.length} questions from bank ${bankId}`,
);
return selected;
}
}
@@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class QuestionOutlineService {}
@@ -0,0 +1,89 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AssessmentTemplate } from '../entities/assessment-template.entity';
import { CreateTemplateDto } from '../dto/create-template.dto';
import { UpdateTemplateDto } from '../dto/update-template.dto';
import { TenantService } from '../../tenant/tenant.service';
@Injectable()
export class TemplateService {
constructor(
@InjectRepository(AssessmentTemplate)
private readonly templateRepository: Repository<AssessmentTemplate>,
private readonly tenantService: TenantService,
) {}
async create(
createDto: CreateTemplateDto,
userId: string,
tenantId: string,
): Promise<AssessmentTemplate> {
const { ...data } = createDto;
const template = this.templateRepository.create({
...data,
createdBy: userId,
tenantId,
});
return this.templateRepository.save(template);
}
async findAll(tenantId: string): Promise<AssessmentTemplate[]> {
return this.templateRepository.find({
where: { tenantId, isActive: true },
relations: ['knowledgeGroup'],
order: { createdAt: 'DESC' },
});
}
async findOne(
id: string,
userId: string,
tenantId: string,
): Promise<AssessmentTemplate> {
const template = await this.templateRepository.findOne({
where: { id },
relations: ['knowledgeGroup'],
});
if (!template) {
throw new NotFoundException(`Template with ID "${id}" not found`);
}
// Check permission using TenantService
const hasAccess = await this.tenantService.canAccessTenant(
userId,
template.tenantId,
tenantId,
);
if (!hasAccess) {
throw new ForbiddenException(
`You do not have permission to access this template`,
);
}
return template;
}
async update(
id: string,
updateDto: UpdateTemplateDto,
userId: string,
tenantId: string,
): Promise<AssessmentTemplate> {
const template = await this.findOne(id, userId, tenantId);
Object.assign(template, updateDto);
return this.templateRepository.save(template);
}
async remove(id: string, userId: string, tenantId: string): Promise<void> {
const template = await this.findOne(id, userId, tenantId);
// Soft delete by setting isActive to false
template.isActive = false;
await this.templateRepository.save(template);
}
}