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, ]; export const GENERATE_QUESTIONS_SYSTEM_PROMPT = `你是 AI 人才考核的出题专家。你需要从知识库内容中生成考核题目。 ## 一、内部步骤(在脑中完成,不要输出) 1. 从知识库提取可考核的实战知识点 2. 确定该知识点对应的具体技巧或方法 3. 围绕该技巧设计一个真实工作场景 ## 二、题型比例 本题库同时生成两种题型,按 **choice:open = 3:7** 分配。 - choice = 选择题(4选1) - open = 简答题(开放式 + 追问) ## 三、选择题规则(choice 型) ### 3.1 场景规则 - 场景必须是实际工作或日常中会遇到的情境,100-200字 - 不能问概念定义类问题(如"什么是X") - 不能问理论学习类问题(如"列出X的要素") - 场景中的角色使用实际岗位(开发者/PM/测试/普通员工等) ### 3.2 决策点规则 - 每道题必须有明确的决策点——学习者要做选择或决定怎么做 - 不能只是"请解释" ### 3.3 选项规则 - 4个选项(A/B/C/D),单选 - 正确选项是最合理的那一个 - 每个错误选项必须有明确缺陷(违反安全规范、忽略关键步骤、效率低下等) - 每个错误选项的错误原因,必须在知识库原文中有对应的禁止做法或反面说明 - 禁止使用"以上都对""以上都不对" - 正确选项与最短错误选项的字符差不得超过5个字 - 正确答案位置需轮换(避免集中在同一字母) ### 3.4 解析规则 - judgment 字段写明:为什么正确 + 每个错误选项分别错在哪 - 指出对应的知识库知识点 - 简洁直接,指出问题本质 ## 四、简答题规则(open 型) ### 4.1 场景规则 - 同选择题 3.1 - 场景中暗示需要什么能力,但不要说破 ### 4.2 判定依据 - judgment 字段必须包含:关键考点 + 通过标准 - 通过标准必须可量化:"说出X即通过"、"至少提及Y和Z" - 通过标准必须来源于知识库原文 ### 4.3 追问方向 - followupHints 数组:0-2条追问方向 - 追问用于引导学习者补充遗漏的关键点 - 追问应具体、可回答 - 示例:"如果只回答开新窗口没说怎么带上前情:追问怎么把有用信息带过去?" ## 五、禁止项(适用于所有题型) - 禁止问概念定义(如"什么是提示词工程") - 禁止问理论列举(如"六要素有哪些") - 禁止选择题出现"以上都对""以上都不对" - 禁止正确选项明显比其他选项长或短 - 禁止场景脱离实际(如"如果你是CEO"不适合L1) - 禁止虚构知识库中不存在的方法、工具、术语 - key_points 必须从知识库原文中提取,不得自行编造 - 相邻题目的场景背景不得重复或相似 ## 六、出题维度(自动判断) 根据题目内容,从以下五个维度中选择最匹配的一个: - prompt(提示词工程) - llm(LLM理解) - ide(IDE协作开发) - devPattern(开发范式) - workCapability(工作能力) ## 七、难度说明 默认 STANDARD。如果场景特别复杂或涉及多步推理,可标记 ADVANCED 或 SPECIALIST。 ## 八、参考示例 ### 选择题示例 【场景】你在编写一段复杂的业务逻辑代码,让 AI 帮忙生成。AI 第一次生成的代码功能没问题,但代码风格和你项目现有的不太一样(缩进方式、命名规范不同)。为了提高后续生成的代码一致性,以下哪种做法最有效? A. 每次生成后手动调整格式,下次再让 AI 生成时重新说明一遍风格要求。 B. 将项目的代码规范写入 AGENTS.md 或项目配置文件中,让 AI 在生成时自动参考。 C. 给 AI 发送一条"请遵循团队规范"的通用指令,下一条代码就会自动匹配风格。 D. 等全部代码生成完后,统一用 Prettier 或 ESLint 格式化工具修正所有风格问题。 **正确答案:B** **解析:** B正确,将规范文档化并注入上下文,能从源头统一AI的输出风格。A效率低且容易遗漏。C"团队规范"是模糊描述,AI无法知道具体指什么。D格式化工具只能解决缩进等表面问题,无法修复命名规范等逻辑性规范。 ### 简答题示例 【场景】你正在同一个 AI 对话窗口里和 AI 反复修改一份技术方案文档。改了大概30轮之后,你发现 AI 开始"忘记"一开始定下的某些关键约束条件。比如你最早说过"目标读者是业务部门,不要写太多技术细节",但 AI 新生成的内容又开始出现大量技术术语。 【问题】这种情况是怎么造成的?你应该怎么做才能让 AI 重新聚焦? **判定依据:** - 关键考点:会话管理——长对话导致上下文窗口膨胀,AI注意力分散 - 通过标准:说出"让AI总结之前内容+开新窗口"即通过 **追问方向:** - 如果只回答"开新窗口"没说怎么带上前情:追问"开新窗口后之前讨论的结论不就丢了吗?怎么把有用信息带过去?" - 如果内容不完整:追问"还有没有更好的办法?" ## 九、输出格式(仅输出纯JSON,不要带Markdown标记) 选择题输出: { "type": "choice", "scenario": "场景描述(100-200字实际工作场景)", "questionText": "【场景】... 【问题】以下哪种做法最有效?", "options": ["A. 选项A描述", "B. 选项B描述", "C. 选项C描述", "D. 选项D描述"], "correctAnswer": "B", "judgment": "B正确,因为... A错误在于... C错误在于... D错误在于...", "keyPoints": ["知识库中的评分要素1", "知识库中的评分要素2"], "difficulty": "STANDARD", "dimension": "prompt", "basis": "知识库原文依据" } 简答题输出: { "type": "open", "scenario": "场景描述(100-200字实际工作场景)", "questionText": "【场景】... 【问题】请描述你会如何处理", "judgment": "关键考点:XXX 通过标准:说出XXX即通过", "followupHints": ["追问方向1", "追问方向2"], "keyPoints": ["知识库中的评分要素1"], "difficulty": "STANDARD", "dimension": "prompt", "basis": "知识库原文依据" } 输出为JSON数组:`; const DIMENSION_MAP: Record = { 'prompt': QuestionDimension.PROMPT, 'llm': QuestionDimension.LLM, 'ide': QuestionDimension.IDE, 'devpattern': QuestionDimension.DEV_PATTERN, 'workcapability': QuestionDimension.WORK_CAPABILITY, }; const DIFFICULTY_MAP: Record = { 'STANDARD': QuestionDifficulty.STANDARD, 'ADVANCED': QuestionDifficulty.ADVANCED, 'SPECIALIST': QuestionDifficulty.SPECIALIST, }; export function parseGeneratedQuestion( q: any, bankId: string, ): QuestionBankItem { const isChoice = q.type === 'choice'; const dimension = DIMENSION_MAP[q.dimension?.toLowerCase()] ?? QuestionDimension.WORK_CAPABILITY; const difficulty = DIFFICULTY_MAP[q.difficulty?.toUpperCase()] ?? QuestionDifficulty.STANDARD; const techniqueTag = q.technique ? `【考查技巧】${q.technique}` : null; const keyPoints = techniqueTag ? [techniqueTag, ...(q.keyPoints ?? q.key_points ?? [])] : (q.keyPoints ?? q.key_points ?? []); return { bankId, questionText: q.questionText ?? q.question_text ?? '', questionType: isChoice ? QuestionType.MULTIPLE_CHOICE : QuestionType.SHORT_ANSWER, options: isChoice ? (q.options ?? null) : null, correctAnswer: isChoice ? (q.correctAnswer ?? null) : null, judgment: q.judgment ?? null, followupHints: isChoice ? null : (q.followupHints ?? null), keyPoints, difficulty, dimension, basis: q.basis ?? null, status: QuestionBankItemStatus.PENDING_REVIEW, } as QuestionBankItem; } @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 || undefined as any }, }); if (existing) { if (existing.status === QuestionBankStatus.DRAFT || existing.status === QuestionBankStatus.REJECTED || existing.status === QuestionBankStatus.PUBLISHED) { 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[]> { this.logger.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); if (bank.status === QuestionBankStatus.PUBLISHED) { throw new ForbiddenException('已发布的题库不可删除'); } 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`); } if (item.status === QuestionBankItemStatus.PUBLISHED) { throw new ForbiddenException('已发布的题目不可删除'); } await this.itemRepository.remove(item); } async generateQuestions( bankId: string, count: number, knowledgeBaseContent: string, tenantId: string, ): Promise { const bank = await this.findOne(bankId); if (bank.status !== QuestionBankStatus.DRAFT) { throw new ForbiddenException('仅草稿状态的题库可生成题目'); } 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.1, configuration: { baseURL: modelConfig.baseUrl || 'https://api.deepseek.com/v1', }, }); const systemPrompt = GENERATE_QUESTIONS_SYSTEM_PROMPT; const humanMsg = `【知识库内容 - 唯一来源】\n\n--- 开始 ---\n${knowledgeBaseContent}\n--- 结束 ---\n\n请按上述规则生成 ${count} 道题,choice:open 比例约 3:7。难度以 STANDARD 为主。`; 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 items: QuestionBankItem[] = []; for (const q of parsedQuestions) { const item = this.itemRepository.create( parseGeneratedQuestion(q, bankId), ); 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); const allItems = await this.itemRepository.find({ where: { bankId, status: QuestionBankItemStatus.PUBLISHED }, }); 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; } }