Files
aurak/server/src/assessment/services/question-bank.service.ts
T
Developer 0b0a060967 fix: 全部TS错误修复(25->0) + 证书API 500修复 + i18n缺失key补全 + 类型定义修正
- 证书API 500修复: AssessmentCertificate实体注册到app.module.ts
- 前端TS错误25个清零: i18n key 17个, 类型定义8个
- i18n补全: 17个缺失key添加到zh/en/ja
- KnowledgeFile类型: 添加title, content字段
- importService: 改用apiClient.request替代raw fetch
- ModeSelector: 移除jsx prop
- questionBankService: .ok -> .status >= 400
- NotebookDetailView: .filter -> .items.filter
- ImportTasksDrawer: tasks.items提取
- API端点审计: 16/16通过
- 数据库Schema审计: 25表288列一致
- AGENTS.md更新
2026-05-18 08:30:59 +08:00

504 lines
15 KiB
TypeScript

import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
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,
QuestionBankItemStatus,
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 | null,
): Promise<QuestionBank> {
if (!createDto.name || !createDto.name.trim()) {
throw new BadRequestException('Question bank name is required');
}
if (createDto.templateId) {
const existing = await this.bankRepository.findOne({
where: { templateId: createDto.templateId, tenantId: tenantId as any },
});
if (existing) {
if (existing.status === QuestionBankStatus.DRAFT || existing.status === QuestionBankStatus.REJECTED) {
await this.bankRepository.remove(existing);
} else {
throw new BadRequestException('该模板已关联有效题库,请编辑已有题库');
}
}
}
const bankData: any = {
name: createDto.name,
description: createDto.description || '',
createdBy: userId,
tenantId: tenantId || null,
status: QuestionBankStatus.DRAFT,
};
if (createDto.templateId) {
bankData.templateId = createDto.templateId;
}
const bank = this.bankRepository.create(bankData as any);
return this.bankRepository.save(bank as unknown as QuestionBank);
}
async findAll(
userId: string,
tenantId: string | null,
page?: number,
limit?: number,
): Promise<{ data: QuestionBank[]; total: number } | QuestionBank[]> {
console.log('[QuestionBank findAll] userId:', userId, 'tenantId:', tenantId);
const queryBuilder = this.bankRepository
.createQueryBuilder('bank')
.leftJoinAndSelect('bank.template', 'template');
if (tenantId === null) {
queryBuilder.where('bank.tenantId IS NULL');
} else {
queryBuilder.where('bank.tenantId = :tenantId', { tenantId });
}
queryBuilder.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;
}
if (bank.status !== QuestionBankStatus.REJECTED) {
throw new ForbiddenException(
'Only REJECTED status can be re-published',
);
}
bank.status = QuestionBankStatus.PUBLISHED;
this.logger.log(`QuestionBank ${id} published`);
return this.bankRepository.save(bank);
}
async addItem(
bankId: string,
createDto: CreateQuestionBankItemDto,
): Promise<QuestionBankItem> {
if (!createDto.questionText || !createDto.questionText.trim()) {
throw new BadRequestException('Question text is required');
}
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,
status: QuestionBankItemStatus.PENDING_REVIEW,
});
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);
if (count <= 0 || count > 50) {
throw new BadRequestException('生成数量必须在 1-50 之间');
}
if (!knowledgeBaseContent || knowledgeBaseContent.trim().length < 10) {
throw new BadRequestException('知识库内容太短,无法生成有效题目');
}
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 BadRequestException('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,
status: QuestionBankItemStatus.PENDING_REVIEW,
});
items.push(item);
}
const savedItems = await this.itemRepository.save(items);
this.logger.log(`[generateQuestions] Generated ${savedItems.length} questions for bank ${bankId}`);
return savedItems;
} 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[] = [];
const availableItems = [...allItems];
let dimIdx = 0;
while (selected.length < count && availableItems.length > 0) {
const dim = DIMENSIONS[dimIdx % DIMENSIONS.length];
dimIdx++;
const available = availableItems.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);
const actualIdx = availableItems.findIndex(i => i.id === item.id);
if (actualIdx > -1) {
availableItems.splice(actualIdx, 1);
}
}
if (dimIdx >= DIMENSIONS.length * 3) {
break;
}
}
if (selected.length < count && availableItems.length > 0) {
const shuffled = this.shuffleArray([...availableItems]);
for (const item of shuffled) {
if (selected.length >= count) break;
if (!usedIds.has(item.id)) {
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;
}
async batchReviewItems(
bankId: string,
itemIds: string[],
approved: boolean,
comment?: string,
): Promise<QuestionBankItem[]> {
await this.findOne(bankId);
const items = await this.itemRepository.find({
where: itemIds.map(id => ({ id, bankId })),
});
if (items.length !== itemIds.length) {
throw new NotFoundException('Some items not found');
}
const newStatus = approved
? QuestionBankItemStatus.PUBLISHED
: QuestionBankItemStatus.PENDING_REVIEW;
for (const item of items) {
item.status = newStatus;
if (comment) {
item.basis = item.basis
? `${item.basis}\n[审核意见]: ${comment}`
: `[审核意见]: ${comment}`;
}
}
await this.itemRepository.save(items);
this.logger.log(`[batchReview] ${items.length} items ${approved ? 'approved' : 'rejected'}`);
return items;
}
private shuffleArray<T>(array: T[]): T[] {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
}