feat: implement QuestionBank CRUD with pagination and template query

- Add pagination support to findAll (page, limit query params)
- Add findByTemplateId method to service
- Add GET /by-template/:templateId endpoint to controller
- Service already includes CRUD for QuestionBank and QuestionBankItem
This commit is contained in:
Developer
2026-04-23 17:19:11 +08:00
commit 0a9588abb7
492 changed files with 112453 additions and 0 deletions
@@ -0,0 +1,246 @@
import { ChatOpenAI } from '@langchain/openai';
import { SystemMessage, HumanMessage } from '@langchain/core/messages';
import { RunnableConfig } from '@langchain/core/runnables';
import { EvaluationState } from '../state';
import { safeParseJson } from '../../../common/json-utils';
/**
* Node responsible for generating assessment questions based on the knowledge base content.
*/
export const questionGeneratorNode = async (
state: EvaluationState,
config?: RunnableConfig,
): Promise<Partial<EvaluationState>> => {
const { model, knowledgeBaseContent, targetCount } = (config?.configurable as any) || {};
const limitCount = targetCount || 5;
console.log('[GeneratorNode] Starting generation...', {
language: state.language,
hasModel: !!model,
contentLength: knowledgeBaseContent?.length,
keywords: state.keywords || [],
targetCount: limitCount,
});
if (!model || !knowledgeBaseContent) {
console.error('[GeneratorNode] Missing model or knowledgeBaseContent');
throw new Error(
'Missing model or knowledgeBaseContent in node configuration',
);
}
const isZh = state.language === 'zh';
const isJa = state.language === 'ja';
const style = state.style || 'technical';
const difficultyText = state.difficultyDistribution
? JSON.stringify(state.difficultyDistribution)
: isZh
? '随机分布'
: isJa
? 'ランダム分布'
: 'Random distribution';
const keywords = state.keywords || [];
const hasKeywords = keywords.length > 0;
const keywordText = hasKeywords ? keywords.join(', ') : '';
const rulesZh = [
`**禁止重复**:绝对禁止生成与下方“禁止重复列表”中相似的题目。`,
`**深度挖掘**:如果之前的题目考查了核心定义,新题目必须考查具体的应用案例、对比分析或隐藏的细节。`,
hasKeywords
? `**关键词权重**:必须围绕关键词 (${keywordText}) 展开,但要从关键词的不同侧面(如流程、限制、优缺点、具体参数等)进行挖掘。`
: null,
`**随机扰动**:即使对于相同的主题或关键词,也要尝试从不同的逻辑链条(如“因为...所以...” vs “如果没有...会怎样”)出发。`,
]
.filter(Boolean)
.map((r, i) => `${i + 1}. ${r}`)
.join('\n');
const rulesJa = [
`**重複禁止**:下記の「作成済み問題リスト」と類似した内容は絶対に避けてください。`,
`**多角的アプローチ**:前回が定義だった場合は、今回は応用方法、制限事項、具体的な数値などに焦点を当ててください。`,
hasKeywords
? `**キーワードの深掘り**:キーワード (${keywordText}) の異なる側面から出題してください。`
: null,
]
.filter(Boolean)
.map((r, i) => `${i + 1}. ${r}`)
.join('\n');
const rulesEn = [
`**NO REPETITION**: Strictly avoid any conceptual overlap with the "Previous Questions" list below.`,
`**New Facets**: If previous questions were about definitions, focus on applications, edge cases, or specific details.`,
hasKeywords
? `**Keyword Variety**: Center on (${keywordText}), but explore different aspects (process, pros/cons, requirements).`
: null,
]
.filter(Boolean)
.map((r, i) => `${i + 1}. ${r}`)
.join('\n');
const existingQuestions = state.questions || [];
const existingQuestionsText = existingQuestions
.map((q, i) => `Q${i + 1}: ${q.questionText}`)
.join('\n');
const systemPromptZh = `你是一位专业的知识评估专家。请根据提供的知识库片段生成 1 个唯一的测试题目。
### 强制性语言规则:
**必须使用中文 (Simplified Chinese) 进行回复**。即使知识库内容是英文或其他语言,问题(question_text)和关键点(key_points)也必须使用中文。
### 强制性多样性规则:
${rulesZh}
### 禁止重复列表(已出过):
${existingQuestionsText || '无'}
### 任务:
${hasKeywords ? `目标关键词:${keywordText}\n` : ''}出题风格:${style}
难度:${difficultyText}
请以 JSON 数组格式返回 1 个问题:
[
{
"question_text": "...",
"key_points": ["点1", "点2"],
"difficulty": "...",
"dimension": "prompt/llm/ide/devPattern/workCapability",
"basis": "[n] 引用原文..."
}
]`;
// dimension取值:prompt=提示词, llm=LLM原理, ide=IDE协作, devPattern=开发范式, workCapability=工作能力
const systemPromptJa = `あなたは専門的なアセスメントエキスパートです。提供されたナレッジベースに基づいて、ユニークな問題を 1 つ作成してください。
### 言語ルール(最重要):
**必ず日本語で作成してください**。提供されたナレッジベースが英語や中国語、その他の言語であっても、質問文(question_text)およびキーポイント(key_points)は必ず日本語で回答してください。中国語が混ざらないように厳格に注意してください。
### 多様性ルール:
${rulesJa}
### 作成済み問題リスト:
${existingQuestionsText || 'なし'}
### 任務:
${hasKeywords ? `目標キーワード:${keywordText}\n` : ''}出題スタイル:${style}
難易度:${difficultyText}
以下のJSON配列形式で問題を1つ返してください:
[
{
"question_text": "...",
"key_points": ["ポイント1", "ポイント2"],
"difficulty": "...",
"dimension": "prompt/llm/ide/devPattern/workCapability",
"basis": "[n] 引用箇所..."
}
]`;
const systemPromptEn = `You are an expert examiner. Generate 1 UNIQUE question based on the provided context.
### Language Rule:
**You MUST generate the question and key points in English.**
### Diversity Rules:
${rulesEn}
### Previous Questions (DO NOT REPEAT):
${existingQuestionsText || 'None'}
Return 1 question as a JSON array with format:
[
{
"question_text": "...",
"key_points": ["point1", "point2"],
"difficulty": "...",
"dimension": "prompt/llm/ide/devPattern/workCapability",
"basis": "[n] citation..."
}
]`;
// dimension values: prompt=prompt engineering, llm=LLM principles, ide=IDE collaboration, devPattern=development paradigm, workCapability=work capability
const systemPrompt = isZh
? systemPromptZh
: isJa
? systemPromptJa
: systemPromptEn;
const humanMsg = isZh
? `请使用中文基于以下内容生成题目:\n\n${knowledgeBaseContent}`
: isJa
? `以下の内容に基づいて、必ず日本語でアセスメント問題を作成してください:\n\n${knowledgeBaseContent}`
: `Generate evaluation question in English based on:\n\n${knowledgeBaseContent}`;
try {
const response = await model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(humanMsg),
]);
try {
let newQuestions = safeParseJson<any>(response.content as string);
if (!newQuestions) {
console.error('[GeneratorNode] Failed to parse JSON. Raw content:', response.content);
throw new Error('Invalid JSON format from AI');
}
// Handle both array and single object
if (!Array.isArray(newQuestions)) {
newQuestions = [newQuestions];
}
const dimensionMap: Record<string, string> = {
// 中文
'技术能力-提示词': 'prompt',
'提示词': 'prompt',
'技术能力-LLM': 'llm',
'LLM': 'llm',
'IDE协作能力': 'ide',
'IDE': 'ide',
'AI开发范式': 'devPattern',
'开发范式': 'devPattern',
'工作能力-安全': 'workCapability',
'工作能力': 'workCapability',
// 英文直接映射
'prompt': 'prompt',
'llm': 'llm',
'ide': 'ide',
'devPattern': 'devPattern',
'workCapability': 'workCapability',
};
const mappedNewQuestions = newQuestions.map((q: any) => {
let inferredDimension = 'workCapability';
const dimValue = q.dimension?.toString().toLowerCase().trim();
if (dimValue) {
inferredDimension = dimensionMap[dimValue] || 'workCapability';
console.log('[GeneratorNode] Dimension mapping:', { original: q.dimension, mapped: inferredDimension });
}
return {
id: (existingQuestions.length + 1).toString(),
questionText: q.question_text,
keyPoints: q.key_points,
difficulty: q.difficulty,
basis: q.basis,
dimension: inferredDimension,
};
});
const questionsToGenerate = Math.max(1, limitCount - existingQuestions.length);
const limitedNewQuestions = mappedNewQuestions.slice(0, questionsToGenerate);
console.log('[GeneratorNode] Generated questions:', mappedNewQuestions.length, 'Limit:', questionsToGenerate);
return {
questions: [...existingQuestions, ...limitedNewQuestions],
};
} catch (error) {
console.error('[GeneratorNode] Parse error:', error);
return { questions: existingQuestions };
}
} catch (invokeError) {
console.error('[GeneratorNode] Invoke error:', invokeError);
throw invokeError;
}
};