forked from hangshuo652/aurak
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user