forked from hangshuo652/aurak
c57c3028e2
- 修复 shuffleArray 返回新数组但调用处用 const 未接收返回值(3处) - 新增 test-multiround.mjs Playwright 多轮对话测试(简答+追问全流程) - 新增 do-assessment.mjs / check-result.mjs 考核体验脚本 - CLAUDE.md 增加 AI 工作流指令规则 - package.json 添加 playwright 依赖 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
644 lines
22 KiB
TypeScript
644 lines
22 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,
|
||
];
|
||
|
||
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<string, QuestionDimension> = {
|
||
'prompt': QuestionDimension.PROMPT,
|
||
'llm': QuestionDimension.LLM,
|
||
'ide': QuestionDimension.IDE,
|
||
'devpattern': QuestionDimension.DEV_PATTERN,
|
||
'workcapability': QuestionDimension.WORK_CAPABILITY,
|
||
};
|
||
|
||
const DIFFICULTY_MAP: Record<string, QuestionDifficulty> = {
|
||
'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<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 || 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<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);
|
||
if (bank.status === QuestionBankStatus.PUBLISHED) {
|
||
throw new ForbiddenException('已发布的题库不可删除');
|
||
}
|
||
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`);
|
||
}
|
||
if (item.status === QuestionBankItemStatus.PUBLISHED) {
|
||
throw new ForbiddenException('已发布的题目不可删除');
|
||
}
|
||
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.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<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 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,
|
||
dimensionWeights?: Array<{ name: string; weight: number }>,
|
||
): Promise<QuestionBankItem[]> {
|
||
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<string>();
|
||
const selected: QuestionBankItem[] = [];
|
||
let availableItems = [...allItems];
|
||
|
||
if (dimensionWeights && dimensionWeights.length > 0) {
|
||
const totalWeight = dimensionWeights.reduce((s, d) => s + d.weight, 0);
|
||
for (const dw of dimensionWeights) {
|
||
const dimName = dw.name as QuestionDimension;
|
||
const targetForDim = Math.round(count * dw.weight / totalWeight);
|
||
let pool = availableItems.filter(i => i.dimension === dimName && !usedIds.has(i.id));
|
||
pool = this.shuffleArray(pool);
|
||
const take = Math.min(targetForDim, pool.length);
|
||
for (let i = 0; i < take; i++) {
|
||
selected.push(pool[i]);
|
||
usedIds.add(pool[i].id);
|
||
}
|
||
}
|
||
availableItems = availableItems.filter(i => !usedIds.has(i.id));
|
||
availableItems = this.shuffleArray(availableItems);
|
||
while (selected.length < count && availableItems.length > 0) {
|
||
const item = availableItems.pop()!;
|
||
selected.push(item);
|
||
usedIds.add(item.id);
|
||
}
|
||
} else {
|
||
let dimIdx = 0;
|
||
while (selected.length < count && availableItems.length > 0) {
|
||
const dim = DIMENSIONS[dimIdx % DIMENSIONS.length];
|
||
dimIdx++;
|
||
const pool = availableItems.filter(i => i.dimension === dim && !usedIds.has(i.id));
|
||
if (pool.length > 0) {
|
||
const idx = Math.floor(Math.random() * pool.length);
|
||
const item = pool[idx];
|
||
selected.push(item);
|
||
usedIds.add(item.id);
|
||
availableItems = availableItems.filter(i => i.id !== item.id);
|
||
}
|
||
if (dimIdx >= DIMENSIONS.length * 3) break;
|
||
}
|
||
if (selected.length < count && availableItems.length > 0) {
|
||
availableItems = this.shuffleArray(availableItems);
|
||
for (const item of availableItems) {
|
||
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 this.shuffleArray(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;
|
||
}
|
||
} |