forked from hangshuo652/aurak
eba30517a6
selectQuestions now only checks item-level PUBLISHED status. startSession already handles bank detection by counting published items. This fixes assessment always falling back to LLM generation.
636 lines
21 KiB
TypeScript
636 lines
21 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 (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<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,
|
||
): 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[] = [];
|
||
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;
|
||
}
|
||
} |