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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user