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,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