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, @InjectRepository(QuestionBankItem) private readonly itemRepository: Repository, private readonly modelConfigService: ModelConfigService, private readonly configService: ConfigService, ) {} async create( createDto: CreateQuestionBankDto, userId: string, tenantId: string | null, ): Promise { 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 { return this.bankRepository.findOne({ where: { templateId }, relations: ['template', 'items'], }); } async findOne(id: string): Promise { 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 { const bank = await this.findOne(id); Object.assign(bank, updateDto); return this.bankRepository.save(bank); } async remove(id: string): Promise { const bank = await this.findOne(id); await this.bankRepository.remove(bank); } async submitForReview(id: string, userId: string): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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(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 = { 'prompt': 'PROMPT', 'llm': 'LLM', 'ide': 'IDE', 'devPattern': 'DEV_PATTERN', 'workCapability': 'WORK_CAPABILITY', }; const difficultyMap: Record = { '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 { 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(); 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 { 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(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; } }