feat: implement QuestionBank CRUD with pagination and template query

- Add pagination support to findAll (page, limit query params)
- Add findByTemplateId method to service
- Add GET /by-template/:templateId endpoint to controller
- Service already includes CRUD for QuestionBank and QuestionBankItem
This commit is contained in:
Developer
2026-04-23 17:19:11 +08:00
commit 0a9588abb7
492 changed files with 112453 additions and 0 deletions
@@ -0,0 +1,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);
}
}