Compare commits

...

2 Commits

Author SHA1 Message Date
Developer 6d9acd7252 fix: MC options display, question selection, timeout handling, and grading prompts 2026-06-03 20:58:19 +08:00
Developer a71bde3452 add IDE question bank seed script (50 questions for L1 IDE协作开发)
- Copilot: 12 questions (intelligent completion, Chat modes, CLI)
- Claude Code: 12 questions (interaction methods, model selection, CLI commands)
- OpenCode: 19 questions (overview, installation, Plan/Build, commands, models)
- Debug: 7 questions (/fix, /debug, /tests, /explain commands)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:55:38 +08:00
13 changed files with 884 additions and 157 deletions
+476
View File
@@ -0,0 +1,476 @@
/**
* IDE 协作开发题库 — 种子脚本
* 运行方式:cd D:/AuraK/server && node scripts/seed-ide-questions.js
*/
const { DatabaseSync } = require('node:sqlite');
const crypto = require('crypto');
const path = require('path');
const DB_PATH = path.join(__dirname, '..', 'data', 'metadata.db');
const BANK_ID = crypto.randomUUID();
const TENANT_ID = 'a140a68e-f70a-44d3-b753-fa33d48cf234'; // 现有tenant
const ADMIN_USER_ID = '1cf8ba6d-d184-4055-ab58-99c6f38bbf93'; // admin用户
const db = new DatabaseSync(DB_PATH);
function uuid() {
return crypto.randomUUID();
}
function now() {
return new Date().toISOString().replace('T', ' ').substring(0, 22) + '000';
}
const ts = now();
// ===== 创建题库 =====
console.log('Creating IDE question bank...');
db.prepare(`
INSERT INTO question_banks (id, tenant_id, template_id, name, description, status, created_by, reviewed_by, reviewed_at, review_comment, created_at, updated_at)
VALUES (?, ?, NULL, ?, ?, 'PUBLISHED', ?, ?, ?, ?, ?, ?)
`).run(BANK_ID, TENANT_ID, 'IDE协作开发题库', 'L1课程四:IDE协作开发考核题库,包含Copilot、Claude Code、OpenCode三种工具的50道题目', ADMIN_USER_ID, ADMIN_USER_ID, ts, 'approve', ts, ts);
// ===== 生成题目 =====
const items = [];
function addItem(questionText, questionType, options, correctAnswer, keyPoints, difficulty, dimension, basis, judgment) {
items.push({
questionText,
questionType,
options: options ? JSON.stringify(options) : null,
correctAnswer,
keyPoints: JSON.stringify(keyPoints),
difficulty: difficulty || 'STANDARD',
dimension: dimension || 'IDE',
basis: basis || null,
judgment: judgment || null,
});
}
// ================================================
// 一、GitHub Copilot — 智能代码补全(4题)
// ================================================
addItem(
'以下说法是否正确?(✓ / ✗)\n\n看到 Copilot 给出的灰色补全建议后,按 Tab 键可以接受建议。',
'TRUE_FALSE', null, '✓',
['Tab键接受补全建议', 'Copilot基础操作'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n看到 Copilot 给出的补全建议后,按 Esc 键可以拒绝这个建议。',
'TRUE_FALSE', null, '✓',
['Esc键拒绝补全建议', 'Copilot基础操作'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n写函数开头后,Copilot 会自动逐行补全后续逻辑。',
'TRUE_FALSE', null, '✓',
['Copilot智能补全功能', '注释驱动代码生成'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章'
);
addItem(
'小张用智能补全生成了一段代码,看起来功能正常。\n\n问:在正式使用这段代码前,他应该先做什么?',
'SHORT_ANSWER', null, '审查代码逻辑,确认没有语法错误或逻辑漏洞后再使用。AI 生成的代码需要人工审核。',
['人工审核AI生成代码', '代码审查', '不直接使用AI生成的代码'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 红线警告'
);
// ================================================
// 二、GitHub Copilot — Chat 三种模式(5题)
// ================================================
addItem(
'小张接手了一个老项目,打开 OrderService.java 发现有段逻辑看不太懂。他打开 Copilot Chat,想先问问这段代码是干什么的。\n\nCopilot Chat 有以下三种模式:Ask / Plan / Agent\n\n问:小张应该选择哪种模式?',
'SHORT_ANSWER', null, 'Ask(问答模式)。Ask 模式只回答问题,不修改代码。',
['Ask模式适用场景', '不修改代码的对话方式'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章 三种对话模式'
);
addItem(
'小李需要在三个文件中新增一个「批量删除用户」的功能,希望 AI 直接帮他完成代码修改。\n\nCopilot Chat 有以下三种模式:Ask / Plan / Agent\n\n问:小李应该选择哪种模式?',
'SHORT_ANSWER', null, 'Agent(智能代理模式)。Agent 模式可以自动跨文件修改代码。',
['Agent模式适用场景', '跨文件修改任务'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章 三种对话模式'
);
addItem(
'小赵想在项目中新增一个功能,但不知道涉及哪些文件、影响范围多大,想让 AI 先扫描整个项目给出方案。\n\nCopilot Chat 有以下三种模式:Ask / Plan / Agent\n\n问:小赵应该选择哪种模式?',
'SHORT_ANSWER', null, 'Plan(计划模式)。Plan 模式只出方案不动代码,适合先评估再动手。',
['Plan模式适用场景', '先规划再执行'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章 三种对话模式'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\nAsk 模式下,Copilot 只会回答问题,不会修改用户的代码。',
'TRUE_FALSE', null, '✓',
['Ask模式只回答不修改'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\nAgent 模式下,Copilot 可以跨多个文件修改代码。',
'TRUE_FALSE', null, '✓',
['Agent模式跨文件修改'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章'
);
// ================================================
// 三、GitHub Copilot — CLI 使用(3题)
// ================================================
addItem(
'小刘想用 Copilot CLI 重构一个 Python 脚本,过程中要多次对话、逐步调优。\n\nCopilot CLI 有以下两种使用方式:交互模式(copilot)/ 非交互模式(copilot -p "指令"\n\n问:小刘应该选择哪种方式?',
'SHORT_ANSWER', null, '交互模式(copilot)。交互模式支持多轮对话,适合需要迭代的复杂任务。',
['CLI交互模式适用场景', '多轮对话重构'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - CLI 使用'
);
addItem(
'小钱想用 Copilot CLI 快速解释一下 git diff 的结果,不想进入交互式对话。\n\nCopilot CLI 有以下两种使用方式:交互模式(copilot)/ 非交互模式(copilot -p "指令"\n\n问:小钱应该选择哪种方式?',
'SHORT_ANSWER', null, '非交互模式(copilot -p "指令")。非交互模式适合一次性任务,快速获得结果后退出。',
['CLI非交互模式适用场景', '一次性任务'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - CLI使用'
);
addItem(
'小赵在 Copilot CLI 交互模式中,想清空当前对话上下文重新开始。\n\nCopilot CLI 中常用的斜杠命令有:/clear / /model / /session / /exit\n\n问:小赵应该使用哪个命令?',
'SHORT_ANSWER', null, '/clear',
['CLI斜杠命令', '/clear清空对话'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - CLI斜杠命令'
);
// ================================================
// 四、Claude Code — 四种交互方式(6题)
// ================================================
addItem(
'以下说法是否正确?(✓ / ✗)\n\n在 Claude Code 中输入 @src/utils.js 可以让 AI 读取该文件。',
'TRUE_FALSE', null, '✓',
['Claude Code文件引用', '@语法'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 文件引用'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n在 Claude Code 中输入 /clear 可以清空当前对话。',
'TRUE_FALSE', null, '✓',
['Claude Code斜杠命令', '/clear清屏'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 斜杠命令'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n在 Claude Code 中输入 !git status 可以查看 Git 状态。',
'TRUE_FALSE', null, '✓',
['Claude Code Bash模式', '!语法执行系统命令'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 Bash模式'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n在 Claude Code 中所有操作都必须用特殊符号,自然语言输入不能完成任何功能。',
'TRUE_FALSE', null, '✗。自然语言也可以完成大部分功能,特殊符号用于特定场景。',
['自然语言可用', '特殊符号的定位'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 交互方式'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n在 Claude Code 中输入 !npm run dev 可以启动开发服务器。',
'TRUE_FALSE', null, '✓',
['Claude Code Bash模式', '!语法启动开发服务器'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 Bash模式'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n在 Claude Code 中输入 /help 可以查看所有可用命令。',
'TRUE_FALSE', null, '✓',
['Claude Code斜杠命令', '/help帮助'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 斜杠命令'
);
// ================================================
// 五、Claude Code — 模型选择与切换(3题)
// ================================================
addItem(
'Claude Code 的三个模型特点如下:\n- Sonnet:主力工程师,日常编码首选\n- Haiku:响应极快、成本低,适合简单任务\n- Opus:处理超级复杂的难题,智商最高\n\n小陈需要修复一个非常复杂的系统架构 Bug。\n\n问:他应该选择哪个模型?',
'SHORT_ANSWER', null, 'Opus',
['Opus模型适用场景', '复杂难题'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 模型选择'
);
addItem(
'Claude Code 的三个模型特点如下:\n- Sonnet:主力工程师,日常编码首选\n- Haiku:响应极快、成本低,适合简单任务\n- Opus:处理超级复杂的难题,智商最高\n\n小陈在做日常的 CRUD 接口开发。\n\n问:他应该选择哪个模型?',
'SHORT_ANSWER', null, 'Sonnet',
['Sonnet模型适用场景', '日常编码'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 模型选择'
);
addItem(
'Claude Code 的三个模型特点如下:\n- Sonnet:主力工程师,日常编码首选\n- Haiku:响应极快、成本低,适合简单任务\n- Opus:处理超级复杂的难题,智商最高\n\n小陈想快速查一下某个 JavaScript 数组方法的语法。\n\n问:他应该选择哪个模型?',
'SHORT_ANSWER', null, 'Haiku',
['Haiku模型适用场景', '快速简单任务'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 模型选择'
);
// ================================================
// 六、Claude Code — CLI 命令(3题)
// ================================================
addItem(
'小赵的 Claude Code 会话因为终端意外关闭了,想接着刚才的对话继续。\n\nClaude CLI 有以下命令:claude / claude --continue / claude --resume\n\n问:小赵应该用哪个命令?',
'SHORT_ANSWER', null, 'claude --continue(或 claude -c)。该命令用于恢复上次意外关闭的会话。',
['恢复会话', '--continue参数'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 CLI命令'
);
addItem(
'小钱想用 Claude Code 快速解释一下 git diff 的结果,不想进入交互式对话。\n\nClaude CLI 有以下命令:claude / claude -p "指令" / claude --resume\n\n问:小钱应该用哪个命令?',
'SHORT_ANSWER', null, 'claude -p "指令"。-p 参数用于一次性任务,适合快速执行。',
['一次性任务', '-p参数'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 CLI命令'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\nclaude --resume 可以从历史会话列表中选择恢复。',
'TRUE_FALSE', null, '✓',
['恢复历史会话', '--resume参数'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 CLI命令'
);
// ================================================
// 七、OpenCode — 整体认知(3题)
// ================================================
addItem(
'以下说法是否正确?(✓ / ✗)\n\nOpenCode 像一位身边的搭档,可以直接读取项目文件、修改代码、执行命令。',
'TRUE_FALSE', null, '✓',
['OpenCode核心定位', 'AI编码代理'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第1章 核心定位'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n传统 AI 像远程顾问,给你建议但需要你自己动手;OpenCode 可以直接帮你操作。',
'TRUE_FALSE', null, '✓',
['OpenCode与传统AI区别', '直接操作能力'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第1章'
);
addItem(
'小周想用 OpenCode 读取包含客户个人信息的代码文件,让 AI 帮忙优化。\n\n问:这种做法是否合适?为什么?',
'SHORT_ANSWER', null, '不合适。客户个人信息属于敏感数据,严禁输入任何公共 AI 工具。应该先对数据进行脱敏处理,用虚构数据或占位符替代后再使用。',
['安全合规', '敏感数据保护', '数据脱敏'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 红线警告'
);
// ================================================
// 八、OpenCode — 安装与使用方式(4题)
// ================================================
addItem(
'OpenCode 有以下四种使用方式:\n- 终端版 — 轻量启动快,适合有基础的用户\n- 桌面应用 — 界面直观,适合新手\n- IDE 扩展 — 深度绑定编辑器\n- Web 版 — 浏览器访问,可远程部署\n\n小周是新手,不喜欢操作命令行,想找一个界面直观的方式。\n\n问:他应该选择哪种方式?',
'SHORT_ANSWER', null, '桌面应用',
['OpenCode使用方式选择', '桌面应用适合新手'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第2章 使用方式'
);
addItem(
'OpenCode 有以下四种使用方式:终端版 / 桌面应用 / IDE 扩展 / Web 版\n\n小刘平时用 VS Code 写代码,希望不离开编辑器就能用 OpenCode。\n\n问:他应该选择哪种方式?',
'SHORT_ANSWER', null, 'IDE 扩展',
['OpenCode IDE扩展', '编辑器集成'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第2章 使用方式'
);
addItem(
'OpenCode 有以下四种使用方式:终端版 / 桌面应用 / IDE 扩展 / Web 版\n\n小马需要在远程服务器上开发,只能通过命令行操作。\n\n问:他应该选择哪种方式?',
'SHORT_ANSWER', null, '终端版',
['OpenCode终端版', '远程服务器开发'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第2章 使用方式'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n在终端中输入 opencode 可以启动 OpenCode。',
'TRUE_FALSE', null, '✓',
['OpenCode启动命令'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第2章 安装'
);
// ================================================
// 九、OpenCode — Plan / Build 工作模式(5题)
// ================================================
addItem(
'小周接手了一个新项目,想先让 OpenCode 分析项目结构,还不想修改任何文件。\n\nOpenCode 有以下两种工作模式:Plan / Build\n\n问:小周应该选择哪种模式?',
'SHORT_ANSWER', null, 'Plan(计划模式)。Plan 模式下 AI 只能读取文件,不会修改代码。',
['Plan模式', '只读分析'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章 Plan模式'
);
addItem(
'小周已经确认了修改方案,想让 OpenCode 开始实际修改代码。\n\nOpenCode 有以下两种工作模式:Plan / Build\n\n问:小周应该选择哪种模式?',
'SHORT_ANSWER', null, 'Build(构建模式)。Build 模式下 AI 可以编辑文件和执行命令。',
['Build模式', '实际代码修改'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章 Build模式'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\nPlan 模式下 AI 只能读取文件,不会修改任何代码。',
'TRUE_FALSE', null, '✓',
['Plan模式只读特性'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\nBuild 模式下 AI 可以编辑文件和执行命令。',
'TRUE_FALSE', null, '✓',
['Build模式可写特性'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章'
);
addItem(
'小周让 OpenCode 在 Build 模式下修改了多个文件。\n\n问:修改完成后,他应该先做什么?',
'SHORT_ANSWER', null, '审查 AI 修改的代码,确认逻辑正确后再使用。AI 生成的代码不能直接部署到生产环境。',
['代码审查', 'AI生成代码需人工审核'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 安全原则'
);
// ================================================
// 十、OpenCode — 常用命令(5题)
// ================================================
addItem(
'小周用 OpenCode 修改了代码,但发现改错了,想撤销刚才的修改。\n\nOpenCode 中有以下命令:/undo / /redo / /clear / /init\n\n问:他应该使用哪个命令?',
'SHORT_ANSWER', null, '/undo',
['OpenCode撤销命令', '/undo'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章 撤销更改'
);
addItem(
'小周撤销了修改后又觉得还是刚才改得好,想恢复回来。\n\nOpenCode 中有以下命令:/undo / /redo / /clear / /init\n\n问:他应该使用哪个命令?',
'SHORT_ANSWER', null, '/redo',
['OpenCode重做命令', '/redo'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章'
);
addItem(
'小周想在新项目目录中创建 AGENTS.md 文件,让 OpenCode 了解项目结构。\n\nOpenCode 中有以下命令:/undo / /redo / /clear / /init\n\n问:他应该使用哪个命令?',
'SHORT_ANSWER', null, '/init',
['OpenCode初始化', '/init命令'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第4章 项目初始化'
);
addItem(
'小周想看看 OpenCode 当前有哪些可用的斜杠命令和快捷键。\n\nOpenCode 中有以下命令:/help / /models / /connect / /exit\n\n问:他应该使用哪个命令?',
'SHORT_ANSWER', null, '/help',
['OpenCode帮助', '/help命令'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章 斜杠命令'
);
addItem(
'小周想切换 OpenCode 正在使用的 AI 模型。\n\nOpenCode 中有以下命令:/help / /models / /connect / /exit\n\n问:他应该使用哪个命令?',
'SHORT_ANSWER', null, '/models',
['OpenCode模型切换', '/models命令'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章 模型选择'
);
// ================================================
// 十一、OpenCode — 模型选择(2题)
// ================================================
addItem(
'以下说法是否正确?(✓ / ✗)\n\nOpenCode 内置多款免费模型,启动后可以直接选择使用,无需配置 API 密钥。',
'TRUE_FALSE', null, '✓',
['OpenCode免费模型', '无需API密钥'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章 模型'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n使用第三方 LLM 提供商(如 OpenAI、Anthropic)需要自行承担 API 费用。',
'TRUE_FALSE', null, '✓',
['第三方LLM费用', 'API成本'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章 模型'
);
// ================================================
// 十二、Debug — 调试助手(7题)
// ================================================
addItem(
'小吴的代码运行时报错了,不知道问题出在哪,想让 Copilot Chat 帮他定位和解决 Bug。\n\nCopilot Chat 中有以下命令:/fix / /tests / /explain / /debug\n\n问:他应该使用哪个命令?',
'SHORT_ANSWER', null, '/debug。该命令专用于帮助定位和解决 Bug。',
['/debug命令', '定位解决Bug'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章 内置命令'
);
addItem(
'小吴已经知道问题在哪了,想让 Copilot 直接修复选中的代码。\n\nCopilot Chat 中有以下命令:/fix / /tests / /explain / /debug\n\n问:他应该使用哪个命令?',
'SHORT_ANSWER', null, '/fix。该命令用于自动修复代码问题。',
['/fix命令', '自动修复代码'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章 内置命令'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n在 Copilot Chat 中输入 /tests 可以生成选中代码的单元测试。',
'TRUE_FALSE', null, '✓',
['/tests命令', '生成单元测试'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章 内置命令'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n在 Copilot Chat 中输入 /explain 可以让 AI 解释选中代码的逻辑。',
'TRUE_FALSE', null, '✓',
['/explain命令', '解释代码逻辑'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章 内置命令'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n/debug 命令可以帮助定位和解决 Bug。',
'TRUE_FALSE', null, '✓',
['/debug命令功能'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章 内置命令'
);
addItem(
'小吴用 /fix 命令让 Copilot 自动修复了代码。\n\n问:修复完成后,他应该先做什么?',
'SHORT_ANSWER', null, '审查修复后的代码,确认修改正确、逻辑无误后再使用。',
['代码审查', '修复后验证'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 安全原则'
);
addItem(
'小吴遇到一个 Bug,想把包含数据库连接串的配置文件贴到 Copilot Chat 中用 /debug 分析。\n\n问:这种做法是否合适?为什么?',
'SHORT_ANSWER', null, '不合适。数据库连接串属于敏感信息,严禁输入公共 AI 工具。应该用脱敏数据或占位符替代后再进行分析。',
['安全合规', '敏感数据保护', '脱敏处理'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 红线警告'
);
// ===== 批量插入题目 =====
console.log(`Inserting ${items.length} questions...`);
const insertItem = db.prepare(`
INSERT INTO question_bank_items (id, bank_id, question_text, questionType, options, correctAnswer, key_points, difficulty, dimension, basis, judgment, status, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PUBLISHED', ?, ?, ?)
`);
for (const item of items) {
insertItem.run(
uuid(),
BANK_ID,
item.questionText,
item.questionType,
item.options,
item.correctAnswer,
item.keyPoints,
item.difficulty,
item.dimension,
item.basis,
item.judgment,
ADMIN_USER_ID,
ts,
ts
);
}
console.log(`Successfully inserted ${items.length} IDE questions into bank ${BANK_ID}`);
db.close();
@@ -231,7 +231,7 @@ export class AssessmentController {
@ApiOperation({ summary: 'Batch delete assessment sessions (admin only)' })
async batchDelete(@Request() req: any, @Body() body: { ids: string[] }) {
const user = req.user;
const isAdmin = user.role === 'super_admin' || user.role === 'admin';
const isAdmin = user.role?.toLowerCase() === 'super_admin' || user.role?.toLowerCase() === 'admin';
if (!isAdmin) {
throw new ForbiddenException('Only admin can batch delete');
}
@@ -286,7 +286,7 @@ export class AssessmentController {
@Request() req: any,
) {
const { id: userId, tenantId, role } = req.user;
const isAdmin = role === 'super_admin' || role === 'admin';
const isAdmin = role?.toLowerCase() === 'super_admin' || role?.toLowerCase() === 'admin';
if (!isAdmin) {
throw new ForbiddenException('Only admin can force end assessment');
}
+77 -43
View File
@@ -189,15 +189,16 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
this.logger.debug('[calculateScores] Scoring debug:', { promptAvg, otherDimsWithScores, otherAvg, workCapability: dimensionAverages.workCapability });
const allScores: number[] = [];
questions.forEach((q: any) => {
const score = scores[q.id || questions.indexOf(q).toString()] || 0;
allScores.push(score);
});
const finalScore = allScores.length > 0
? allScores.reduce((a, b) => a + b, 0) / allScores.length
: 0;
// Weighted final score using weightConfig
let finalScore: number;
if (promptAvg > 0 && otherAvg > 0) {
const totalWeight = (weightConfig?.prompt ?? 50) + (weightConfig?.other ?? 50);
finalScore = totalWeight > 0
? (promptAvg * (weightConfig?.prompt ?? 50) + otherAvg * (weightConfig?.other ?? 50)) / totalWeight
: (promptAvg + otherAvg) / 2;
} else {
finalScore = promptAvg || otherAvg || 0;
}
const radarData: Record<string, number> = {};
Object.keys(dimensionAverages).forEach(dim => {
@@ -430,33 +431,54 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
// Use kbId if provided, otherwise fall back to template's group ID
const activeKbId = kbId || template?.knowledgeGroupId;
this.logger.log(`[startSession] activeKbId resolved to: ${activeKbId}`);
if (!activeKbId) {
// If no knowledge source, check if template has a question bank first
let hasBankQuestions = false;
if (!activeKbId && templateId && template) {
try {
const targetCount = template.questionCount || 5;
const linkedBanks = await this.questionBankRepository.find({
where: { templateId },
});
if (linkedBanks.length > 0) {
const bankIds = linkedBanks.map(b => b.id);
const count = await this.questionBankItemRepository.count({
where: { bankId: In(bankIds), status: QuestionBankItemStatus.PUBLISHED },
});
if (count >= targetCount) {
hasBankQuestions = true;
this.logger.log(`[startSession] Template has ${count} published questions, skipping KB check`);
}
}
} catch (e) {
this.logger.warn(`[startSession] Bank pre-check failed: ${e.message}`);
}
}
if (!activeKbId && !hasBankQuestions) {
this.logger.error(`[startSession] No knowledge source resolved`);
throw new BadRequestException('Knowledge source (ID or Template) must be provided.');
}
// Try to determine if it's a KB or Group and check permissions
// Determine if it's a KB or Group (only when activeKbId exists)
let isKb = false;
try {
await this.kbService.findOne(activeKbId, userId, tenantId);
isKb = true;
} catch (kbError) {
if (kbError instanceof NotFoundException) {
// Try finding it as a Group
try {
await this.groupService.findOne(activeKbId, userId, tenantId);
} catch (groupError) {
this.logger.error(
`[startSession] Knowledge source ${activeKbId} not found as KB or Group`,
);
throw new NotFoundException(
this.i18nService.getMessage('knowledgeSourceNotFound') ||
'Knowledge source not found',
);
if (activeKbId) {
try {
await this.kbService.findOne(activeKbId, userId, tenantId);
isKb = true;
} catch (kbError) {
if (kbError instanceof NotFoundException) {
try {
await this.groupService.findOne(activeKbId, userId, tenantId);
} catch (groupError) {
this.logger.error(`[startSession] Knowledge source ${activeKbId} not found`);
throw new NotFoundException(
this.i18nService.getMessage('knowledgeSourceNotFound') || 'Knowledge source not found',
);
}
} else {
throw kbError;
}
} else {
throw kbError; // e.g. ForbiddenException
}
}
this.logger.debug(`[startSession] isKb: ${isKb}`);
@@ -503,6 +525,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
const selectedItems = await this.questionBankService.selectQuestions(
bankId,
targetCount,
template?.dimensions,
);
questionsFromBank = selectedItems.map(item => {
@@ -586,9 +609,9 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
};
// Skip content check if questions are loaded from the question bank
const hasBankQuestions = questionsFromBank.length > 0;
const hasBankContent = questionsFromBank.length > 0;
if (!hasBankQuestions) {
if (!hasBankContent) {
const content = await this.getSessionContent(sessionData);
if (!content || content.trim().length < 10) {
@@ -787,7 +810,7 @@ const initialState: Partial<EvaluationState> = {
const scores = finalData.scores;
const questions = finalData.questions || [];
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
const passingScore = (session.templateJson?.passingScore ?? 90) / 10;
const passingScore = (session.templateJson?.passingScore ?? 60) / 10;
if (questions.length > 0 && Object.keys(scores).length > 0) {
const { finalScore, dimensionScores, radarData } = this.calculateScores(
@@ -882,7 +905,7 @@ const initialState: Partial<EvaluationState> = {
let finalResult: any = null;
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
const passingScore = (session.templateJson?.passingScore ?? 90) / 10;
const passingScore = (session.templateJson?.passingScore ?? 60) / 10;
// Resume from the last interrupt (typically after interviewer)
const stream = await this.graph.stream(null, {
@@ -935,7 +958,7 @@ const initialState: Partial<EvaluationState> = {
const scores = finalResult.scores as Record<string, number>;
const questions = finalResult.questions || [];
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
const passingScore = (session.templateJson?.passingScore ?? 90) / 10;
const passingScore = (session.templateJson?.passingScore ?? 60) / 10;
if (questions.length > 0 && Object.keys(scores).length > 0) {
const { finalScore, dimensionScores, radarData } = this.calculateScores(
@@ -1158,7 +1181,7 @@ const initialState: Partial<EvaluationState> = {
const scores = finalData.scores;
const questions = finalData.questions || [];
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
const passingScore = (session.templateJson?.passingScore ?? 90) / 10;
const passingScore = (session.templateJson?.passingScore ?? 60) / 10;
if (questions.length > 0 && Object.keys(scores).length > 0) {
const { finalScore, dimensionScores, radarData } = this.calculateScores(
@@ -1364,17 +1387,27 @@ const initialState: Partial<EvaluationState> = {
const content = await this.getSessionContent(session);
const model = await this.getModel(session.tenantId);
const existingQuestions = session.questions_json || [];
const hasQuestionsFromBank = existingQuestions.length > 0;
const isZh = (session.language || 'en') === 'zh';
const isJa = session.language === 'ja';
const initialState: Partial<EvaluationState> = {
assessmentSessionId: sessionId,
knowledgeBaseId:
session.knowledgeBaseId || session.knowledgeGroupId || '',
messages: [],
messages: hasQuestionsFromBank
? [new HumanMessage(
isZh ? '我已准备好回答问题。' : isJa ? '質問への回答準備ができています。' : 'I am ready to answer the questions.',
)]
: [],
questionCount: session.templateJson?.questionCount,
difficultyDistribution: session.templateJson?.difficultyDistribution,
style: session.templateJson?.style,
keywords: session.templateJson?.keywords,
questionAnswerKey: session.templateJson?.questionAnswerKey,
language: session.language || 'en',
questions: hasQuestionsFromBank ? existingQuestions : undefined,
};
this.logger.log(
@@ -1504,7 +1537,8 @@ const initialState: Partial<EvaluationState> = {
return existing;
}
const level = this.determineLevel(session.finalScore || 0);
const passingThreshold = (session.templateJson?.passingScore ?? 60) / 10;
const level = this.determineLevel(session.finalScore || 0, !!(session as any).passed, passingThreshold);
const qrCode = `cert://${sessionId}-${Date.now()}`;
const questionDetails = (session.questions_json || []).map((q: any, i: number) => ({
@@ -1535,11 +1569,11 @@ const initialState: Partial<EvaluationState> = {
} as any;
}
private determineLevel(score: number): string {
private determineLevel(score: number, passed: boolean, passingThreshold: number): string {
if (!passed) return 'Novice';
if (score >= 9) return 'Expert';
if (score >= 7.5) return 'Advanced';
if (score >= 6) return 'Proficient';
return 'Novice';
if (score >= 7) return 'Advanced';
return 'Proficient';
}
async getStats(
@@ -1723,7 +1757,7 @@ const initialState: Partial<EvaluationState> = {
}
session.finalScore = newScore;
const passingScore = (session.templateJson?.passingScore ?? 90) / 10;
const passingScore = (session.templateJson?.passingScore ?? 60) / 10;
(session as any).passed = newScore >= passingScore;
session.reviewedBy = reviewerId;
session.reviewedAt = new Date();
@@ -100,13 +100,11 @@ export class CreateTemplateDto {
@IsInt()
@Min(60)
@Max(7200)
@IsOptional()
@Max(86400)
totalTimeLimit?: number;
@IsInt()
@Min(30)
@Max(1800)
@IsOptional()
@Max(3600)
perQuestionTimeLimit?: number;
}
@@ -97,7 +97,7 @@ export class AssessmentTemplate {
@Column({ type: 'int', name: 'question_count_max', default: 10 })
questionCountMax: number;
@Column({ type: 'int', name: 'passing_score', default: 90 })
@Column({ type: 'int', name: 'passing_score', default: 60 })
passingScore: number;
@Column({ type: 'int', name: 'total_time_limit', default: 1800 })
@@ -56,7 +56,12 @@ const scoreSummary = Object.entries(scores)
1. **你必须使用以下语言生成报告:中文 (Simplified Chinese)**。
2. **严禁夹杂日文**。即使对话记录中包含日文,报告内容也必须全中文。
3. 报告的第一行必须严格遵守此格式:"LEVEL: [Novice/Proficient/Advanced/Expert]"。
4. 必须保持客观。如果用户没有提供有效的回答或得分为 0,你必须将其识别为 'Novice',并明确指出他们尚未证明其掌握程度。
4. **等级判定必须遵循以下分数阈值**
- 总体平均分 >= 9 → Expert(专家)
- 总体平均分 >= 7 → Advanced(高级)
- 已通过(有有效回答且得分 > 0)→ Proficient(熟练)
- 未通过(无有效回答或得分为 0)→ Novice(新手)
即使得分很高,也要确保等级与上述阈值匹配。不要随意提高或降低等级。
5. 不要虚构或幻想优点(如"潜力"或"好奇心"),如果用户明确表示"不知道"或未提供实质内容。
6. 专注于对话记录中已证明的事实。
@@ -87,8 +92,13 @@ ${messages
2. **中国語を混ぜないでください**。会話ログに中国語が含まれていても、レポートの内容はすべて日本語で記述してください。
3. レポートの最初の行は, 必ず次の形式に従ってください:"LEVEL: [Novice/Proficient/Advanced/Expert]"。
4. 客観的であること。ユーザーが有効な回答を提供しなかった場合、またはスコアが 0 の場合、'Novice' と判定し、習熟度が証明されていないことを明示してください。
5. ユーザーが「わからない」と言ったり、内容を提供しなかった場合に、長所(「ポテンシャル」や「好奇心」など)を捏造しないでください。
6. 会話ログで証明された事実に集中してください。
5. **レベル判定は以下のスコアしきい値に従うこと**:
- 平均スコア >= 9 → Expert
- 平均スコア >= 7 → Advanced
- 合格(有効な回答がありスコア > 0)→ Proficient
- 不合格(有効な回答なし、またはスコア 0)→ Novice
6. ユーザーが「わからない」と言ったり、内容を提供しなかった場合に、長所(「ポテンシャル」や「好奇心」など)を捏造しないでください。
7. 会話ログで証明された事実に集中してください。
各ディメンションスコア:
${dimensionAvg}
@@ -115,8 +125,13 @@ IMPORTANT:
1. **You MUST generate the report strictly in English.**
2. START the report with exactly this format: "LEVEL: [Novice/Proficient/Advanced/Expert]" on the first line.
3. Be OBJECTIVE. If the user provided no valid answers or scores are 0, you MUST identify them as 'Novice' and explicitly state they have NOT demonstrated mastery.
4. DO NOT invent or hallucinate strengths (like 'potential' or 'curiosity') if the user explicitly said "I don't know" or provided no content.
5. Focus on what was PROVEN in the conversation logs.
4. **Level assignment MUST follow these score thresholds**:
- Average score >= 9 → Expert
- Average score >= 7 → Advanced
- Passed (has valid answers with score > 0) → Proficient
- Not passed (no valid answers or score is 0) → Novice
5. DO NOT invent or hallucinate strengths (like 'potential' or 'curiosity') if the user explicitly said "I don't know" or provided no content.
6. Focus on what was PROVEN in the conversation logs.
DIMENSION SCORES:
${dimensionAvg}
@@ -90,34 +90,83 @@ export const questionGeneratorNode = async (
.map((q, i) => `Q${i + 1}: ${q.questionText}`)
.join('\n');
const systemPromptZh = `你是一个信息提取工具。严格按以下步骤操作
const systemPromptZh = `你是一个出题工具。严格按以下规则生成题目
### 第一步:提取知识点
阅读下方 Human 消息中的【知识库内容】,逐条列出其中包含的所有可考核知识点。
每条以"知识点N:"开头,引用原文语句。如果不足,诚实报告。
每条以"知识点N:"开头,引用原文语句。
### 第二步:知识点生成考
仅用第一步提取的知识点生成 1 道题。必须引用知识点编号。
### 第二步:基于知识点
仅用第一步提取的知识点生成题。必须引用知识点编号。
如果知识点数量不足(少于3个),输出空数组 [] 并停止。
### 题型分配规则
每生成 3 道题:
- 第1、4、7...道:选择题(MULTIPLE_CHOICE),占 1/3
- 第2、3、5、6...道:对话简答题(SHORT_ANSWER),占 2/3
严格按照这个顺序循环,不要自行调整比例。
### 出题范围限制
出题内容必须严格限制在知识库范围内。每道题必须有知识点编号引用。
以下情况绝对禁止:
- 使用 LLM 自身知识编题
- 引用知识库中不存在的概念
- 题目内容超出知识库覆盖的主题
### 选择题出题标准
- 必须是场景驱动:描述一个真实工作场景,让用户判断最佳做法
- 四个选项(A/B/C/D),只有一个正确,另外三个要有迷惑性
- 难度:不是考概念背诵,是考实际应用判断
- 正确答案必须附带解析,说明为什么对、错在哪
- 出题依据必须引用第一步提取的知识点编号
### 对话简答题出题标准
- 开放式场景问题,不预设标准答案
- 考察用户的理解深度和表达能力
- 适合多轮追问展开讨论
- 出题依据必须引用第一步提取的知识点编号
### 绝对禁止:
- 禁止使用知识库内容中不存在的任何概念、术语、数据
- 禁止使用你自己的知识
${existingQuestionsText ? `- 禁止与已出题目重复:${existingQuestionsText}` : ''}
- 禁止出纯概念题(如"提示词六要素是什么")
- 禁止出需要记忆具体数据的题
- 禁止使用知识库之外的知识
- 禁止生成与知识库主题无关的题目
${existingQuestionsText ? `- 禁止与已出题目概念重复:${existingQuestionsText}` : ''}
### 输出(纯 JSON 数组):
[
{
"knowledge_points": ["知识点引用"],
"question_text": "基于知识点的题目",
"key_points": ["评分要点"],
"difficulty": "STANDARD|ADVANCED|SPECIALIST",
"dimension": "prompt|llm|ide|devPattern|workCapability",
"basis": "知识库原文"
}
]`;
// dimension取值:prompt=提示词, llm=LLM原理, ide=IDE协作, devPattern=开发范式, workCapability=工作能力
### 输出格式(严格遵循)
选择题完整格式:
{
"question_type": "MULTIPLE_CHOICE",
"question_text": "场景描述+问题,不超过120字",
"options": ["A) 选项1", "B) 选项2", "C) 选项3", "D) 选项4"],
"correct_answer": "A",
"judgment": "解析:为什么对、为什么错,不超过200字",
"key_points": ["考核要点", "2-3个"],
"difficulty": "STANDARD",
"dimension": "prompt",
"basis": "知识点N:参考来源"
}
const systemPromptJa = `あなたは情報抽出ツールです。以下の手順に厳密に従ってください。
对话简答题完整格式:
{
"question_type": "SHORT_ANSWER",
"question_text": "开放式场景问题,不超过120字",
"key_points": ["期望的回答方向", "2-3个"],
"difficulty": "STANDARD",
"dimension": "prompt",
"basis": "知识点N:参考来源"
}
### 输出要求
- 只输出 JSON 数组,不要其他文字
- question_type 必须为 MULTIPLE_CHOICE 或 SHORT_ANSWER
- dimension 只能取以下值之一:prompt、llm、ide、devPattern、workCapability
- 每次生成 1 道题,以 JSON 数组格式输出
- 选择题必须包含全部8个字段:question_text、options、correct_answer、judgment、key_points、difficulty、dimension、basis
- 对话简答题必须包含全部6个字段:question_text、key_points、difficulty、dimension、basis
- 每个字段的值不能为空`;
const systemPromptJa = `あなたは問題作成ツールです。以下の手順に厳密に従ってください。
### 第一歩:知識ポイントの抽出
Human メッセージ内の【ナレッジベース内容】を読み、含まれるすべての評価可能な知識ポイントを箇条書きで抽出。
@@ -126,48 +175,76 @@ Human メッセージ内の【ナレッジベース内容】を読み、含ま
### 第二歩:知識ポイントから問題を作成
第一歩で抽出した知識ポイントのみを使用して 1 問作成。知識ポイント番号を引用すること。
### 問題タイプの割合
3問中、約1問を選択問題、2問を対話式記述問題にしてください。全体で約30%/70%の割合。
### 出題方向
「AI協作スキル」に関する問題:
- プロンプトの書き方(役割、タスク、背景、制約)
- 複数ラウンドの対話テクニック
- AIに先に質問させる方法
- セッション管理(いつ継続、いつ新規)
- よくある間違いと自己チェック
- セキュリティ意識(機密データの取扱い)
### 選択問題の基準
- シナリオ駆動:実務シーンを想定
- 4択(A/B/C/D)、正解は1つ
- 正解には必ず解説を含める
### 対話式記述問題の基準
- オープンクエスチョン、正解なし
- 理解の深さと表現力を評価
### 絶対禁止:
- ナレッジベースに存在しない概念、用語、データの使用
- 自身の知識の使用
${existingQuestionsText ? `- 作成済み問題との重複禁止:${existingQuestionsText}` : ''}
- 暗記問題の禁止
- 知識ベースにない概念の使用禁止
${existingQuestionsText ? `- 既出問題との重複禁止:${existingQuestionsText}` : ''}
### 出力(純粋な JSON 配列):
[
{
"knowledge_points": ["知識ポイント参照"],
"question_text": "知識ポイントに基づく問題",
"key_points": ["採点ポイント"],
"difficulty": "STANDARD|ADVANCED|SPECIALIST",
"dimension": "prompt|llm|ide|devPattern|workCapability",
"basis": "ナレッジベースの原文"
}
]`;
### 出力
JSON 配列のみ出力:
選択問題:{"question_type":"MULTIPLE_CHOICE","question_text":"...","options":["A)...","B)...","C)...","D)..."],"correct_answer":"A","judgment":"...","key_points":["..."],"difficulty":"STANDARD","dimension":"prompt|llm|ide|devPattern|workCapability","basis":"..."}
記述問題:{"question_type":"SHORT_ANSWER","question_text":"...","key_points":["..."],"difficulty":"STANDARD","dimension":"prompt|llm|ide|devPattern|workCapability","basis":"..."}`;
const systemPromptEn = `You are an information extraction tool. Follow these steps exactly.
const systemPromptEn = `You are a question generation tool. Follow these steps exactly.
### Step 1: Extract Knowledge Points
Read the knowledge base content in the Human message. List ALL assessable knowledge points found.
Read the knowledge base content in the Human message. List ALL assessable knowledge points.
Each point must start with "KP N:" and quote the source text. If insufficient, honestly report.
### Step 2: Generate Question from Points
Use ONLY the knowledge points from Step 1 to generate 1 question. Must reference KP numbers.
### Absolutely Forbidden:
- Using any concept, term, or data NOT present in the knowledge base content
- Using your own knowledge
${existingQuestionsText ? `- Repeating previous questions: ${existingQuestionsText}` : ''}
### Type Mix
Out of every 3 questions, approximately 1 should be MULTIPLE_CHOICE and 2 should be SHORT_ANSWER (dialogue-style). Roughly 30%/70% split.
### Output (pure JSON array only):
[
{
"knowledge_points": ["KP reference"],
"question_text": "Question based on the knowledge points",
"key_points": ["scoring points"],
"difficulty": "STANDARD|ADVANCED|SPECIALIST",
"dimension": "prompt|llm|ide|devPattern|workCapability",
"basis": "Source text from knowledge base"
}
]`;
### Topics
AI collaboration skills:
- Writing good prompts (role, task, context, constraints)
- Multi-turn iteration techniques
- Letting AI ask clarifying questions first
- Session management (continue vs new window)
- Common mistakes and self-review
- Security awareness (handling sensitive data)
### MC Standards
- Scenario-driven: describe a real work scenario
- 4 options (A/B/C/D), one correct
- Must include judgment explaining why correct/incorrect
### SA Standards
- Open-ended, no predefined answer
- Tests understanding depth and expression
### Forbidden:
- Pure concept recall questions
- Questions requiring memorization of specific data
${existingQuestionsText ? `- Repeating previous question concepts: ${existingQuestionsText}` : ''}
### Output
JSON array only. One question at a time.
MC: {"question_type":"MULTIPLE_CHOICE","question_text":"...","options":["A)...","B)...","C)...","D)..."],"correct_answer":"A","judgment":"...","key_points":["..."],"difficulty":"STANDARD","dimension":"prompt|llm|ide|devPattern|workCapability","basis":"..."}
SA: {"question_type":"SHORT_ANSWER","question_text":"...","key_points":["..."],"difficulty":"STANDARD","dimension":"prompt|llm|ide|devPattern|workCapability","basis":"..."}`;
// dimension values: prompt=prompt engineering, llm=LLM principles, ide=IDE collaboration, devPattern=development paradigm, workCapability=work capability
@@ -201,6 +278,42 @@ ${existingQuestionsText ? `- Repeating previous questions: ${existingQuestionsTe
newQuestions = [newQuestions];
}
// === 代码级校验:确保 LLM 输出符合规范 ===
const VALID_DIMENSIONS = ['prompt', 'llm', 'ide', 'devPattern', 'workCapability'];
const VALID_TYPES = ['MULTIPLE_CHOICE', 'SHORT_ANSWER'];
const validatedQuestions = newQuestions.filter((q: any) => {
const qType = q.question_type;
const dim = q.dimension?.toString().toLowerCase().trim();
const errors: string[] = [];
if (!VALID_TYPES.includes(qType)) errors.push(`invalid question_type: ${qType}`);
if (!dim || !VALID_DIMENSIONS.includes(dim)) errors.push(`invalid dimension: ${q.dimension}`);
if (!q.question_text || q.question_text.length < 5) errors.push('question_text missing or too short');
if (qType === 'MULTIPLE_CHOICE') {
if (!Array.isArray(q.options) || q.options.length < 2) errors.push('options missing or insufficient');
if (!q.correct_answer) errors.push('correct_answer missing');
if (!q.judgment) errors.push('judgment missing');
} else if (qType === 'SHORT_ANSWER') {
if (!Array.isArray(q.key_points) || q.key_points.length === 0) errors.push('key_points missing');
}
if (errors.length > 0) {
console.warn('[GeneratorNode] Validation failed for question:', errors.join('; '));
return false;
}
return true;
});
if (validatedQuestions.length === 0) {
console.warn('[GeneratorNode] All generated questions failed validation, using existing questions only');
return { questions: existingQuestions };
}
// 只取验证通过的题目
newQuestions = validatedQuestions;
const dimensionMap: Record<string, string> = {
// 中文
'技术能力-提示词': 'prompt',
@@ -228,15 +341,27 @@ ${existingQuestionsText ? `- Repeating previous questions: ${existingQuestionsTe
inferredDimension = dimensionMap[dimValue] || 'workCapability';
console.log('[GeneratorNode] Dimension mapping:', { original: q.dimension, mapped: inferredDimension });
}
return {
const qType = q.question_type === 'MULTIPLE_CHOICE' ? 'MULTIPLE_CHOICE' : 'SHORT_ANSWER';
const base = {
id: (existingQuestions.length + 1).toString(),
questionText: q.question_text,
questionType: 'SHORT_ANSWER',
keyPoints: q.key_points,
difficulty: q.difficulty,
basis: q.basis,
questionType: qType,
keyPoints: q.key_points || [],
difficulty: q.difficulty || 'STANDARD',
basis: q.basis || '',
dimension: inferredDimension,
};
if (qType === 'MULTIPLE_CHOICE') {
return {
...base,
options: q.options || [],
correctAnswer: q.correct_answer || '',
judgment: q.judgment || '',
};
}
return base;
});
const questionsToGenerate = Math.max(1, limitCount - existingQuestions.length);
@@ -91,6 +91,72 @@ export const graderNode = async (
};
}
// ── Rule-based grading: use structured followupMapping if available ──
if (currentQuestion.followupHints) {
let mapping: any = null;
if (typeof currentQuestion.followupHints === 'string') {
try { mapping = JSON.parse(currentQuestion.followupHints); } catch {}
} else if (typeof currentQuestion.followupHints === 'object') {
mapping = currentQuestion.followupHints;
}
if (mapping && Array.isArray(mapping.branches)) {
const userAnswerText = typeof lastUserMessage.content === 'string'
? lastUserMessage.content : JSON.stringify(lastUserMessage.content);
// Score based on keyword coverage
let bestScore = mapping.defaultScore ?? 5;
let matchedFollowup = mapping.defaultFollowup || '';
let matchedAll = true;
const maxFollowUps = mapping.maxFollowups ?? 2;
for (const branch of mapping.branches) {
const kws = branch.keywords || [];
const matchCount = kws.filter((kw: string) => userAnswerText.toLowerCase().includes(kw.toLowerCase())).length;
if (kws.length > 0 && matchCount >= kws.length * 0.5) {
const branchScore = branch.score ?? 7;
if (branchScore > bestScore) bestScore = branchScore;
if (branch.followup) matchedFollowup = branch.followup;
} else if (kws.length > 0 && matchCount === 0) {
matchedAll = false;
}
}
const completionThreshold = mapping.completionThreshold ?? 80;
const tooShort = userAnswerText.trim().length < 8;
const saysIDontKnow = userAnswerText.trim().length < 10 && (
userAnswerText.includes('不知道') || userAnswerText.includes("don't know") || userAnswerText.includes('わかりません')
);
let shouldFollowUp: boolean;
if (saysIDontKnow || tooShort) {
shouldFollowUp = false;
bestScore = Math.min(bestScore, 2);
} else if (bestScore >= completionThreshold / 10) {
shouldFollowUp = false;
} else if (currentFollowUpCount >= maxFollowUps) {
shouldFollowUp = false;
} else {
shouldFollowUp = true;
}
const feedbackMessage = new AIMessage(`Score: ${bestScore}/10\n\nFeedback: ${shouldFollowUp ? matchedFollowup : '回答已覆盖关键点。'}`);
const feedbackHistoryMessages = shouldFollowUp && matchedFollowup
? [feedbackMessage, new AIMessage(matchedFollowup)]
: [feedbackMessage];
console.log('[GraderNode] Rule grading:', { score: bestScore, shouldFollowUp, matchedAll, followup: matchedFollowup?.substring(0, 60) });
return {
feedbackHistory: feedbackHistoryMessages,
scores: { [currentQuestion.id || currentQuestionIndex.toString()]: bestScore },
shouldFollowUp,
followUpCount: shouldFollowUp ? currentFollowUpCount + 1 : 0,
currentQuestionIndex: shouldFollowUp ? currentQuestionIndex : currentQuestionIndex + 1,
} as any;
}
}
const systemPromptZh = `你是一位考官。请评分并给出反馈。
规则:
@@ -100,8 +166,10 @@ export const graderNode = async (
问题:${currentQuestion.questionText}
关键点:${currentQuestion.keyPoints.join(', ')}
评分标准:准确性、完整性、深度
部分正确也给分(5-7分),完全不沾边才0-2分
评分标准:不要求深度,不要求使用特定术语,只看用户是否理解了概念
用户理解核心概念就给分。即使没有使用关键点中的原词,只要意思到位就算覆盖
例如关键点是"上下文窗口有限",用户说"信息太多超过AI处理长度"也是覆盖。
评分原则:往宽了给分,不确定时就给高分。明显正确就给8-10分,部分正确5-7分,完全不沾边才0-2分。
返回JSON
- score: 0-10
@@ -456,10 +456,6 @@ export class QuestionBankService {
): 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 之间');
}
@@ -523,6 +519,7 @@ export class QuestionBankService {
async selectQuestions(
bankId: string,
count: number,
dimensionWeights?: Array<{ name: string; weight: number }>,
): Promise<QuestionBankItem[]> {
const bank = await this.findOne(bankId);
@@ -537,40 +534,51 @@ export class QuestionBankService {
const usedIds = new Set<string>();
const selected: QuestionBankItem[] = [];
const availableItems = [...allItems];
let 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 (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);
const pool = availableItems.filter(i => i.dimension === dimName && !usedIds.has(i.id));
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);
}
}
if (dimIdx >= DIMENSIONS.length * 3) {
break;
availableItems = availableItems.filter(i => !usedIds.has(i.id));
this.shuffleArray(availableItems);
while (selected.length < count && availableItems.length > 0) {
const item = availableItems.pop()!;
selected.push(item);
usedIds.add(item.id);
}
}
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)) {
} 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) {
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);
}
}
}
}
@@ -49,6 +49,7 @@ export class TemplateService {
const { ...data } = createDto;
const template = this.templateRepository.create({
...data,
isActive: data.isActive !== undefined ? data.isActive : true,
createdBy: userId,
tenantId,
});
@@ -76,7 +76,7 @@ export const AssessmentTemplateManager: React.FC = () => {
: (template.difficultyDistribution || ''),
style: template.style || 'Professional',
knowledgeGroupId: template.knowledgeGroupId || '',
passingScore: template.passingScore ? template.passingScore / 10 : 6,
passingScore: template.passingScore !== null && template.passingScore !== undefined ? template.passingScore / 10 : 6,
totalTimeLimit: template.totalTimeLimit ?? 1800,
perQuestionTimeLimit: template.perQuestionTimeLimit ?? 300,
});
@@ -436,7 +436,7 @@ export const AssessmentTemplateManager: React.FC = () => {
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
<Hash size={12} className="text-indigo-500" /> ()
</label>
<input type="number" min="60" max="7200" step="60"
<input type="number" min="60" max="86400" step="60"
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
value={formData.totalTimeLimit}
onChange={e => setFormData({ ...formData, totalTimeLimit: parseInt(e.target.value) || 1800 })}
@@ -446,7 +446,7 @@ export const AssessmentTemplateManager: React.FC = () => {
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
<Hash size={12} className="text-indigo-500" /> ()
</label>
<input type="number" min="30" max="1800" step="30"
<input type="number" min="30" max="3600" step="30"
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
value={formData.perQuestionTimeLimit}
onChange={e => setFormData({ ...formData, perQuestionTimeLimit: parseInt(e.target.value) || 300 })}
+6 -9
View File
@@ -296,6 +296,8 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
if (event.data.status === 'COMPLETED') {
setSession(prev => prev ? { ...prev, status: 'COMPLETED' } : null);
fetchHistory();
} else if (event.data.currentQuestionIndex !== undefined) {
assessmentService.nextQuestion(session.id).catch(() => {});
}
}
}
@@ -620,7 +622,6 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
{currentQuestion.options.map((opt: string, i: number) => {
const letter = optionLabels[i];
const isSelected = selectedChoice === letter;
const displayText = opt.slice(1);
return (
<button
key={letter}
@@ -634,10 +635,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
isTimedOut && "opacity-50 cursor-not-allowed"
)}
>
<span className="inline-flex items-center justify-center w-7 h-7 rounded-xl text-xs font-black mr-3 shrink-0 border-2 border-current">
{letter}
</span>
{displayText}
{opt}
</button>
);
})}
@@ -662,7 +660,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !isTimedOut) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !isTimedOut) {
e.preventDefault();
handleSubmitAnswer();
}
@@ -867,16 +865,15 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
{q.options?.map((opt: string, oi: number) => {
const letter = String.fromCharCode(65 + oi);
const isAnswer = letter === q.correctAnswer;
const displayText = opt.slice(1);
return (
<span key={oi} className={cn(
"px-3 py-1 rounded-lg font-medium",
isAnswer ? "bg-emerald-100 text-emerald-700 border border-emerald-200" : "bg-slate-50 text-slate-500"
)}>
{letter}. {displayText}
{opt}
</span>
);
})}
})}
</div>
)}
{q.judgment && (
+5
View File
@@ -147,6 +147,11 @@ export class AssessmentService {
return data;
}
async nextQuestion(sessionId: string): Promise<{ success: boolean }> {
const { data } = await apiClient.post<{ success: boolean }>(`/assessment/${sessionId}/next-question`, {});
return data;
}
async forceEnd(sessionId: string): Promise<AssessmentSession> {
const { data } = await apiClient.post<AssessmentSession>(`/assessment/${sessionId}/force-end`, {});
return data;