forked from hangshuo652/aurak
0b0a060967
- 证书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更新
504 lines
15 KiB
TypeScript
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;
|
|
}
|
|
} |