Compare commits
39 Commits
main
..
6d9acd7252
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d9acd7252 | |||
| a71bde3452 | |||
| 6e569ff478 | |||
| a83de861dd | |||
| 0b0da09d4b | |||
| d7cd5641d7 | |||
| c53f26a07e | |||
| b15e821252 | |||
| 990b8c7b83 | |||
| f8df92c36b | |||
| 51f2a41cc3 | |||
| 0a3a8a2e32 | |||
| 9303d7ac64 | |||
| 02f4ab23f7 | |||
| 7fd2a4cda2 | |||
| 7b1103903f | |||
| 3cc3b28471 | |||
| 5c82c75a09 | |||
| 24ffc028e2 | |||
| 734c0129d8 | |||
| 1224a74e63 | |||
| c015ea3697 | |||
| 240aea24aa | |||
| 54762ca299 | |||
| eba30517a6 | |||
| 35b1c6c37d | |||
| 3993099907 | |||
| 57898f939c | |||
| e782d180d7 | |||
| 17ddfa83bf | |||
| 83483d8117 | |||
| 29bac74b58 | |||
| 5b5f14674d | |||
| 82a9e75842 | |||
| 7f8e7214b3 | |||
| eb0798de5b | |||
| 33e48f6d4e | |||
| b139ae18b7 | |||
| 68371922ca |
@@ -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();
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ChatOpenAI } from '@langchain/openai';
|
import { ChatOpenAI } from '@langchain/openai';
|
||||||
import { ModelConfig } from '../types';
|
import { ModelConfig } from '../types';
|
||||||
import { I18nService } from '../i18n/i18n.service';
|
import { I18nService } from '../i18n/i18n.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApiService {
|
export class ApiService {
|
||||||
|
private readonly logger = new Logger(ApiService.name);
|
||||||
|
|
||||||
constructor(private i18nService: I18nService) {}
|
constructor(private i18nService: I18nService) {}
|
||||||
|
|
||||||
// Simple health check method
|
// Simple health check method
|
||||||
@@ -23,7 +25,7 @@ export class ApiService {
|
|||||||
const response = await llm.invoke(prompt);
|
const response = await llm.invoke(prompt);
|
||||||
return response.content.toString();
|
return response.content.toString();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('LangChain call failed:', error);
|
this.logger.error('LangChain call failed:', error);
|
||||||
if (error.message?.includes('401')) {
|
if (error.message?.includes('401')) {
|
||||||
throw new Error(this.i18nService.getMessage('invalidApiKey'));
|
throw new Error(this.i18nService.getMessage('invalidApiKey'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,34 +31,11 @@ import { ImportTaskModule } from './import-task/import-task.module';
|
|||||||
import { AssessmentModule } from './assessment/assessment.module';
|
import { AssessmentModule } from './assessment/assessment.module';
|
||||||
import { I18nMiddleware } from './i18n/i18n.middleware';
|
import { I18nMiddleware } from './i18n/i18n.middleware';
|
||||||
import { TenantMiddleware } from './tenant/tenant.middleware';
|
import { TenantMiddleware } from './tenant/tenant.middleware';
|
||||||
import { User } from './user/user.entity';
|
|
||||||
import { UserSetting } from './user/user-setting.entity';
|
|
||||||
import { ModelConfig } from './model-config/model-config.entity';
|
|
||||||
import { KnowledgeBase } from './knowledge-base/knowledge-base.entity';
|
|
||||||
import { KnowledgeGroup } from './knowledge-group/knowledge-group.entity';
|
|
||||||
import { SearchHistory } from './search-history/search-history.entity';
|
|
||||||
import { ChatMessage } from './search-history/chat-message.entity';
|
|
||||||
import { Note } from './note/note.entity';
|
|
||||||
import { NoteCategory } from './note/note-category.entity';
|
|
||||||
import { PodcastEpisode } from './podcasts/entities/podcast-episode.entity';
|
|
||||||
import { ImportTask } from './import-task/import-task.entity';
|
|
||||||
import { AssessmentSession } from './assessment/entities/assessment-session.entity';
|
|
||||||
import { AssessmentQuestion } from './assessment/entities/assessment-question.entity';
|
|
||||||
import { AssessmentAnswer } from './assessment/entities/assessment-answer.entity';
|
|
||||||
import { AssessmentTemplate } from './assessment/entities/assessment-template.entity';
|
|
||||||
import { QuestionBank } from './assessment/entities/question-bank.entity';
|
|
||||||
import { QuestionBankItem } from './assessment/entities/question-bank-item.entity';
|
|
||||||
import { Tenant } from './tenant/tenant.entity';
|
|
||||||
import { TenantSetting } from './tenant/tenant-setting.entity';
|
|
||||||
import { ApiKey } from './auth/entities/api-key.entity';
|
|
||||||
import { TenantMember } from './tenant/tenant-member.entity';
|
|
||||||
import { TenantModule } from './tenant/tenant.module';
|
import { TenantModule } from './tenant/tenant.module';
|
||||||
import { SuperAdminModule } from './super-admin/super-admin.module';
|
import { SuperAdminModule } from './super-admin/super-admin.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { FeishuModule } from './feishu/feishu.module';
|
import { FeishuModule } from './feishu/feishu.module';
|
||||||
import { FeishuBot } from './feishu/entities/feishu-bot.entity';
|
|
||||||
import { FeishuAssessmentSession } from './feishu/entities/feishu-assessment-session.entity';
|
|
||||||
import { AssessmentCertificate } from './assessment/entities/assessment-certificate.entity';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -77,33 +54,8 @@ import { AssessmentCertificate } from './assessment/entities/assessment-certific
|
|||||||
useFactory: (configService: ConfigService) => ({
|
useFactory: (configService: ConfigService) => ({
|
||||||
type: 'better-sqlite3',
|
type: 'better-sqlite3',
|
||||||
database: configService.get<string>('DATABASE_PATH'),
|
database: configService.get<string>('DATABASE_PATH'),
|
||||||
entities: [
|
autoLoadEntities: true,
|
||||||
User,
|
synchronize: true,
|
||||||
UserSetting,
|
|
||||||
ModelConfig,
|
|
||||||
KnowledgeBase,
|
|
||||||
KnowledgeGroup,
|
|
||||||
SearchHistory,
|
|
||||||
ChatMessage,
|
|
||||||
Note,
|
|
||||||
NoteCategory,
|
|
||||||
PodcastEpisode,
|
|
||||||
ImportTask,
|
|
||||||
AssessmentSession,
|
|
||||||
AssessmentQuestion,
|
|
||||||
AssessmentAnswer,
|
|
||||||
AssessmentTemplate,
|
|
||||||
QuestionBank,
|
|
||||||
QuestionBankItem,
|
|
||||||
Tenant,
|
|
||||||
TenantSetting,
|
|
||||||
TenantMember,
|
|
||||||
ApiKey,
|
|
||||||
FeishuBot,
|
|
||||||
FeishuAssessmentSession,
|
|
||||||
AssessmentCertificate,
|
|
||||||
],
|
|
||||||
synchronize: true, // Auto-create database schema. Disable in production.
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { AssessmentService } from './assessment.service';
|
|||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
||||||
|
import { ExportService } from './services/export.service';
|
||||||
|
import { AuditLogService } from './services/audit-log.service';
|
||||||
|
|
||||||
describe('AssessmentController', () => {
|
describe('AssessmentController', () => {
|
||||||
let controller: AssessmentController;
|
let controller: AssessmentController;
|
||||||
@@ -23,8 +25,10 @@ describe('AssessmentController', () => {
|
|||||||
controllers: [AssessmentController],
|
controllers: [AssessmentController],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: AssessmentService, useFactory: mockService },
|
{ provide: AssessmentService, useFactory: mockService },
|
||||||
{ provide: 'UserService', useFactory: mockService },
|
{ provide: UserService, useFactory: mockService },
|
||||||
{ provide: TenantService, useFactory: mockService },
|
{ provide: TenantService, useFactory: mockService },
|
||||||
|
{ provide: ExportService, useFactory: mockService },
|
||||||
|
{ provide: AuditLogService, useFactory: () => ({ log: jest.fn() }) },
|
||||||
{ provide: Reflector, useFactory: mockReflector },
|
{ provide: Reflector, useFactory: mockReflector },
|
||||||
{ provide: CombinedAuthGuard, useFactory: mockGuard },
|
{ provide: CombinedAuthGuard, useFactory: mockGuard },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Put,
|
Put,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { AssessmentService } from './assessment.service';
|
import { AssessmentService } from './assessment.service';
|
||||||
import { ExportService } from './services/export.service';
|
import { ExportService } from './services/export.service';
|
||||||
|
import { AuditLogService } from './services/audit-log.service';
|
||||||
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
||||||
import { Public } from '../auth/public.decorator';
|
import { Public } from '../auth/public.decorator';
|
||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
@@ -25,9 +27,12 @@ import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
|||||||
@Controller('assessment')
|
@Controller('assessment')
|
||||||
@UseGuards(CombinedAuthGuard)
|
@UseGuards(CombinedAuthGuard)
|
||||||
export class AssessmentController {
|
export class AssessmentController {
|
||||||
|
private readonly logger = new Logger(AssessmentController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly assessmentService: AssessmentService,
|
private readonly assessmentService: AssessmentService,
|
||||||
private readonly exportService: ExportService,
|
private readonly exportService: ExportService,
|
||||||
|
private readonly auditLog: AuditLogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('start')
|
@Post('start')
|
||||||
@@ -38,16 +43,18 @@ export class AssessmentController {
|
|||||||
body: { knowledgeBaseId?: string; language?: string; templateId?: string },
|
body: { knowledgeBaseId?: string; language?: string; templateId?: string },
|
||||||
) {
|
) {
|
||||||
const { id: userId, tenantId } = req.user;
|
const { id: userId, tenantId } = req.user;
|
||||||
console.log(
|
this.logger.log(
|
||||||
`[AssessmentController] startSession: user=${userId}, tenant=${tenantId}, templateId=${body.templateId}, kbId=${body.knowledgeBaseId}`,
|
`startSession: user=${userId}, tenant=${tenantId}, templateId=${body.templateId}, kbId=${body.knowledgeBaseId}`,
|
||||||
);
|
);
|
||||||
return this.assessmentService.startSession(
|
const session = await this.assessmentService.startSession(
|
||||||
userId,
|
userId,
|
||||||
body.knowledgeBaseId,
|
body.knowledgeBaseId,
|
||||||
tenantId,
|
tenantId,
|
||||||
body.language,
|
body.language,
|
||||||
body.templateId,
|
body.templateId,
|
||||||
);
|
);
|
||||||
|
this.auditLog.log({ userId, tenantId, action: 'session.start', resourceType: 'assessment_session', resourceId: session.id });
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/answer')
|
@Post(':id/answer')
|
||||||
@@ -57,24 +64,26 @@ export class AssessmentController {
|
|||||||
@Param('id') sessionId: string,
|
@Param('id') sessionId: string,
|
||||||
@Body() body: { answer: string; language?: string },
|
@Body() body: { answer: string; language?: string },
|
||||||
) {
|
) {
|
||||||
const { id: userId } = req.user;
|
const { id: userId, tenantId } = req.user;
|
||||||
console.log(
|
this.logger.log(
|
||||||
`[AssessmentController] >>> submitAnswer CALLED: user=${userId}, session=${sessionId}, answerLen=${body.answer?.length}`,
|
`submitAnswer: user=${userId}, session=${sessionId}, answerLen=${body.answer?.length}`,
|
||||||
);
|
);
|
||||||
return this.assessmentService.submitAnswer(
|
const result = await this.assessmentService.submitAnswer(
|
||||||
sessionId,
|
sessionId,
|
||||||
userId,
|
userId,
|
||||||
body.answer,
|
body.answer,
|
||||||
body.language,
|
body.language,
|
||||||
);
|
);
|
||||||
|
this.auditLog.log({ userId, tenantId, action: 'session.answer', resourceType: 'assessment_session', resourceId: sessionId, details: { answerLength: body.answer?.length } });
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Sse(':id/start-stream')
|
@Sse(':id/start-stream')
|
||||||
@ApiOperation({ summary: 'Stream initial session generation' })
|
@ApiOperation({ summary: 'Stream initial session generation' })
|
||||||
startSessionStream(@Request() req: any, @Param('id') sessionId: string) {
|
startSessionStream(@Request() req: any, @Param('id') sessionId: string) {
|
||||||
const { id: userId } = req.user;
|
const { id: userId } = req.user;
|
||||||
console.log(
|
this.logger.log(
|
||||||
`[AssessmentController] startSessionStream: user=${userId}, session=${sessionId}`,
|
`startSessionStream: user=${userId}, session=${sessionId}`,
|
||||||
);
|
);
|
||||||
return this.assessmentService
|
return this.assessmentService
|
||||||
.startSessionStream(sessionId, userId)
|
.startSessionStream(sessionId, userId)
|
||||||
@@ -92,8 +101,8 @@ export class AssessmentController {
|
|||||||
@Query('language') language?: string,
|
@Query('language') language?: string,
|
||||||
) {
|
) {
|
||||||
const { id: userId } = req.user;
|
const { id: userId } = req.user;
|
||||||
console.log(
|
this.logger.log(
|
||||||
`[AssessmentController] >>> submitAnswerStream CALLED: user=${userId}, session=${sessionId}, answerLen=${answer?.length}, lang=${language}`,
|
`submitAnswerStream: user=${userId}, session=${sessionId}, answerLen=${answer?.length}, lang=${language}`,
|
||||||
);
|
);
|
||||||
return this.assessmentService
|
return this.assessmentService
|
||||||
.submitAnswerStream(sessionId, userId, answer, language)
|
.submitAnswerStream(sessionId, userId, answer, language)
|
||||||
@@ -104,8 +113,8 @@ export class AssessmentController {
|
|||||||
@ApiOperation({ summary: 'Get the current state of an assessment session' })
|
@ApiOperation({ summary: 'Get the current state of an assessment session' })
|
||||||
async getSessionState(@Request() req: any, @Param('id') sessionId: string) {
|
async getSessionState(@Request() req: any, @Param('id') sessionId: string) {
|
||||||
const { id: userId } = req.user;
|
const { id: userId } = req.user;
|
||||||
console.log(
|
this.logger.log(
|
||||||
`[AssessmentController] getSessionState: user=${userId}, session=${sessionId}`,
|
`getSessionState: user=${userId}, session=${sessionId}`,
|
||||||
);
|
);
|
||||||
return this.assessmentService.getSessionState(sessionId, userId);
|
return this.assessmentService.getSessionState(sessionId, userId);
|
||||||
}
|
}
|
||||||
@@ -114,10 +123,12 @@ export class AssessmentController {
|
|||||||
@ApiOperation({ summary: 'Delete an assessment session' })
|
@ApiOperation({ summary: 'Delete an assessment session' })
|
||||||
async deleteSession(@Request() req: any, @Param('id') sessionId: string) {
|
async deleteSession(@Request() req: any, @Param('id') sessionId: string) {
|
||||||
const user = req.user;
|
const user = req.user;
|
||||||
console.log(
|
this.logger.log(
|
||||||
`[AssessmentController] deleteSession: user=${user.id}, role=${user.role}, session=${sessionId}`,
|
`deleteSession: user=${user.id}, role=${user.role}, session=${sessionId}`,
|
||||||
);
|
);
|
||||||
return this.assessmentService.deleteSession(sessionId, user);
|
await this.assessmentService.deleteSession(sessionId, user);
|
||||||
|
this.auditLog.log({ userId: user.id, tenantId: user.tenantId, action: 'session.delete', resourceType: 'assessment_session', resourceId: sessionId });
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/certificate')
|
@Get(':id/certificate')
|
||||||
@@ -127,8 +138,8 @@ export class AssessmentController {
|
|||||||
@Param('id') sessionId: string,
|
@Param('id') sessionId: string,
|
||||||
) {
|
) {
|
||||||
const { id: userId, tenantId } = req.user;
|
const { id: userId, tenantId } = req.user;
|
||||||
console.log(
|
this.logger.log(
|
||||||
`[AssessmentController] getCertificate: user=${userId}, session=${sessionId}`,
|
`getCertificate: user=${userId}, session=${sessionId}`,
|
||||||
);
|
);
|
||||||
return this.assessmentService.generateCertificate(sessionId, userId, tenantId);
|
return this.assessmentService.generateCertificate(sessionId, userId, tenantId);
|
||||||
}
|
}
|
||||||
@@ -170,8 +181,8 @@ export class AssessmentController {
|
|||||||
@Query('knowledgeGroupId') knowledgeGroupId?: string,
|
@Query('knowledgeGroupId') knowledgeGroupId?: string,
|
||||||
) {
|
) {
|
||||||
const { id: userId, tenantId, role } = req.user;
|
const { id: userId, tenantId, role } = req.user;
|
||||||
console.log(
|
this.logger.log(
|
||||||
`[AssessmentController] getStats: user=${userId}, role=${role}, tenant=${tenantId}`,
|
`getStats: user=${userId}, role=${role}, tenant=${tenantId}`,
|
||||||
);
|
);
|
||||||
return this.assessmentService.getStats(
|
return this.assessmentService.getStats(
|
||||||
userId,
|
userId,
|
||||||
@@ -216,6 +227,26 @@ export class AssessmentController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('batch-delete')
|
||||||
|
@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?.toLowerCase() === 'super_admin' || user.role?.toLowerCase() === 'admin';
|
||||||
|
if (!isAdmin) {
|
||||||
|
throw new ForbiddenException('Only admin can batch delete');
|
||||||
|
}
|
||||||
|
const count = await this.assessmentService.batchDeleteSessions(body.ids, user);
|
||||||
|
this.auditLog.log({ userId: user.id, tenantId: user.tenantId, action: 'session.batch_delete', resourceType: 'assessment_session', details: { count, ids: body.ids } });
|
||||||
|
return { deleted: count };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('batch-export')
|
||||||
|
@ApiOperation({ summary: 'Batch export assessments as JSON array' })
|
||||||
|
async batchExport(@Request() req: any, @Body() body: { ids: string[] }) {
|
||||||
|
const { id: userId } = req.user;
|
||||||
|
return this.assessmentService.batchExportSessions(body.ids, userId);
|
||||||
|
}
|
||||||
|
|
||||||
@Put(':id/review')
|
@Put(':id/review')
|
||||||
@ApiOperation({ summary: 'Review assessment - adjust final score' })
|
@ApiOperation({ summary: 'Review assessment - adjust final score' })
|
||||||
async review(
|
async review(
|
||||||
@@ -224,13 +255,15 @@ export class AssessmentController {
|
|||||||
@Req() req: any,
|
@Req() req: any,
|
||||||
) {
|
) {
|
||||||
const { id: userId, tenantId } = req.user;
|
const { id: userId, tenantId } = req.user;
|
||||||
return this.assessmentService.reviewAssessment(
|
const result = await this.assessmentService.reviewAssessment(
|
||||||
sessionId,
|
sessionId,
|
||||||
body.newScore,
|
body.newScore,
|
||||||
body.comment,
|
body.comment,
|
||||||
userId,
|
userId,
|
||||||
tenantId,
|
tenantId,
|
||||||
);
|
);
|
||||||
|
this.auditLog.log({ userId, tenantId, action: 'session.review', resourceType: 'assessment_session', resourceId: sessionId, details: { newScore: body.newScore, comment: body.comment } });
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/time-check')
|
@Get(':id/time-check')
|
||||||
@@ -252,12 +285,14 @@ export class AssessmentController {
|
|||||||
@Param('id') sessionId: string,
|
@Param('id') sessionId: string,
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
) {
|
) {
|
||||||
const { role } = req.user;
|
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) {
|
if (!isAdmin) {
|
||||||
throw new ForbiddenException('Only admin can force end assessment');
|
throw new ForbiddenException('Only admin can force end assessment');
|
||||||
}
|
}
|
||||||
return this.assessmentService.forceEndAssessment(sessionId);
|
const result = await this.assessmentService.forceEndAssessment(sessionId);
|
||||||
|
this.auditLog.log({ userId, tenantId, action: 'session.force_end', resourceType: 'assessment_session', resourceId: sessionId });
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/export/excel')
|
@Get(':id/export/excel')
|
||||||
@@ -271,12 +306,12 @@ export class AssessmentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/export/pdf')
|
@Get(':id/export/pdf')
|
||||||
@ApiOperation({ summary: 'Export assessment to PDF (text format)' })
|
@ApiOperation({ summary: 'Export assessment to HTML report' })
|
||||||
async exportPdf(@Param('id') sessionId: string) {
|
async exportPdf(@Param('id') sessionId: string) {
|
||||||
const buffer = await this.exportService.exportToPdf(sessionId);
|
const buffer = await this.exportService.exportToPdf(sessionId);
|
||||||
return {
|
return {
|
||||||
filename: `assessment-${sessionId}.txt`,
|
filename: `assessment-${sessionId}.html`,
|
||||||
content: buffer.toString('utf-8'),
|
buffer: buffer.toString('base64'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { ContentFilterService } from './services/content-filter.service';
|
|||||||
import { QuestionOutlineService } from './services/question-outline.service';
|
import { QuestionOutlineService } from './services/question-outline.service';
|
||||||
import { QuestionBankService } from './services/question-bank.service';
|
import { QuestionBankService } from './services/question-bank.service';
|
||||||
import { ExportService } from './services/export.service';
|
import { ExportService } from './services/export.service';
|
||||||
|
import { AuditLog } from './entities/audit-log.entity';
|
||||||
|
import { AuditLogService } from './services/audit-log.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -34,6 +36,7 @@ import { ExportService } from './services/export.service';
|
|||||||
AssessmentCertificate,
|
AssessmentCertificate,
|
||||||
QuestionBank,
|
QuestionBank,
|
||||||
QuestionBankItem,
|
QuestionBankItem,
|
||||||
|
AuditLog,
|
||||||
]),
|
]),
|
||||||
forwardRef(() => KnowledgeBaseModule),
|
forwardRef(() => KnowledgeBaseModule),
|
||||||
forwardRef(() => KnowledgeGroupModule),
|
forwardRef(() => KnowledgeGroupModule),
|
||||||
@@ -51,6 +54,7 @@ import { ExportService } from './services/export.service';
|
|||||||
QuestionOutlineService,
|
QuestionOutlineService,
|
||||||
QuestionBankService,
|
QuestionBankService,
|
||||||
ExportService,
|
ExportService,
|
||||||
|
AuditLogService,
|
||||||
],
|
],
|
||||||
exports: [AssessmentService, TemplateService, QuestionOutlineService, QuestionBankService, ExportService],
|
exports: [AssessmentService, TemplateService, QuestionOutlineService, QuestionBankService, ExportService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { AssessmentService } from './assessment.service';
|
import { AssessmentService } from './assessment.service';
|
||||||
import { AssessmentSession } from './entities/assessment-session.entity';
|
import { AssessmentSession, AssessmentStatus } from './entities/assessment-session.entity';
|
||||||
import { AssessmentQuestion } from './entities/assessment-question.entity';
|
import { AssessmentQuestion } from './entities/assessment-question.entity';
|
||||||
import { AssessmentAnswer } from './entities/assessment-answer.entity';
|
import { AssessmentAnswer } from './entities/assessment-answer.entity';
|
||||||
import { AssessmentCertificate } from './entities/assessment-certificate.entity';
|
import { AssessmentCertificate } from './entities/assessment-certificate.entity';
|
||||||
|
import { QuestionBank } from './entities/question-bank.entity';
|
||||||
|
import { QuestionBankItem } from './entities/question-bank-item.entity';
|
||||||
import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
|
import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
|
||||||
import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
|
import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
|
||||||
import { ModelConfigService } from '../model-config/model-config.service';
|
import { ModelConfigService } from '../model-config/model-config.service';
|
||||||
@@ -22,16 +25,35 @@ import { NotFoundException } from '@nestjs/common';
|
|||||||
describe('AssessmentService', () => {
|
describe('AssessmentService', () => {
|
||||||
let service: AssessmentService;
|
let service: AssessmentService;
|
||||||
let sessionRepository: any;
|
let sessionRepository: any;
|
||||||
|
let certificateRepository: any;
|
||||||
|
let dataSource: any;
|
||||||
|
|
||||||
const mockRepository = () => ({
|
const mockRepository = () => ({
|
||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
find: jest.fn(),
|
find: jest.fn(),
|
||||||
findOne: jest.fn(),
|
findOne: jest.fn(),
|
||||||
save: jest.fn(),
|
save: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockService = () => ({});
|
const mockService = () => ({});
|
||||||
|
|
||||||
|
const regularUser = { id: 'user-1', role: 'user' };
|
||||||
|
const adminUser = { id: 'admin-1', role: 'admin' };
|
||||||
|
|
||||||
|
const mockManager = (overrides?: any) => ({
|
||||||
|
findOne: jest.fn(),
|
||||||
|
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||||
|
save: jest.fn(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockDataSource = (manager?: any) => ({
|
||||||
|
transaction: jest.fn(async (cb: any) => {
|
||||||
|
return cb(manager || mockManager());
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -40,6 +62,8 @@ describe('AssessmentService', () => {
|
|||||||
{ provide: getRepositoryToken(AssessmentQuestion), useFactory: mockRepository },
|
{ provide: getRepositoryToken(AssessmentQuestion), useFactory: mockRepository },
|
||||||
{ provide: getRepositoryToken(AssessmentAnswer), useFactory: mockRepository },
|
{ provide: getRepositoryToken(AssessmentAnswer), useFactory: mockRepository },
|
||||||
{ provide: getRepositoryToken(AssessmentCertificate), useFactory: mockRepository },
|
{ provide: getRepositoryToken(AssessmentCertificate), useFactory: mockRepository },
|
||||||
|
{ provide: getRepositoryToken(QuestionBank), useFactory: mockRepository },
|
||||||
|
{ provide: getRepositoryToken(QuestionBankItem), useFactory: mockRepository },
|
||||||
{ provide: KnowledgeBaseService, useFactory: mockService },
|
{ provide: KnowledgeBaseService, useFactory: mockService },
|
||||||
{ provide: KnowledgeGroupService, useFactory: mockService },
|
{ provide: KnowledgeGroupService, useFactory: mockService },
|
||||||
{ provide: ModelConfigService, useFactory: mockService },
|
{ provide: ModelConfigService, useFactory: mockService },
|
||||||
@@ -52,11 +76,14 @@ describe('AssessmentService', () => {
|
|||||||
{ provide: ChatService, useFactory: mockService },
|
{ provide: ChatService, useFactory: mockService },
|
||||||
{ provide: I18nService, useFactory: mockService },
|
{ provide: I18nService, useFactory: mockService },
|
||||||
{ provide: TenantService, useFactory: mockService },
|
{ provide: TenantService, useFactory: mockService },
|
||||||
|
{ provide: DataSource, useFactory: () => mockDataSource(mockManager()) },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<AssessmentService>(AssessmentService);
|
service = module.get<AssessmentService>(AssessmentService);
|
||||||
sessionRepository = module.get(getRepositoryToken(AssessmentSession));
|
sessionRepository = module.get(getRepositoryToken(AssessmentSession));
|
||||||
|
certificateRepository = module.get(getRepositoryToken(AssessmentCertificate));
|
||||||
|
dataSource = module.get(DataSource);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
@@ -64,15 +91,110 @@ describe('AssessmentService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteSession', () => {
|
describe('deleteSession', () => {
|
||||||
it('should delete a session if it exists and belongs to the user', async () => {
|
it('should delete a session when non-admin user owns it', async () => {
|
||||||
sessionRepository.delete.mockResolvedValue({ affected: 1 });
|
const manager = mockManager({
|
||||||
await expect(service.deleteSession('session-id', 'user-id')).resolves.not.toThrow();
|
findOne: jest.fn().mockResolvedValue({ id: 'session-id', userId: 'user-1' }),
|
||||||
expect(sessionRepository.delete).toHaveBeenCalledWith({ id: 'session-id', userId: 'user-id' });
|
});
|
||||||
|
dataSource.transaction.mockImplementation(async (cb: any) => cb(manager));
|
||||||
|
|
||||||
|
await expect(service.deleteSession('session-id', regularUser)).resolves.not.toThrow();
|
||||||
|
expect(manager.findOne).toHaveBeenCalledWith(AssessmentSession, { where: { id: 'session-id', userId: 'user-1' } });
|
||||||
|
expect(manager.delete).toHaveBeenCalledWith(AssessmentCertificate, { sessionId: 'session-id' });
|
||||||
|
expect(manager.delete).toHaveBeenCalledWith(AssessmentSession, { id: 'session-id' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw NotFoundException if no session was affected', async () => {
|
it('should delete any session when admin user', async () => {
|
||||||
sessionRepository.delete.mockResolvedValue({ affected: 0 });
|
const manager = mockManager({
|
||||||
await expect(service.deleteSession('non-existent', 'user-id')).rejects.toThrow(NotFoundException);
|
findOne: jest.fn().mockResolvedValue({ id: 'other-session', userId: 'user-2' }),
|
||||||
|
});
|
||||||
|
dataSource.transaction.mockImplementation(async (cb: any) => cb(manager));
|
||||||
|
|
||||||
|
await expect(service.deleteSession('other-session', adminUser)).resolves.not.toThrow();
|
||||||
|
expect(manager.findOne).toHaveBeenCalledWith(AssessmentSession, { where: { id: 'other-session' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException if session not found', async () => {
|
||||||
|
const manager = mockManager({
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
});
|
||||||
|
dataSource.transaction.mockImplementation(async (cb: any) => cb(manager));
|
||||||
|
|
||||||
|
await expect(service.deleteSession('non-existent', regularUser)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateCertificate', () => {
|
||||||
|
const completedSession = {
|
||||||
|
id: 'session-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
status: AssessmentStatus.COMPLETED,
|
||||||
|
finalScore: 85,
|
||||||
|
templateId: 'template-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should throw NotFoundException when session does not exist', async () => {
|
||||||
|
sessionRepository.findOne.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
service.generateCertificate('no-session', 'user-1', 'tenant-1'),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw Error when session is not completed', async () => {
|
||||||
|
sessionRepository.findOne.mockResolvedValue({
|
||||||
|
...completedSession,
|
||||||
|
status: AssessmentStatus.IN_PROGRESS,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.generateCertificate('session-1', 'user-1', 'tenant-1'),
|
||||||
|
).rejects.toThrow('Session not completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return existing certificate if already generated (idempotent)', async () => {
|
||||||
|
const existingCert = { id: 'cert-1', sessionId: 'session-1' };
|
||||||
|
sessionRepository.findOne.mockResolvedValue(completedSession);
|
||||||
|
certificateRepository.findOne.mockResolvedValue(existingCert);
|
||||||
|
const result = await service.generateCertificate('session-1', 'user-1', 'tenant-1');
|
||||||
|
expect(result).toEqual(existingCert);
|
||||||
|
expect(certificateRepository.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new certificate with correct level for score >= 90 (Expert)', async () => {
|
||||||
|
sessionRepository.findOne.mockResolvedValue({ ...completedSession, finalScore: 95 });
|
||||||
|
certificateRepository.findOne.mockResolvedValue(null);
|
||||||
|
certificateRepository.create.mockReturnValue({ id: 'cert-new' });
|
||||||
|
certificateRepository.save.mockResolvedValue({ id: 'cert-new', level: 'Expert' });
|
||||||
|
|
||||||
|
const result = await service.generateCertificate('session-1', 'user-1', 'tenant-1');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(certificateRepository.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ level: 'Expert', totalScore: 95 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new certificate with Advanced level for score 75-89', async () => {
|
||||||
|
sessionRepository.findOne.mockResolvedValue(completedSession);
|
||||||
|
certificateRepository.findOne.mockResolvedValue(null);
|
||||||
|
certificateRepository.create.mockReturnValue({ id: 'cert-new' });
|
||||||
|
certificateRepository.save.mockResolvedValue({ id: 'cert-new', level: 'Advanced' });
|
||||||
|
|
||||||
|
const result = await service.generateCertificate('session-1', 'user-1', 'tenant-1');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(certificateRepository.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ level: 'Advanced', totalScore: 85 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new certificate with Novice level for score < 60', async () => {
|
||||||
|
sessionRepository.findOne.mockResolvedValue({ ...completedSession, finalScore: 45 });
|
||||||
|
certificateRepository.findOne.mockResolvedValue(null);
|
||||||
|
certificateRepository.create.mockReturnValue({ id: 'cert-new' });
|
||||||
|
certificateRepository.save.mockResolvedValue({ id: 'cert-new', level: 'Novice' });
|
||||||
|
|
||||||
|
const result = await service.generateCertificate('session-1', 'user-1', 'tenant-1');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(certificateRepository.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ level: 'Novice', totalScore: 45 }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, DeepPartial, In } from 'typeorm';
|
import { Repository, DeepPartial, In, DataSource } from 'typeorm';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { ChatOpenAI } from '@langchain/openai';
|
import { ChatOpenAI } from '@langchain/openai';
|
||||||
import {
|
import {
|
||||||
@@ -27,7 +27,7 @@ import { AssessmentAnswer } from './entities/assessment-answer.entity';
|
|||||||
import { AssessmentTemplate } from './entities/assessment-template.entity';
|
import { AssessmentTemplate } from './entities/assessment-template.entity';
|
||||||
import { AssessmentCertificate } from './entities/assessment-certificate.entity';
|
import { AssessmentCertificate } from './entities/assessment-certificate.entity';
|
||||||
import { QuestionBank, QuestionBankStatus } from './entities/question-bank.entity';
|
import { QuestionBank, QuestionBankStatus } from './entities/question-bank.entity';
|
||||||
import { QuestionBankItem } from './entities/question-bank-item.entity';
|
import { QuestionBankItem, QuestionBankItemStatus } from './entities/question-bank-item.entity';
|
||||||
import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
|
import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
|
||||||
import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
|
import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
|
||||||
import { ModelConfigService } from '../model-config/model-config.service';
|
import { ModelConfigService } from '../model-config/model-config.service';
|
||||||
@@ -78,6 +78,7 @@ export class AssessmentService {
|
|||||||
private chatService: ChatService,
|
private chatService: ChatService,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private tenantService: TenantService,
|
private tenantService: TenantService,
|
||||||
|
private dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
||||||
@@ -136,12 +137,19 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeDimension(dim: string): string {
|
||||||
|
const lower = dim.toLowerCase();
|
||||||
|
if (lower === 'dev_pattern') return 'devPattern';
|
||||||
|
if (lower === 'work_capability') return 'workCapability';
|
||||||
|
return lower;
|
||||||
|
}
|
||||||
|
|
||||||
private calculateScores(
|
private calculateScores(
|
||||||
questions: any[],
|
questions: any[],
|
||||||
scores: Record<string, number>,
|
scores: Record<string, number>,
|
||||||
weightConfig: { prompt: number; other: number },
|
weightConfig: { prompt: number; other: number },
|
||||||
): { finalScore: number; dimensionScores: Record<string, number>; radarData: Record<string, number> } {
|
): { finalScore: number; dimensionScores: Record<string, number>; radarData: Record<string, number> } {
|
||||||
console.log('[calculateScores] Input:', {
|
this.logger.debug('[calculateScores] Input:', {
|
||||||
questionsCount: questions.length,
|
questionsCount: questions.length,
|
||||||
scores,
|
scores,
|
||||||
weightConfig,
|
weightConfig,
|
||||||
@@ -156,7 +164,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
questions.forEach((q: any, idx: number) => {
|
questions.forEach((q: any, idx: number) => {
|
||||||
const dimension = q.dimension || 'workCapability';
|
const dimension = this.normalizeDimension(q.dimension || 'workCapability');
|
||||||
const score = scores[q.id || idx.toString()] || 0;
|
const score = scores[q.id || idx.toString()] || 0;
|
||||||
if (dimensionScoresMap[dimension]) {
|
if (dimensionScoresMap[dimension]) {
|
||||||
dimensionScoresMap[dimension].push(score);
|
dimensionScoresMap[dimension].push(score);
|
||||||
@@ -179,16 +187,25 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
|||||||
? otherDimsWithScores.reduce((sum, dim) => sum + (dimensionAverages[dim] || 0), 0) / otherDimsWithScores.length
|
? otherDimsWithScores.reduce((sum, dim) => sum + (dimensionAverages[dim] || 0), 0) / otherDimsWithScores.length
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
console.log('[calculateScores] Scoring debug:', { promptAvg, otherDimsWithScores, otherAvg, workCapability: dimensionAverages.workCapability });
|
this.logger.debug('[calculateScores] Scoring debug:', { promptAvg, otherDimsWithScores, otherAvg, workCapability: dimensionAverages.workCapability });
|
||||||
|
|
||||||
const finalScore = promptAvg * (weightConfig.prompt / 100) + otherAvg * (weightConfig.other / 100);
|
// 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> = {};
|
const radarData: Record<string, number> = {};
|
||||||
Object.keys(dimensionAverages).forEach(dim => {
|
Object.keys(dimensionAverages).forEach(dim => {
|
||||||
radarData[dim] = Math.round(dimensionAverages[dim] * 10) / 10;
|
radarData[dim] = Math.round(dimensionAverages[dim] * 10) / 10;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[calculateScores] Result:', {
|
this.logger.debug('[calculateScores] Result:', {
|
||||||
finalScore: Math.round(finalScore * 10) / 10,
|
finalScore: Math.round(finalScore * 10) / 10,
|
||||||
dimensionScores: dimensionAverages,
|
dimensionScores: dimensionAverages,
|
||||||
promptAvg,
|
promptAvg,
|
||||||
@@ -414,38 +431,59 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
|||||||
|
|
||||||
// Use kbId if provided, otherwise fall back to template's group ID
|
// Use kbId if provided, otherwise fall back to template's group ID
|
||||||
const activeKbId = kbId || template?.knowledgeGroupId;
|
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`);
|
this.logger.error(`[startSession] No knowledge source resolved`);
|
||||||
throw new BadRequestException('Knowledge source (ID or Template) must be provided.');
|
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;
|
let isKb = false;
|
||||||
try {
|
if (activeKbId) {
|
||||||
await this.kbService.findOne(activeKbId, userId, tenantId);
|
try {
|
||||||
isKb = true;
|
await this.kbService.findOne(activeKbId, userId, tenantId);
|
||||||
} catch (kbError) {
|
isKb = true;
|
||||||
if (kbError instanceof NotFoundException) {
|
} catch (kbError) {
|
||||||
// Try finding it as a Group
|
if (kbError instanceof NotFoundException) {
|
||||||
try {
|
try {
|
||||||
await this.groupService.findOne(activeKbId, userId, tenantId);
|
await this.groupService.findOne(activeKbId, userId, tenantId);
|
||||||
} catch (groupError) {
|
} catch (groupError) {
|
||||||
this.logger.error(
|
this.logger.error(`[startSession] Knowledge source ${activeKbId} not found`);
|
||||||
`[startSession] Knowledge source ${activeKbId} not found as KB or Group`,
|
throw new NotFoundException(
|
||||||
);
|
this.i18nService.getMessage('knowledgeSourceNotFound') || 'Knowledge source 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}`);
|
this.logger.debug(`[startSession] isKb: ${isKb}`);
|
||||||
|
|
||||||
const templateData = template
|
const templateData: any = template
|
||||||
? {
|
? {
|
||||||
name: template.name,
|
name: template.name,
|
||||||
keywords: template.keywords,
|
keywords: template.keywords,
|
||||||
@@ -457,6 +495,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
|||||||
weightConfig: template.weightConfig,
|
weightConfig: template.weightConfig,
|
||||||
passingScore: template.passingScore,
|
passingScore: template.passingScore,
|
||||||
style: template.style,
|
style: template.style,
|
||||||
|
dimensions: template.dimensions,
|
||||||
linkedGroupIds: template.linkedGroupIds,
|
linkedGroupIds: template.linkedGroupIds,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -467,36 +506,71 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
|||||||
if (templateId) {
|
if (templateId) {
|
||||||
try {
|
try {
|
||||||
const targetCount = template?.questionCount || 5;
|
const targetCount = template?.questionCount || 5;
|
||||||
const publishedBanks = await this.questionBankRepository.find({
|
const linkedBanks = await this.questionBankRepository.find({
|
||||||
where: { templateId, status: QuestionBankStatus.PUBLISHED },
|
where: { templateId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (publishedBanks.length > 0) {
|
if (linkedBanks.length > 0) {
|
||||||
const bankIds = publishedBanks.map(b => b.id);
|
const bankIds = linkedBanks.map(b => b.id);
|
||||||
const questionCount = await this.questionBankItemRepository.count({
|
const questionCount = await this.questionBankItemRepository.count({
|
||||||
where: { bankId: In(bankIds) },
|
where: { bankId: In(bankIds), status: QuestionBankItemStatus.PUBLISHED },
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[startSession] Found ${publishedBanks.length} published banks with ${questionCount} questions, target: ${targetCount}`,
|
`[startSession] Found ${linkedBanks.length} banks with ${questionCount} published questions, target: ${targetCount}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (questionCount >= targetCount) {
|
if (questionCount >= targetCount) {
|
||||||
const bankId = publishedBanks[0].id;
|
const bankId = linkedBanks[0].id;
|
||||||
const selectedItems = await this.questionBankService.selectQuestions(
|
const selectedItems = await this.questionBankService.selectQuestions(
|
||||||
bankId,
|
bankId,
|
||||||
targetCount,
|
targetCount,
|
||||||
|
template?.dimensions,
|
||||||
);
|
);
|
||||||
|
|
||||||
questionsFromBank = selectedItems.map(item => ({
|
questionsFromBank = selectedItems.map(item => {
|
||||||
id: item.id,
|
let options = item.options;
|
||||||
questionText: item.questionText,
|
let correctAnswer = item.correctAnswer;
|
||||||
questionType: item.questionType,
|
if (item.questionType === 'MULTIPLE_CHOICE' && options && options.length > 0 && correctAnswer) {
|
||||||
keyPoints: item.keyPoints,
|
const labels = ['A', 'B', 'C', 'D'];
|
||||||
difficulty: item.difficulty,
|
const optTexts = options.map((o: string) => o.replace(/^[A-D][.)、]\s*/, ''));
|
||||||
dimension: item.dimension,
|
const correctIdx = correctAnswer.charCodeAt(0) - 65;
|
||||||
basis: item.basis,
|
const correctText = correctIdx >= 0 && correctIdx < optTexts.length ? optTexts[correctIdx] : null;
|
||||||
}));
|
const indices = optTexts.map((_: any, i: number) => i);
|
||||||
|
for (let i = indices.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[indices[i], indices[j]] = [indices[j], indices[i]];
|
||||||
|
}
|
||||||
|
options = indices.map((origIdx: number, newPos: number) => `${labels[newPos]}${optTexts[origIdx]}`);
|
||||||
|
correctAnswer = correctText ? labels[indices.indexOf(correctIdx)] : correctAnswer;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
questionText: item.questionText,
|
||||||
|
questionType: item.questionType,
|
||||||
|
options,
|
||||||
|
correctAnswer,
|
||||||
|
judgment: item.judgment,
|
||||||
|
keyPoints: item.keyPoints,
|
||||||
|
difficulty: item.difficulty,
|
||||||
|
dimension: item.dimension,
|
||||||
|
basis: item.basis,
|
||||||
|
maxFollowUps: item.followupHints?.length || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const answerKey: Record<string, { correctAnswer?: string | null; judgment?: string | null }> = {};
|
||||||
|
selectedItems.forEach(item => {
|
||||||
|
if (item.correctAnswer || item.judgment) {
|
||||||
|
answerKey[item.id] = {
|
||||||
|
correctAnswer: item.correctAnswer,
|
||||||
|
judgment: item.judgment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (Object.keys(answerKey).length > 0 && templateData) {
|
||||||
|
templateData.questionAnswerKey = answerKey;
|
||||||
|
}
|
||||||
|
|
||||||
questionSource = 'bank';
|
questionSource = 'bank';
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -534,15 +608,20 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
|||||||
perQuestionTimeLimit: template?.perQuestionTimeLimit || 300,
|
perQuestionTimeLimit: template?.perQuestionTimeLimit || 300,
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = await this.getSessionContent(sessionData);
|
// Skip content check if questions are loaded from the question bank
|
||||||
|
const hasBankContent = questionsFromBank.length > 0;
|
||||||
|
|
||||||
if (!content || content.trim().length < 10) {
|
if (!hasBankContent) {
|
||||||
this.logger.error(
|
const content = await this.getSessionContent(sessionData);
|
||||||
`[startSession] Insufficient content length: ${content?.length || 0}`,
|
|
||||||
);
|
if (!content || content.trim().length < 10) {
|
||||||
throw new BadRequestException(
|
this.logger.error(
|
||||||
'Selected knowledge source has no sufficient content for evaluation.',
|
`[startSession] Insufficient content length: ${content?.length || 0}`,
|
||||||
);
|
);
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Selected knowledge source has no sufficient content for evaluation.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = this.sessionRepository.create(
|
const session = this.sessionRepository.create(
|
||||||
@@ -560,7 +639,9 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
|||||||
`[startSession] Session ${savedSession.id} created and saved`,
|
`[startSession] Session ${savedSession.id} created and saved`,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.cleanupOldSessions(userId);
|
// cleanupOldSessions permanently destroys data - disabled to preserve history.
|
||||||
|
// Admins can use batch-delete endpoint for manual cleanup.
|
||||||
|
// this.cleanupOldSessions(userId);
|
||||||
|
|
||||||
return savedSession;
|
return savedSession;
|
||||||
}
|
}
|
||||||
@@ -581,12 +662,14 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const model = await this.getModel(session.tenantId);
|
const model = await this.getModel(session.tenantId);
|
||||||
const content = await this.getSessionContent(session);
|
|
||||||
|
|
||||||
// Check if questions already exist in session (from question bank)
|
// Check if questions already exist in session (from question bank)
|
||||||
const existingQuestions = session.questions_json || [];
|
const existingQuestions = session.questions_json || [];
|
||||||
const hasExistingQuestions = existingQuestions.length > 0;
|
const hasExistingQuestions = existingQuestions.length > 0;
|
||||||
|
|
||||||
|
// Skip content retrieval when bank questions exist (prevents generator errors)
|
||||||
|
const content = hasExistingQuestions ? '' : await this.getSessionContent(session);
|
||||||
|
|
||||||
// Check if we already have state
|
// Check if we already have state
|
||||||
const existingState = await this.graph.getState({
|
const existingState = await this.graph.getState({
|
||||||
configurable: { thread_id: sessionId },
|
configurable: { thread_id: sessionId },
|
||||||
@@ -599,7 +682,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Session ${sessionId} already has state, skipping generation.`,
|
`Session ${sessionId} already has state, skipping generation.`,
|
||||||
);
|
);
|
||||||
const mappedData = { ...existingState.values };
|
const mappedData = this.sanitizeStateForClient({ ...existingState.values });
|
||||||
mappedData.messages = this.mapMessages(mappedData.messages || []);
|
mappedData.messages = this.mapMessages(mappedData.messages || []);
|
||||||
mappedData.feedbackHistory = this.mapMessages(
|
mappedData.feedbackHistory = this.mapMessages(
|
||||||
mappedData.feedbackHistory || [],
|
mappedData.feedbackHistory || [],
|
||||||
@@ -621,6 +704,7 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
|
|
||||||
style: session.templateJson?.style,
|
style: session.templateJson?.style,
|
||||||
keywords: session.templateJson?.keywords,
|
keywords: session.templateJson?.keywords,
|
||||||
|
questionAnswerKey: session.templateJson?.questionAnswerKey,
|
||||||
currentQuestionIndex: 0,
|
currentQuestionIndex: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -708,7 +792,7 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
const finalData = fullState.values as EvaluationState;
|
const finalData = fullState.values as EvaluationState;
|
||||||
|
|
||||||
if (finalData && finalData.messages) {
|
if (finalData && finalData.messages) {
|
||||||
console.log(
|
this.logger.debug(
|
||||||
`[AssessmentService] startSessionStream Final Authoritative State messages:`,
|
`[AssessmentService] startSessionStream Final Authoritative State messages:`,
|
||||||
finalData.messages.length,
|
finalData.messages.length,
|
||||||
);
|
);
|
||||||
@@ -726,7 +810,7 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
const scores = finalData.scores;
|
const scores = finalData.scores;
|
||||||
const questions = finalData.questions || [];
|
const questions = finalData.questions || [];
|
||||||
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
|
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
|
||||||
const passingScore = session.templateJson?.passingScore || 90;
|
const passingScore = (session.templateJson?.passingScore ?? 60) / 10;
|
||||||
|
|
||||||
if (questions.length > 0 && Object.keys(scores).length > 0) {
|
if (questions.length > 0 && Object.keys(scores).length > 0) {
|
||||||
const { finalScore, dimensionScores, radarData } = this.calculateScores(
|
const { finalScore, dimensionScores, radarData } = this.calculateScores(
|
||||||
@@ -742,7 +826,10 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
}
|
}
|
||||||
await this.sessionRepository.save(session);
|
await this.sessionRepository.save(session);
|
||||||
|
|
||||||
const mappedData: any = { ...finalData };
|
const mappedData: any = this.sanitizeStateForClient(
|
||||||
|
{ ...finalData },
|
||||||
|
session.status !== AssessmentStatus.COMPLETED,
|
||||||
|
);
|
||||||
mappedData.messages = this.mapMessages(finalData.messages);
|
mappedData.messages = this.mapMessages(finalData.messages);
|
||||||
mappedData.feedbackHistory = this.mapMessages(
|
mappedData.feedbackHistory = this.mapMessages(
|
||||||
finalData.feedbackHistory || [],
|
finalData.feedbackHistory || [],
|
||||||
@@ -750,6 +837,7 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
mappedData.status = session.status;
|
mappedData.status = session.status;
|
||||||
mappedData.report = session.finalReport;
|
mappedData.report = session.finalReport;
|
||||||
mappedData.finalScore = session.finalScore;
|
mappedData.finalScore = session.finalScore;
|
||||||
|
mappedData.passed = (session as any).passed;
|
||||||
observer.next({ type: 'final', data: mappedData });
|
observer.next({ type: 'final', data: mappedData });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -776,6 +864,33 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
});
|
});
|
||||||
if (!session) throw new NotFoundException('Session not found');
|
if (!session) throw new NotFoundException('Session not found');
|
||||||
|
|
||||||
|
if (session.status === AssessmentStatus.IN_PROGRESS) {
|
||||||
|
const now = new Date();
|
||||||
|
const startTime = session.startedAt ? new Date(session.startedAt) : now;
|
||||||
|
const questionStartTime = session.currentQuestionStartedAt ? new Date(session.currentQuestionStartedAt) : now;
|
||||||
|
const totalElapsed = Math.floor((now.getTime() - startTime.getTime()) / 1000);
|
||||||
|
const questionElapsed = Math.floor((now.getTime() - questionStartTime.getTime()) / 1000);
|
||||||
|
|
||||||
|
if (totalElapsed >= session.totalTimeLimit || questionElapsed >= session.perQuestionTimeLimit) {
|
||||||
|
session.status = AssessmentStatus.COMPLETED;
|
||||||
|
session.finalReport = totalElapsed >= session.totalTimeLimit
|
||||||
|
? '评测总时间已用尽,评估已自动结束'
|
||||||
|
: '单题答题时间已用尽,评估已自动结束';
|
||||||
|
if (session.finalScore === null || session.finalScore === undefined) {
|
||||||
|
session.finalScore = 0;
|
||||||
|
}
|
||||||
|
await this.sessionRepository.save(session);
|
||||||
|
this.logger.log(`[submitAnswer] Session ${sessionId} auto-ended due to timeout`);
|
||||||
|
return {
|
||||||
|
assessmentSessionId: sessionId,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
timeout: true,
|
||||||
|
finalScore: session.finalScore,
|
||||||
|
finalReport: session.finalReport,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const model = await this.getModel(session.tenantId);
|
const model = await this.getModel(session.tenantId);
|
||||||
await this.ensureGraphState(sessionId, session);
|
await this.ensureGraphState(sessionId, session);
|
||||||
const content = await this.getSessionContent(session);
|
const content = await this.getSessionContent(session);
|
||||||
@@ -790,7 +905,7 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
|
|
||||||
let finalResult: any = null;
|
let finalResult: any = null;
|
||||||
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
|
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
|
||||||
const passingScore = session.templateJson?.passingScore || 90;
|
const passingScore = (session.templateJson?.passingScore ?? 60) / 10;
|
||||||
|
|
||||||
// Resume from the last interrupt (typically after interviewer)
|
// Resume from the last interrupt (typically after interviewer)
|
||||||
const stream = await this.graph.stream(null, {
|
const stream = await this.graph.stream(null, {
|
||||||
@@ -843,18 +958,18 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
const scores = finalResult.scores as Record<string, number>;
|
const scores = finalResult.scores as Record<string, number>;
|
||||||
const questions = finalResult.questions || [];
|
const questions = finalResult.questions || [];
|
||||||
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
|
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
|
||||||
const passingScore = session.templateJson?.passingScore || 90;
|
const passingScore = (session.templateJson?.passingScore ?? 60) / 10;
|
||||||
|
|
||||||
if (questions.length > 0 && Object.keys(scores).length > 0) {
|
if (questions.length > 0 && Object.keys(scores).length > 0) {
|
||||||
const { finalScore, dimensionScores, radarData } = this.calculateScores(
|
const { finalScore, dimensionScores, radarData } = this.calculateScores(
|
||||||
questions,
|
questions,
|
||||||
scores,
|
scores,
|
||||||
weightConfig,
|
weightConfig,
|
||||||
);
|
);
|
||||||
session.finalScore = finalScore;
|
session.finalScore = finalScore;
|
||||||
(session as any).dimensionScores = dimensionScores;
|
(session as any).dimensionScores = dimensionScores;
|
||||||
(session as any).radarData = radarData;
|
(session as any).radarData = radarData;
|
||||||
(session as any).passed = finalScore >= passingScore;
|
(session as any).passed = finalScore >= passingScore;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -902,13 +1017,13 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
answer: string,
|
answer: string,
|
||||||
language: string = 'en',
|
language: string = 'en',
|
||||||
): Observable<any> {
|
): Observable<any> {
|
||||||
console.log('[submitAnswerStream] START - sessionId:', sessionId, 'answer length:', answer?.length);
|
this.logger.debug('[submitAnswerStream] START - sessionId:', sessionId, 'answer length:', answer?.length);
|
||||||
let emittedNextQuestion = false;
|
let emittedNextQuestion = false;
|
||||||
let hasEmittedNodes = false;
|
let hasEmittedNodes = false;
|
||||||
return new Observable((observer) => {
|
return new Observable((observer) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
console.log('[submitAnswerStream] After Observable - sessionId:', sessionId);
|
this.logger.debug('[submitAnswerStream] After Observable - sessionId:', sessionId);
|
||||||
const session = await this.sessionRepository.findOne({
|
const session = await this.sessionRepository.findOne({
|
||||||
where: { id: sessionId, userId },
|
where: { id: sessionId, userId },
|
||||||
});
|
});
|
||||||
@@ -917,6 +1032,36 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (session.status === AssessmentStatus.IN_PROGRESS) {
|
||||||
|
const now = new Date();
|
||||||
|
const startTime = session.startedAt ? new Date(session.startedAt) : now;
|
||||||
|
const questionStartTime = session.currentQuestionStartedAt ? new Date(session.currentQuestionStartedAt) : now;
|
||||||
|
const totalElapsed = Math.floor((now.getTime() - startTime.getTime()) / 1000);
|
||||||
|
const questionElapsed = Math.floor((now.getTime() - questionStartTime.getTime()) / 1000);
|
||||||
|
|
||||||
|
if (totalElapsed >= session.totalTimeLimit || questionElapsed >= session.perQuestionTimeLimit) {
|
||||||
|
session.status = AssessmentStatus.COMPLETED;
|
||||||
|
session.finalReport = totalElapsed >= session.totalTimeLimit
|
||||||
|
? '评测总时间已用尽,评估已自动结束'
|
||||||
|
: '单题答题时间已用尽,评估已自动结束';
|
||||||
|
if (session.finalScore === null || session.finalScore === undefined) {
|
||||||
|
session.finalScore = 0;
|
||||||
|
}
|
||||||
|
await this.sessionRepository.save(session);
|
||||||
|
this.logger.log(`[submitAnswerStream] Session ${sessionId} auto-ended due to timeout`);
|
||||||
|
observer.next({
|
||||||
|
type: 'final',
|
||||||
|
assessmentSessionId: sessionId,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
timeout: true,
|
||||||
|
finalScore: session.finalScore,
|
||||||
|
finalReport: session.finalReport,
|
||||||
|
});
|
||||||
|
observer.complete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const model = await this.getModel(session.tenantId);
|
const model = await this.getModel(session.tenantId);
|
||||||
const content = await this.getSessionContent(session);
|
const content = await this.getSessionContent(session);
|
||||||
await this.ensureGraphState(sessionId, session);
|
await this.ensureGraphState(sessionId, session);
|
||||||
@@ -927,7 +1072,7 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
graphState &&
|
graphState &&
|
||||||
graphState.values &&
|
graphState.values &&
|
||||||
Object.keys(graphState.values).length > 0;
|
Object.keys(graphState.values).length > 0;
|
||||||
console.log(
|
this.logger.debug(
|
||||||
`[AssessmentService] submitAnswerStream: sessionId=${sessionId}, hasState=${hasState}, nextNodes=[${graphState.next || ''}]`,
|
`[AssessmentService] submitAnswerStream: sessionId=${sessionId}, hasState=${hasState}, nextNodes=[${graphState.next || ''}]`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -953,8 +1098,8 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
let hasEmittedNodes = false;
|
let hasEmittedNodes = false;
|
||||||
for await (const [mode, data] of stream) {
|
for await (const [mode, data] of stream) {
|
||||||
streamCount++;
|
streamCount++;
|
||||||
console.log('[submitAnswerStream] Stream event:', streamCount, mode, Object.keys(data || {}));
|
this.logger.debug('[submitAnswerStream] Stream event:', streamCount, mode, Object.keys(data || {}));
|
||||||
console.log('[submitAnswerStream] Data detail:', JSON.stringify(data).substring(0, 500));
|
this.logger.debug('[submitAnswerStream] Data detail:', JSON.stringify(data).substring(0, 500));
|
||||||
if (mode === 'updates') {
|
if (mode === 'updates') {
|
||||||
hasEmittedNodes = true;
|
hasEmittedNodes = true;
|
||||||
const node = Object.keys(data)[0];
|
const node = Object.keys(data)[0];
|
||||||
@@ -962,17 +1107,17 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
|
|
||||||
// Skip interrupt nodes - they have no useful data
|
// Skip interrupt nodes - they have no useful data
|
||||||
if (node === '__interrupt__' || !updateData || Object.keys(updateData).length === 0) {
|
if (node === '__interrupt__' || !updateData || Object.keys(updateData).length === 0) {
|
||||||
console.log('[submitAnswerStream] Skipping empty interrupt node');
|
this.logger.debug('[submitAnswerStream] Skipping empty interrupt node');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[submitAnswerStream] Node update:', node, {
|
this.logger.debug('[submitAnswerStream] Node update:', node, {
|
||||||
hasMessages: !!updateData.messages,
|
hasMessages: !!updateData.messages,
|
||||||
messageCount: updateData.messages?.length,
|
messageCount: updateData.messages?.length,
|
||||||
currentIndex: updateData.currentQuestionIndex,
|
currentIndex: updateData.currentQuestionIndex,
|
||||||
dataKeys: Object.keys(updateData).join(',')
|
dataKeys: Object.keys(updateData).join(',')
|
||||||
});
|
});
|
||||||
console.log('[submitAnswerStream] Sending to frontend:', JSON.stringify(updateData).substring(0, 500));
|
this.logger.debug('[submitAnswerStream] Sending to frontend:', JSON.stringify(updateData).substring(0, 500));
|
||||||
if (updateData.messages) {
|
if (updateData.messages) {
|
||||||
updateData.messages = this.mapMessages(updateData.messages);
|
updateData.messages = this.mapMessages(updateData.messages);
|
||||||
}
|
}
|
||||||
@@ -983,7 +1128,7 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
}
|
}
|
||||||
observer.next({ type: 'node', node, data: updateData });
|
observer.next({ type: 'node', node, data: updateData });
|
||||||
} else if (mode === 'values') {
|
} else if (mode === 'values') {
|
||||||
console.log('[submitAnswerStream] Values update - keys:', Object.keys(data || {}));
|
this.logger.debug('[submitAnswerStream] Values update - keys:', Object.keys(data || {}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -994,13 +1139,13 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
const finalData = fullState.values as EvaluationState;
|
const finalData = fullState.values as EvaluationState;
|
||||||
|
|
||||||
// Force emit the next question if stream didn't emit updates (hasEmittedNodes is false)
|
// Force emit the next question if stream didn't emit updates (hasEmittedNodes is false)
|
||||||
console.log('[submitAnswerStream] Force check:', { hasEmittedNodes, hasFinalData: !!finalData, hasQuestions: !!finalData?.questions, qLen: finalData?.questions?.length, emittedNextQuestion });
|
this.logger.debug('[submitAnswerStream] Force check:', { hasEmittedNodes, hasFinalData: !!finalData, hasQuestions: !!finalData?.questions, qLen: finalData?.questions?.length, emittedNextQuestion });
|
||||||
if (!hasEmittedNodes && finalData && finalData.questions && finalData.questions.length > 0 && !emittedNextQuestion) {
|
if (!hasEmittedNodes && finalData && finalData.questions && finalData.questions.length > 0 && !emittedNextQuestion) {
|
||||||
const currentIndex = finalData.currentQuestionIndex || 0;
|
const currentIndex = finalData.currentQuestionIndex || 0;
|
||||||
const nextQuestion = finalData.questions[currentIndex];
|
const nextQuestion = finalData.questions[currentIndex];
|
||||||
if (nextQuestion) {
|
if (nextQuestion) {
|
||||||
const questionText = nextQuestion.questionText || '';
|
const questionText = nextQuestion.questionText || '';
|
||||||
console.log('[submitAnswerStream] Forcing emit next question:', {
|
this.logger.debug('[submitAnswerStream] Forcing emit next question:', {
|
||||||
currentIndex,
|
currentIndex,
|
||||||
questionPreview: questionText.substring(0, 50)
|
questionPreview: questionText.substring(0, 50)
|
||||||
});
|
});
|
||||||
@@ -1020,7 +1165,7 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (finalData && finalData.messages) {
|
if (finalData && finalData.messages) {
|
||||||
console.log(
|
this.logger.debug(
|
||||||
`[AssessmentService] submitAnswerStream Final Authoritative State messages:`,
|
`[AssessmentService] submitAnswerStream Final Authoritative State messages:`,
|
||||||
finalData.messages.length,
|
finalData.messages.length,
|
||||||
);
|
);
|
||||||
@@ -1036,7 +1181,7 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
const scores = finalData.scores;
|
const scores = finalData.scores;
|
||||||
const questions = finalData.questions || [];
|
const questions = finalData.questions || [];
|
||||||
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
|
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
|
||||||
const passingScore = session.templateJson?.passingScore || 90;
|
const passingScore = (session.templateJson?.passingScore ?? 60) / 10;
|
||||||
|
|
||||||
if (questions.length > 0 && Object.keys(scores).length > 0) {
|
if (questions.length > 0 && Object.keys(scores).length > 0) {
|
||||||
const { finalScore, dimensionScores, radarData } = this.calculateScores(
|
const { finalScore, dimensionScores, radarData } = this.calculateScores(
|
||||||
@@ -1048,6 +1193,7 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
(session as any).dimensionScores = dimensionScores;
|
(session as any).dimensionScores = dimensionScores;
|
||||||
(session as any).radarData = radarData;
|
(session as any).radarData = radarData;
|
||||||
(session as any).passed = finalScore >= passingScore;
|
(session as any).passed = finalScore >= passingScore;
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[DimensionScoring] Session ${sessionId} Final Score: ${finalScore}, Passed: ${finalScore >= passingScore}`,
|
`[DimensionScoring] Session ${sessionId} Final Score: ${finalScore}, Passed: ${finalScore >= passingScore}`,
|
||||||
);
|
);
|
||||||
@@ -1055,13 +1201,18 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
}
|
}
|
||||||
await this.sessionRepository.save(session);
|
await this.sessionRepository.save(session);
|
||||||
|
|
||||||
const mappedData: any = { ...finalData };
|
const mappedData: any = this.sanitizeStateForClient(
|
||||||
|
{ ...finalData },
|
||||||
|
session.status !== AssessmentStatus.COMPLETED,
|
||||||
|
);
|
||||||
mappedData.messages = this.mapMessages(finalData.messages);
|
mappedData.messages = this.mapMessages(finalData.messages);
|
||||||
mappedData.feedbackHistory = this.mapMessages(
|
mappedData.feedbackHistory = this.mapMessages(
|
||||||
finalData.feedbackHistory || [],
|
finalData.feedbackHistory || [],
|
||||||
);
|
);
|
||||||
mappedData.status = session.status;
|
mappedData.status = session.status;
|
||||||
mappedData.report = session.finalReport;
|
mappedData.report = session.finalReport;
|
||||||
|
mappedData.finalScore = session.finalScore;
|
||||||
|
mappedData.passed = (session as any).passed;
|
||||||
observer.next({ type: 'final', data: mappedData });
|
observer.next({ type: 'final', data: mappedData });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1101,7 +1252,10 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
values.feedbackHistory = this.mapMessages(values.feedbackHistory);
|
values.feedbackHistory = this.mapMessages(values.feedbackHistory);
|
||||||
}
|
}
|
||||||
|
|
||||||
return values;
|
return this.sanitizeStateForClient(
|
||||||
|
values,
|
||||||
|
session.status !== AssessmentStatus.COMPLETED,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1138,16 +1292,25 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
const userId = user.id;
|
const userId = user.id;
|
||||||
const isAdmin = user.role === 'super_admin' || user.role === 'admin';
|
const isAdmin = user.role === 'super_admin' || user.role === 'admin';
|
||||||
|
|
||||||
const deleteCondition: any = { id: sessionId };
|
await this.dataSource.transaction(async (manager) => {
|
||||||
if (!isAdmin) {
|
const deleteCondition: any = { id: sessionId };
|
||||||
deleteCondition.userId = userId;
|
if (!isAdmin) {
|
||||||
}
|
deleteCondition.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.sessionRepository.delete(deleteCondition);
|
const session = await manager.findOne(AssessmentSession, { where: deleteCondition });
|
||||||
if (result.affected === 0) {
|
if (!session) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException('Session not found or you do not have permission to delete it');
|
||||||
'Session not found or you do not have permission to delete it',
|
}
|
||||||
);
|
|
||||||
|
await manager.delete(AssessmentCertificate, { sessionId });
|
||||||
|
await manager.delete(AssessmentSession, { id: sessionId });
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.graph.getState({ configurable: { thread_id: sessionId } });
|
||||||
|
} catch {
|
||||||
|
this.logger.debug(`[deleteSession] No graph state to clean up for ${sessionId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,70 +1341,73 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
const historicalMessages = this.hydrateMessages(session.messages);
|
const historicalMessages = this.hydrateMessages(session.messages);
|
||||||
const existingQuestions = session.questions_json || [];
|
const existingQuestions = session.questions_json || [];
|
||||||
const hasQuestionsFromBank = existingQuestions.length > 0;
|
const hasQuestionsFromBank = existingQuestions.length > 0;
|
||||||
|
const scoresRecord: Record<string, number> = {};
|
||||||
|
if (session.feedbackHistory) {
|
||||||
|
for (const fh of session.feedbackHistory) {
|
||||||
|
if (fh.score && fh.questionId) scoresRecord[fh.questionId] = fh.score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recoveredState: any = {
|
||||||
|
assessmentSessionId: sessionId,
|
||||||
|
knowledgeBaseId:
|
||||||
|
session.knowledgeBaseId || session.knowledgeGroupId || '',
|
||||||
|
messages: historicalMessages,
|
||||||
|
feedbackHistory: this.hydrateMessages(
|
||||||
|
session.feedbackHistory || [],
|
||||||
|
),
|
||||||
|
questions: existingQuestions,
|
||||||
|
currentQuestionIndex: session.currentQuestionIndex || 0,
|
||||||
|
followUpCount: session.followUpCount || 0,
|
||||||
|
shouldFollowUp: false,
|
||||||
|
scores: scoresRecord,
|
||||||
|
questionCount: session.templateJson?.questionCount || 5,
|
||||||
|
difficultyDistribution:
|
||||||
|
session.templateJson?.difficultyDistribution,
|
||||||
|
style: session.templateJson?.style,
|
||||||
|
keywords: session.templateJson?.keywords,
|
||||||
|
questionAnswerKey: session.templateJson?.questionAnswerKey,
|
||||||
|
language: session.language || 'zh',
|
||||||
|
report: session.finalReport || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
if (hasQuestionsFromBank) {
|
if (hasQuestionsFromBank) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[ensureGraphState] Using ${existingQuestions.length} questions from question bank`,
|
`[ensureGraphState] Using ${existingQuestions.length} questions from question bank`,
|
||||||
);
|
);
|
||||||
await this.graph.updateState(
|
|
||||||
{ configurable: { thread_id: sessionId } },
|
|
||||||
{
|
|
||||||
assessmentSessionId: sessionId,
|
|
||||||
knowledgeBaseId:
|
|
||||||
session.knowledgeBaseId || session.knowledgeGroupId || '',
|
|
||||||
messages: historicalMessages,
|
|
||||||
feedbackHistory: this.hydrateMessages(
|
|
||||||
session.feedbackHistory || [],
|
|
||||||
),
|
|
||||||
questions: existingQuestions,
|
|
||||||
currentQuestionIndex: session.currentQuestionIndex || 0,
|
|
||||||
followUpCount: session.followUpCount || 0,
|
|
||||||
questionCount: session.templateJson?.questionCount || 5,
|
|
||||||
difficultyDistribution:
|
|
||||||
session.templateJson?.difficultyDistribution,
|
|
||||||
style: session.templateJson?.style,
|
|
||||||
keywords: session.templateJson?.keywords,
|
|
||||||
},
|
|
||||||
'grader',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await this.graph.updateState(
|
|
||||||
{ configurable: { thread_id: sessionId } },
|
|
||||||
{
|
|
||||||
assessmentSessionId: sessionId,
|
|
||||||
knowledgeBaseId:
|
|
||||||
session.knowledgeBaseId || session.knowledgeGroupId || '',
|
|
||||||
messages: historicalMessages,
|
|
||||||
feedbackHistory: this.hydrateMessages(
|
|
||||||
session.feedbackHistory || [],
|
|
||||||
),
|
|
||||||
questions: session.questions_json || [],
|
|
||||||
currentQuestionIndex: session.currentQuestionIndex || 0,
|
|
||||||
followUpCount: session.followUpCount || 0,
|
|
||||||
questionCount: session.templateJson?.questionCount || 5,
|
|
||||||
difficultyDistribution:
|
|
||||||
session.templateJson?.difficultyDistribution,
|
|
||||||
style: session.templateJson?.style,
|
|
||||||
keywords: session.templateJson?.keywords,
|
|
||||||
},
|
|
||||||
'grader',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.graph.updateState(
|
||||||
|
{ configurable: { thread_id: sessionId } },
|
||||||
|
recoveredState,
|
||||||
|
'interviewer',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.log(`Initializing new state for session ${sessionId}`);
|
this.logger.log(`Initializing new state for session ${sessionId}`);
|
||||||
const content = await this.getSessionContent(session);
|
const content = await this.getSessionContent(session);
|
||||||
const model = await this.getModel(session.tenantId);
|
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> = {
|
const initialState: Partial<EvaluationState> = {
|
||||||
assessmentSessionId: sessionId,
|
assessmentSessionId: sessionId,
|
||||||
knowledgeBaseId:
|
knowledgeBaseId:
|
||||||
session.knowledgeBaseId || session.knowledgeGroupId || '',
|
session.knowledgeBaseId || session.knowledgeGroupId || '',
|
||||||
messages: [],
|
messages: hasQuestionsFromBank
|
||||||
|
? [new HumanMessage(
|
||||||
|
isZh ? '我已准备好回答问题。' : isJa ? '質問への回答準備ができています。' : 'I am ready to answer the questions.',
|
||||||
|
)]
|
||||||
|
: [],
|
||||||
questionCount: session.templateJson?.questionCount,
|
questionCount: session.templateJson?.questionCount,
|
||||||
difficultyDistribution: session.templateJson?.difficultyDistribution,
|
difficultyDistribution: session.templateJson?.difficultyDistribution,
|
||||||
style: session.templateJson?.style,
|
style: session.templateJson?.style,
|
||||||
keywords: session.templateJson?.keywords,
|
keywords: session.templateJson?.keywords,
|
||||||
|
questionAnswerKey: session.templateJson?.questionAnswerKey,
|
||||||
language: session.language || 'en',
|
language: session.language || 'en',
|
||||||
|
questions: hasQuestionsFromBank ? existingQuestions : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -1305,6 +1471,27 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips sensitive fields before sending state to frontend.
|
||||||
|
*/
|
||||||
|
private sanitizeStateForClient(data: any, stripAnswers = true): any {
|
||||||
|
if (!data) return data;
|
||||||
|
const sanitized = { ...data };
|
||||||
|
if (stripAnswers) {
|
||||||
|
delete sanitized.questionAnswerKey;
|
||||||
|
}
|
||||||
|
if (Array.isArray(sanitized.questions)) {
|
||||||
|
sanitized.questions = sanitized.questions.map((q: any) => {
|
||||||
|
if (stripAnswers) {
|
||||||
|
const { correctAnswer, judgment, followupHints, ...rest } = q;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
return q;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps LangChain messages to a simple format for the frontend and storage.
|
* Maps LangChain messages to a simple format for the frontend and storage.
|
||||||
*/
|
*/
|
||||||
@@ -1340,7 +1527,7 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (session.status !== AssessmentStatus.COMPLETED) {
|
if (session.status !== AssessmentStatus.COMPLETED) {
|
||||||
throw new Error('Session not completed');
|
throw new BadRequestException('Session not completed yet');
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await this.certificateRepository.findOne({
|
const existing = await this.certificateRepository.findOne({
|
||||||
@@ -1350,9 +1537,17 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
return existing;
|
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 qrCode = `cert://${sessionId}-${Date.now()}`;
|
||||||
|
|
||||||
|
const questionDetails = (session.questions_json || []).map((q: any, i: number) => ({
|
||||||
|
index: i + 1,
|
||||||
|
questionText: q.questionText?.substring(0, 100) || '',
|
||||||
|
questionType: q.questionType || 'SHORT_ANSWER',
|
||||||
|
dimension: q.dimension || '',
|
||||||
|
}));
|
||||||
|
|
||||||
const certificate = this.certificateRepository.create({
|
const certificate = this.certificateRepository.create({
|
||||||
userId,
|
userId,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -1365,14 +1560,20 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
passed: (session as any).passed || false,
|
passed: (session as any).passed || false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.certificateRepository.save(certificate);
|
const saved = await this.certificateRepository.save(certificate);
|
||||||
|
return {
|
||||||
|
...saved,
|
||||||
|
templateName: session.template?.name || session.templateJson?.name || '-',
|
||||||
|
userName: session.user?.displayName || session.user?.username || '',
|
||||||
|
questionDetails,
|
||||||
|
} as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
private determineLevel(score: number): string {
|
private determineLevel(score: number, passed: boolean, passingThreshold: number): string {
|
||||||
if (score >= 90) return 'Expert';
|
if (!passed) return 'Novice';
|
||||||
if (score >= 75) return 'Advanced';
|
if (score >= 9) return 'Expert';
|
||||||
if (score >= 60) return 'Proficient';
|
if (score >= 7) return 'Advanced';
|
||||||
return 'Novice';
|
return 'Proficient';
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStats(
|
async getStats(
|
||||||
@@ -1464,19 +1665,15 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
|
|
||||||
const sessions = await qb.take(100).getMany();
|
const sessions = await qb.take(100).getMany();
|
||||||
|
|
||||||
const dimensionScores: Record<string, number[]> = {
|
const dimensionScores: Record<string, number[]> = {};
|
||||||
PROMPT: [],
|
|
||||||
LLM: [],
|
|
||||||
IDE: [],
|
|
||||||
DEV_PATTERN: [],
|
|
||||||
WORK_CAPABILITY: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
const messages = session.messages || [];
|
const scores = (session as any).dimensionScores || {};
|
||||||
for (const msg of messages) {
|
for (const [dim, score] of Object.entries(scores)) {
|
||||||
if (msg.dimension && msg.score !== undefined) {
|
if (dimensionScores[dim]) {
|
||||||
dimensionScores[msg.dimension]?.push(msg.score);
|
dimensionScores[dim].push(score as number);
|
||||||
|
} else {
|
||||||
|
dimensionScores[dim] = [score as number];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1531,48 +1728,50 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
reviewerId: string,
|
reviewerId: string,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
): Promise<AssessmentSession> {
|
): Promise<AssessmentSession> {
|
||||||
const session = await this.sessionRepository.findOne({
|
return this.dataSource.transaction(async (manager) => {
|
||||||
where: { id: sessionId },
|
const session = await manager.findOne(AssessmentSession, {
|
||||||
|
where: { id: sessionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new NotFoundException('Assessment session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status !== AssessmentStatus.COMPLETED) {
|
||||||
|
throw new ForbiddenException('Can only review completed assessments');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewRecord = {
|
||||||
|
reviewedBy: reviewerId,
|
||||||
|
reviewedAt: new Date().toISOString(),
|
||||||
|
originalScore: session.finalScore,
|
||||||
|
newScore: newScore,
|
||||||
|
comment: comment || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const reviewHistory = session.reviewHistory || [];
|
||||||
|
reviewHistory.push(reviewRecord);
|
||||||
|
|
||||||
|
if (!session.originalScore) {
|
||||||
|
session.originalScore = session.finalScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.finalScore = newScore;
|
||||||
|
const passingScore = (session.templateJson?.passingScore ?? 60) / 10;
|
||||||
|
(session as any).passed = newScore >= passingScore;
|
||||||
|
session.reviewedBy = reviewerId;
|
||||||
|
session.reviewedAt = new Date();
|
||||||
|
session.reviewComment = comment || null;
|
||||||
|
session.reviewHistory = reviewHistory;
|
||||||
|
|
||||||
|
await manager.save(session);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[reviewAssessment] Session ${sessionId} reviewed by ${reviewerId}, score changed from ${reviewRecord.originalScore} to ${newScore}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return session;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
throw new NotFoundException('Assessment session not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.status !== AssessmentStatus.COMPLETED) {
|
|
||||||
throw new ForbiddenException('Can only review completed assessments');
|
|
||||||
}
|
|
||||||
|
|
||||||
const reviewRecord = {
|
|
||||||
reviewedBy: reviewerId,
|
|
||||||
reviewedAt: new Date().toISOString(),
|
|
||||||
originalScore: session.finalScore,
|
|
||||||
newScore: newScore,
|
|
||||||
comment: comment || '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const reviewHistory = session.reviewHistory || [];
|
|
||||||
reviewHistory.push(reviewRecord);
|
|
||||||
|
|
||||||
if (!session.originalScore) {
|
|
||||||
session.originalScore = session.finalScore;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.finalScore = newScore;
|
|
||||||
const passingScore = session.templateJson?.passingScore || 90;
|
|
||||||
(session as any).passed = newScore >= passingScore;
|
|
||||||
session.reviewedBy = reviewerId;
|
|
||||||
session.reviewedAt = new Date();
|
|
||||||
session.reviewComment = comment || null;
|
|
||||||
session.reviewHistory = reviewHistory;
|
|
||||||
|
|
||||||
await this.sessionRepository.save(session);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`[reviewAssessment] Session ${sessionId} reviewed by ${reviewerId}, score changed from ${reviewRecord.originalScore} to ${newScore}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return session;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserHistory(userId: string): Promise<AssessmentSession[]> {
|
async getUserHistory(userId: string): Promise<AssessmentSession[]> {
|
||||||
@@ -1655,7 +1854,6 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
totalScore: number;
|
totalScore: number;
|
||||||
passed: boolean;
|
passed: boolean;
|
||||||
issuedAt: Date;
|
issuedAt: Date;
|
||||||
userId: string;
|
|
||||||
};
|
};
|
||||||
message?: string;
|
message?: string;
|
||||||
}> {
|
}> {
|
||||||
@@ -1676,7 +1874,6 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
totalScore: certificate.totalScore,
|
totalScore: certificate.totalScore,
|
||||||
passed: certificate.passed,
|
passed: certificate.passed,
|
||||||
issuedAt: certificate.issuedAt,
|
issuedAt: certificate.issuedAt,
|
||||||
userId: certificate.userId,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1712,6 +1909,45 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async batchDeleteSessions(ids: string[], user: any): Promise<number> {
|
||||||
|
const isAdmin = user.role === 'super_admin' || user.role === 'admin';
|
||||||
|
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const query: any = { id: In(ids) };
|
||||||
|
if (!isAdmin) {
|
||||||
|
query.userId = user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = await manager.find(AssessmentSession, { where: query });
|
||||||
|
const sessionIds = sessions.map((s) => s.id);
|
||||||
|
|
||||||
|
if (sessionIds.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.delete(AssessmentCertificate, { sessionId: In(sessionIds) });
|
||||||
|
const result = await manager.delete(AssessmentSession, { id: In(sessionIds) });
|
||||||
|
this.logger.log(`[batchDeleteSessions] Deleted ${sessionIds.length} sessions`);
|
||||||
|
return result.affected || 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchExportSessions(ids: string[], userId: string): Promise<any[]> {
|
||||||
|
const sessions = await this.sessionRepository.find({
|
||||||
|
where: { id: In(ids), userId },
|
||||||
|
relations: ['questions'],
|
||||||
|
});
|
||||||
|
return sessions.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
status: s.status,
|
||||||
|
finalScore: s.finalScore,
|
||||||
|
startedAt: s.startedAt,
|
||||||
|
createdAt: s.createdAt,
|
||||||
|
totalTimeLimit: s.totalTimeLimit,
|
||||||
|
questionCount: s.questions?.length || 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async forceEndAssessment(sessionId: string): Promise<AssessmentSession> {
|
async forceEndAssessment(sessionId: string): Promise<AssessmentSession> {
|
||||||
const session = await this.sessionRepository.findOne({
|
const session = await this.sessionRepository.findOne({
|
||||||
where: { id: sessionId },
|
where: { id: sessionId },
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
ReviewDto,
|
ReviewDto,
|
||||||
} from '../services/question-bank.service';
|
} from '../services/question-bank.service';
|
||||||
import { CombinedAuthGuard } from '../../auth/combined-auth.guard';
|
import { CombinedAuthGuard } from '../../auth/combined-auth.guard';
|
||||||
|
import { KnowledgeGroupService } from '../../knowledge-group/knowledge-group.service';
|
||||||
|
|
||||||
@Controller('question-banks')
|
@Controller('question-banks')
|
||||||
@UseGuards(CombinedAuthGuard)
|
@UseGuards(CombinedAuthGuard)
|
||||||
@@ -29,12 +30,20 @@ import { CombinedAuthGuard } from '../../auth/combined-auth.guard';
|
|||||||
export class QuestionBankController {
|
export class QuestionBankController {
|
||||||
private readonly logger = new Logger(QuestionBankController.name);
|
private readonly logger = new Logger(QuestionBankController.name);
|
||||||
|
|
||||||
constructor(private readonly questionBankService: QuestionBankService) {}
|
constructor(
|
||||||
|
private readonly questionBankService: QuestionBankService,
|
||||||
|
private readonly groupService: KnowledgeGroupService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() createDto: CreateQuestionBankDto, @Req() req: any) {
|
async create(@Body() createDto: CreateQuestionBankDto, @Req() req: any) {
|
||||||
this.logger.log(`Creating question bank: ${createDto.name}`);
|
try {
|
||||||
return this.questionBankService.create(createDto, req.user.id, req.user.tenantId);
|
this.logger.log(`Creating question bank: ${createDto.name}, user: ${req.user?.id}, tenant: ${req.user?.tenantId}`);
|
||||||
|
return await this.questionBankService.create(createDto, req.user.id, req.user.tenantId);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[create] Failed: ${err.message}`, err.stack);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -125,11 +134,32 @@ export class QuestionBankController {
|
|||||||
@Body() body: { count: number; knowledgeBaseContent?: string },
|
@Body() body: { count: number; knowledgeBaseContent?: string },
|
||||||
@Req() req: any,
|
@Req() req: any,
|
||||||
) {
|
) {
|
||||||
this.logger.log(`[generate] Generating ${body.count} questions for bank ${bankId}`);
|
let content = body.knowledgeBaseContent || '';
|
||||||
|
if (!content || content.trim().length < 10) {
|
||||||
|
try {
|
||||||
|
const bank = await this.questionBankService.findOne(bankId);
|
||||||
|
if (bank?.template?.knowledgeGroupId) {
|
||||||
|
const files = await this.groupService.getGroupFiles(
|
||||||
|
bank.template.knowledgeGroupId,
|
||||||
|
req.user.id,
|
||||||
|
req.user.tenantId,
|
||||||
|
);
|
||||||
|
content = files
|
||||||
|
.filter((f: any) => f.content && f.content.trim().length > 0)
|
||||||
|
.map((f: any) => `--- ${f.title || f.originalName || 'Document'} ---\n${f.content}`)
|
||||||
|
.join('\n\n');
|
||||||
|
this.logger.log(`[generate] Auto-loaded ${files.length} files, content length: ${content.length}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[generate] Auto-load failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[generate] Generating ${body.count} questions for bank ${bankId}, content length: ${content.length}`);
|
||||||
return this.questionBankService.generateQuestions(
|
return this.questionBankService.generateQuestions(
|
||||||
bankId,
|
bankId,
|
||||||
body.count,
|
body.count,
|
||||||
body.knowledgeBaseContent || '',
|
content,
|
||||||
req.user.tenantId,
|
req.user.tenantId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Max,
|
Max,
|
||||||
IsObject,
|
IsObject,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateTemplateDto {
|
export class CreateTemplateDto {
|
||||||
@@ -59,6 +60,11 @@ export class CreateTemplateDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
linkedGroupIds?: string[];
|
linkedGroupIds?: string[];
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsObject({ each: true })
|
||||||
|
@IsOptional()
|
||||||
|
dimensions?: Array<{ name: string; label: string; weight: number }>;
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
weightConfig?: {
|
weightConfig?: {
|
||||||
@@ -91,4 +97,14 @@ export class CreateTemplateDto {
|
|||||||
@Max(100)
|
@Max(100)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
passingScore?: number;
|
passingScore?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(60)
|
||||||
|
@Max(86400)
|
||||||
|
totalTimeLimit?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(30)
|
||||||
|
@Max(3600)
|
||||||
|
perQuestionTimeLimit?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ export class AssessmentSession {
|
|||||||
@Column({ type: 'float', name: 'original_score', nullable: true })
|
@Column({ type: 'float', name: 'original_score', nullable: true })
|
||||||
originalScore: number;
|
originalScore: number;
|
||||||
|
|
||||||
|
@Column({ type: 'simple-json', nullable: true, name: 'dimension_scores' })
|
||||||
|
dimensionScores: Record<string, number>;
|
||||||
|
|
||||||
|
@Column({ type: 'simple-json', nullable: true, name: 'radar_data' })
|
||||||
|
radarData: any;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
passed: boolean;
|
||||||
|
|
||||||
@Column({ type: 'text', name: 'final_report', nullable: true })
|
@Column({ type: 'text', name: 'final_report', nullable: true })
|
||||||
finalReport: string;
|
finalReport: string;
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ export class AssessmentTemplate {
|
|||||||
@JoinColumn({ name: 'knowledge_group_id' })
|
@JoinColumn({ name: 'knowledge_group_id' })
|
||||||
knowledgeGroup: KnowledgeGroup;
|
knowledgeGroup: KnowledgeGroup;
|
||||||
|
|
||||||
|
@Column({ type: 'simple-json', name: 'dimensions', nullable: true })
|
||||||
|
dimensions: Array<{ name: string; label: string; weight: number }>;
|
||||||
|
|
||||||
@Column({ type: 'boolean', name: 'is_active', default: true })
|
@Column({ type: 'boolean', name: 'is_active', default: true })
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
|
||||||
@@ -94,7 +97,7 @@ export class AssessmentTemplate {
|
|||||||
@Column({ type: 'int', name: 'question_count_max', default: 10 })
|
@Column({ type: 'int', name: 'question_count_max', default: 10 })
|
||||||
questionCountMax: number;
|
questionCountMax: number;
|
||||||
|
|
||||||
@Column({ type: 'int', name: 'passing_score', default: 90 })
|
@Column({ type: 'int', name: 'passing_score', default: 60 })
|
||||||
passingScore: number;
|
passingScore: number;
|
||||||
|
|
||||||
@Column({ type: 'int', name: 'total_time_limit', default: 1800 })
|
@Column({ type: 'int', name: 'total_time_limit', default: 1800 })
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('audit_logs')
|
||||||
|
export class AuditLog {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_id', type: 'text' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', nullable: true, type: 'text' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50 })
|
||||||
|
action: string;
|
||||||
|
|
||||||
|
@Column({ name: 'resource_type', type: 'varchar', length: 50 })
|
||||||
|
resourceType: string;
|
||||||
|
|
||||||
|
@Column({ name: 'resource_id', nullable: true, type: 'text' })
|
||||||
|
resourceId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'simple-json', nullable: true })
|
||||||
|
details: any;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { QuestionBankItem, QuestionType, QuestionDifficulty, QuestionDimension, QuestionBankItemStatus } from './question-bank-item.entity';
|
||||||
|
|
||||||
|
describe('QuestionBankItem entity', () => {
|
||||||
|
describe('existing fields', () => {
|
||||||
|
it('should create an instance with default questionType', () => {
|
||||||
|
const item = new QuestionBankItem();
|
||||||
|
expect(item.questionType).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set and get basic fields', () => {
|
||||||
|
const item = new QuestionBankItem();
|
||||||
|
item.questionText = '【场景】你在编写代码... 【问题】请描述你会如何处理';
|
||||||
|
item.questionType = QuestionType.SHORT_ANSWER;
|
||||||
|
item.options = null;
|
||||||
|
item.correctAnswer = null;
|
||||||
|
item.keyPoints = ['规范文档化', '源头统一'];
|
||||||
|
item.difficulty = QuestionDifficulty.STANDARD;
|
||||||
|
item.dimension = QuestionDimension.PROMPT;
|
||||||
|
item.basis = '知识库原文依据';
|
||||||
|
item.status = QuestionBankItemStatus.PENDING_REVIEW;
|
||||||
|
|
||||||
|
expect(item.questionText).toBe('【场景】你在编写代码... 【问题】请描述你会如何处理');
|
||||||
|
expect(item.questionType).toBe(QuestionType.SHORT_ANSWER);
|
||||||
|
expect(item.options).toBeNull();
|
||||||
|
expect(item.correctAnswer).toBeNull();
|
||||||
|
expect(item.keyPoints).toEqual(['规范文档化', '源头统一']);
|
||||||
|
expect(item.difficulty).toBe(QuestionDifficulty.STANDARD);
|
||||||
|
expect(item.dimension).toBe(QuestionDimension.PROMPT);
|
||||||
|
expect(item.basis).toBe('知识库原文依据');
|
||||||
|
expect(item.status).toBe(QuestionBankItemStatus.PENDING_REVIEW);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('judgment field', () => {
|
||||||
|
it('should accept judgment text for choice question', () => {
|
||||||
|
const item = new QuestionBankItem();
|
||||||
|
item.judgment = 'B正确,因为提供了具体约束和角色设定。A错误在于过于笼统。C错误在于过度细节但缺乏核心约束。D错误在于错误建议。';
|
||||||
|
expect(item.judgment).toBe('B正确,因为提供了具体约束和角色设定。A错误在于过于笼统。C错误在于过度细节但缺乏核心约束。D错误在于错误建议。');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept judgment text for open question', () => {
|
||||||
|
const item = new QuestionBankItem();
|
||||||
|
item.judgment = '关键考点:会话管理——长对话导致上下文窗口膨胀 通过标准:说出"让AI总结之前内容+开新窗口"即通过';
|
||||||
|
expect(item.judgment).toContain('通过标准');
|
||||||
|
expect(item.judgment).toContain('会话管理');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow null judgment', () => {
|
||||||
|
const item = new QuestionBankItem();
|
||||||
|
item.judgment = null;
|
||||||
|
expect(item.judgment).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('followupHints field', () => {
|
||||||
|
it('should accept array of followup hints', () => {
|
||||||
|
const item = new QuestionBankItem();
|
||||||
|
item.followupHints = [
|
||||||
|
'如果只回答"开新窗口"没说怎么带上前情:追问"开新窗口后之前讨论的结论不就丢了吗?怎么把有用信息带过去?"',
|
||||||
|
'如果内容不完整:追问"还有没有更好的办法?"',
|
||||||
|
];
|
||||||
|
expect(item.followupHints).toHaveLength(2);
|
||||||
|
expect(item.followupHints[0]).toContain('开新窗口');
|
||||||
|
expect(item.followupHints[1]).toContain('更好的办法');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept single followup hint', () => {
|
||||||
|
const item = new QuestionBankItem();
|
||||||
|
item.followupHints = ['追问如何保留之前结论'];
|
||||||
|
expect(item.followupHints).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept empty array', () => {
|
||||||
|
const item = new QuestionBankItem();
|
||||||
|
item.followupHints = [];
|
||||||
|
expect(item.followupHints).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow null followupHints', () => {
|
||||||
|
const item = new QuestionBankItem();
|
||||||
|
item.followupHints = null;
|
||||||
|
expect(item.followupHints).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,6 +56,7 @@ export class QuestionBankItem {
|
|||||||
@Column({
|
@Column({
|
||||||
type: 'simple-enum',
|
type: 'simple-enum',
|
||||||
enum: QuestionType,
|
enum: QuestionType,
|
||||||
|
default: QuestionType.SHORT_ANSWER,
|
||||||
})
|
})
|
||||||
questionType: QuestionType;
|
questionType: QuestionType;
|
||||||
|
|
||||||
@@ -71,24 +72,33 @@ export class QuestionBankItem {
|
|||||||
@Column({
|
@Column({
|
||||||
type: 'simple-enum',
|
type: 'simple-enum',
|
||||||
enum: QuestionDifficulty,
|
enum: QuestionDifficulty,
|
||||||
|
default: QuestionDifficulty.STANDARD,
|
||||||
})
|
})
|
||||||
difficulty: QuestionDifficulty;
|
difficulty: QuestionDifficulty;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'simple-enum',
|
type: 'simple-enum',
|
||||||
enum: QuestionDimension,
|
enum: QuestionDimension,
|
||||||
|
default: QuestionDimension.PROMPT,
|
||||||
})
|
})
|
||||||
dimension: QuestionDimension;
|
dimension: QuestionDimension;
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
basis: string | null;
|
basis: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
judgment: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'simple-json', nullable: true, name: 'followup_hints' })
|
||||||
|
followupHints: string[] | null;
|
||||||
|
|
||||||
@Column({ name: 'created_by', nullable: true, type: 'text' })
|
@Column({ name: 'created_by', nullable: true, type: 'text' })
|
||||||
createdBy: string | null;
|
createdBy: string | null;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'simple-enum',
|
type: 'simple-enum',
|
||||||
enum: QuestionBankItemStatus,
|
enum: QuestionBankItemStatus,
|
||||||
|
default: QuestionBankItemStatus.PENDING_REVIEW,
|
||||||
})
|
})
|
||||||
status: QuestionBankItemStatus;
|
status: QuestionBankItemStatus;
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export class QuestionBank {
|
|||||||
@Column({ name: 'template_id', nullable: true })
|
@Column({ name: 'template_id', nullable: true })
|
||||||
templateId: string | null;
|
templateId: string | null;
|
||||||
|
|
||||||
@OneToOne(() => AssessmentTemplate, { nullable: true })
|
@OneToOne(() => AssessmentTemplate, { nullable: true, onDelete: 'SET NULL' })
|
||||||
@JoinColumn({ name: 'template_id' })
|
@JoinColumn({ name: 'template_id' })
|
||||||
template: AssessmentTemplate;
|
template: AssessmentTemplate;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { routeAfterGrading } from './builder';
|
||||||
|
|
||||||
|
describe('routeAfterGrading', () => {
|
||||||
|
it('should route to interviewer when shouldFollowUp is true (overrides all other logic)', () => {
|
||||||
|
const result = routeAfterGrading({
|
||||||
|
shouldFollowUp: true,
|
||||||
|
currentQuestionIndex: 0,
|
||||||
|
questionCount: 5,
|
||||||
|
questions: [],
|
||||||
|
} as any);
|
||||||
|
expect(result).toBe('interviewer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should route to generator when currentIndex >= questionsLen and currentIndex < targetCount', () => {
|
||||||
|
const result = routeAfterGrading({
|
||||||
|
shouldFollowUp: false,
|
||||||
|
currentQuestionIndex: 3,
|
||||||
|
questionCount: 5,
|
||||||
|
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }],
|
||||||
|
} as any);
|
||||||
|
expect(result).toBe('generator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should route to interviewer when currentIndex < questionsLen and currentIndex < targetCount', () => {
|
||||||
|
const result = routeAfterGrading({
|
||||||
|
shouldFollowUp: false,
|
||||||
|
currentQuestionIndex: 2,
|
||||||
|
questionCount: 5,
|
||||||
|
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }],
|
||||||
|
} as any);
|
||||||
|
expect(result).toBe('interviewer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should route to analyzer when currentIndex >= targetCount', () => {
|
||||||
|
const result = routeAfterGrading({
|
||||||
|
shouldFollowUp: false,
|
||||||
|
currentQuestionIndex: 5,
|
||||||
|
questionCount: 5,
|
||||||
|
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }],
|
||||||
|
} as any);
|
||||||
|
expect(result).toBe('analyzer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default targetCount of 5 when questionCount is undefined', () => {
|
||||||
|
const result = routeAfterGrading({
|
||||||
|
shouldFollowUp: false,
|
||||||
|
currentQuestionIndex: 4,
|
||||||
|
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }],
|
||||||
|
} as any);
|
||||||
|
expect(result).toBe('generator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default targetCount of 5 when questionCount is undefined and index 5 routes to analyzer', () => {
|
||||||
|
const result = routeAfterGrading({
|
||||||
|
shouldFollowUp: false,
|
||||||
|
currentQuestionIndex: 5,
|
||||||
|
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }],
|
||||||
|
} as any);
|
||||||
|
expect(result).toBe('analyzer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined questions gracefully (defaults to empty array)', () => {
|
||||||
|
const result = routeAfterGrading({
|
||||||
|
shouldFollowUp: false,
|
||||||
|
currentQuestionIndex: 0,
|
||||||
|
questionCount: 5,
|
||||||
|
} as any);
|
||||||
|
expect(result).toBe('generator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent negative currentQuestionIndex via Math.max(0)', () => {
|
||||||
|
const result = routeAfterGrading({
|
||||||
|
shouldFollowUp: false,
|
||||||
|
currentQuestionIndex: -1,
|
||||||
|
questionCount: 5,
|
||||||
|
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }],
|
||||||
|
} as any);
|
||||||
|
expect(result).toBe('interviewer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle completely empty state (no fields provided)', () => {
|
||||||
|
const result = routeAfterGrading({} as any);
|
||||||
|
expect(result).toBe('generator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should route to interviewer at last index before targetCount boundary', () => {
|
||||||
|
const result = routeAfterGrading({
|
||||||
|
shouldFollowUp: false,
|
||||||
|
currentQuestionIndex: 4,
|
||||||
|
questionCount: 5,
|
||||||
|
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }],
|
||||||
|
} as any);
|
||||||
|
expect(result).toBe('interviewer');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ import { reportAnalyzerNode } from './nodes/analyzer.node';
|
|||||||
/**
|
/**
|
||||||
* Conditional routing logic for the Grader node.
|
* Conditional routing logic for the Grader node.
|
||||||
*/
|
*/
|
||||||
const routeAfterGrading = (state: typeof EvaluationAnnotation.State) => {
|
export const routeAfterGrading = (state: typeof EvaluationAnnotation.State) => {
|
||||||
const targetCount = state.questionCount || 5;
|
const targetCount = state.questionCount || 5;
|
||||||
const questionsLen = state.questions?.length || 0;
|
const questionsLen = state.questions?.length || 0;
|
||||||
const currentIndex = Math.max(0, state.currentQuestionIndex || 0);
|
const currentIndex = Math.max(0, state.currentQuestionIndex || 0);
|
||||||
|
|||||||
@@ -56,7 +56,12 @@ const scoreSummary = Object.entries(scores)
|
|||||||
1. **你必须使用以下语言生成报告:中文 (Simplified Chinese)**。
|
1. **你必须使用以下语言生成报告:中文 (Simplified Chinese)**。
|
||||||
2. **严禁夹杂日文**。即使对话记录中包含日文,报告内容也必须全中文。
|
2. **严禁夹杂日文**。即使对话记录中包含日文,报告内容也必须全中文。
|
||||||
3. 报告的第一行必须严格遵守此格式:"LEVEL: [Novice/Proficient/Advanced/Expert]"。
|
3. 报告的第一行必须严格遵守此格式:"LEVEL: [Novice/Proficient/Advanced/Expert]"。
|
||||||
4. 必须保持客观。如果用户没有提供有效的回答或得分为 0,你必须将其识别为 'Novice',并明确指出他们尚未证明其掌握程度。
|
4. **等级判定必须遵循以下分数阈值**:
|
||||||
|
- 总体平均分 >= 9 → Expert(专家)
|
||||||
|
- 总体平均分 >= 7 → Advanced(高级)
|
||||||
|
- 已通过(有有效回答且得分 > 0)→ Proficient(熟练)
|
||||||
|
- 未通过(无有效回答或得分为 0)→ Novice(新手)
|
||||||
|
即使得分很高,也要确保等级与上述阈值匹配。不要随意提高或降低等级。
|
||||||
5. 不要虚构或幻想优点(如"潜力"或"好奇心"),如果用户明确表示"不知道"或未提供实质内容。
|
5. 不要虚构或幻想优点(如"潜力"或"好奇心"),如果用户明确表示"不知道"或未提供实质内容。
|
||||||
6. 专注于对话记录中已证明的事实。
|
6. 专注于对话记录中已证明的事实。
|
||||||
|
|
||||||
@@ -87,8 +92,13 @@ ${messages
|
|||||||
2. **中国語を混ぜないでください**。会話ログに中国語が含まれていても、レポートの内容はすべて日本語で記述してください。
|
2. **中国語を混ぜないでください**。会話ログに中国語が含まれていても、レポートの内容はすべて日本語で記述してください。
|
||||||
3. レポートの最初の行は, 必ず次の形式に従ってください:"LEVEL: [Novice/Proficient/Advanced/Expert]"。
|
3. レポートの最初の行は, 必ず次の形式に従ってください:"LEVEL: [Novice/Proficient/Advanced/Expert]"。
|
||||||
4. 客観的であること。ユーザーが有効な回答を提供しなかった場合、またはスコアが 0 の場合、'Novice' と判定し、習熟度が証明されていないことを明示してください。
|
4. 客観的であること。ユーザーが有効な回答を提供しなかった場合、またはスコアが 0 の場合、'Novice' と判定し、習熟度が証明されていないことを明示してください。
|
||||||
5. ユーザーが「わからない」と言ったり、内容を提供しなかった場合に、長所(「ポテンシャル」や「好奇心」など)を捏造しないでください。
|
5. **レベル判定は以下のスコアしきい値に従うこと**:
|
||||||
6. 会話ログで証明された事実に集中してください。
|
- 平均スコア >= 9 → Expert
|
||||||
|
- 平均スコア >= 7 → Advanced
|
||||||
|
- 合格(有効な回答がありスコア > 0)→ Proficient
|
||||||
|
- 不合格(有効な回答なし、またはスコア 0)→ Novice
|
||||||
|
6. ユーザーが「わからない」と言ったり、内容を提供しなかった場合に、長所(「ポテンシャル」や「好奇心」など)を捏造しないでください。
|
||||||
|
7. 会話ログで証明された事実に集中してください。
|
||||||
|
|
||||||
各ディメンションスコア:
|
各ディメンションスコア:
|
||||||
${dimensionAvg}
|
${dimensionAvg}
|
||||||
@@ -115,8 +125,13 @@ IMPORTANT:
|
|||||||
1. **You MUST generate the report strictly in English.**
|
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.
|
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.
|
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.
|
4. **Level assignment MUST follow these score thresholds**:
|
||||||
5. Focus on what was PROVEN in the conversation logs.
|
- 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:
|
DIMENSION SCORES:
|
||||||
${dimensionAvg}
|
${dimensionAvg}
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ export const questionGeneratorNode = async (
|
|||||||
targetCount: limitCount,
|
targetCount: limitCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const existingQuestions = state.questions || [];
|
||||||
|
|
||||||
|
// Early return if enough questions from bank (no LLM call needed)
|
||||||
|
if (existingQuestions.length >= limitCount) {
|
||||||
|
console.log('[GeneratorNode] Skipping generation - enough questions from bank:', existingQuestions.length);
|
||||||
|
return { questions: existingQuestions };
|
||||||
|
}
|
||||||
|
|
||||||
if (!model || !knowledgeBaseContent) {
|
if (!model || !knowledgeBaseContent) {
|
||||||
console.error('[GeneratorNode] Missing model or knowledgeBaseContent');
|
console.error('[GeneratorNode] Missing model or knowledgeBaseContent');
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -78,91 +86,165 @@ export const questionGeneratorNode = async (
|
|||||||
.map((r, i) => `${i + 1}. ${r}`)
|
.map((r, i) => `${i + 1}. ${r}`)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
const existingQuestions = state.questions || [];
|
|
||||||
|
|
||||||
if (existingQuestions.length >= limitCount) {
|
|
||||||
console.log('[GeneratorNode] Skipping generation - enough questions from bank:', existingQuestions.length);
|
|
||||||
return { questions: existingQuestions };
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingQuestionsText = existingQuestions
|
const existingQuestionsText = existingQuestions
|
||||||
.map((q, i) => `Q${i + 1}: ${q.questionText}`)
|
.map((q, i) => `Q${i + 1}: ${q.questionText}`)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
const systemPromptZh = `你是一位专业的知识评估专家。请根据提供的知识库片段生成 1 个唯一的测试题目。
|
const systemPromptZh = `你是一个出题工具。严格按以下规则生成题目。
|
||||||
|
|
||||||
### 强制性语言规则:
|
### 第一步:提取知识点
|
||||||
**必须使用中文 (Simplified Chinese) 进行回复**。即使知识库内容是英文或其他语言,问题(question_text)和关键点(key_points)也必须使用中文。
|
阅读下方 Human 消息中的【知识库内容】,逐条列出其中包含的所有可考核知识点。
|
||||||
|
每条以"知识点N:"开头,引用原文语句。
|
||||||
|
|
||||||
### 强制性多样性规则:
|
### 第二步:基于知识点出题
|
||||||
${rulesZh}
|
仅用第一步提取的知识点生成题目。必须引用知识点编号。
|
||||||
|
如果知识点数量不足(少于3个),输出空数组 [] 并停止。
|
||||||
|
|
||||||
### 禁止重复列表(已出过):
|
### 题型分配规则
|
||||||
${existingQuestionsText || '无'}
|
每生成 3 道题:
|
||||||
|
- 第1、4、7...道:选择题(MULTIPLE_CHOICE),占 1/3
|
||||||
|
- 第2、3、5、6...道:对话简答题(SHORT_ANSWER),占 2/3
|
||||||
|
严格按照这个顺序循环,不要自行调整比例。
|
||||||
|
|
||||||
### 任务:
|
### 出题范围限制
|
||||||
${hasKeywords ? `目标关键词:${keywordText}\n` : ''}出题风格:${style}
|
出题内容必须严格限制在知识库范围内。每道题必须有知识点编号引用。
|
||||||
难度:${difficultyText}
|
以下情况绝对禁止:
|
||||||
|
- 使用 LLM 自身知识编题
|
||||||
|
- 引用知识库中不存在的概念
|
||||||
|
- 题目内容超出知识库覆盖的主题
|
||||||
|
|
||||||
请以 JSON 数组格式返回 1 个问题:
|
### 选择题出题标准
|
||||||
[
|
- 必须是场景驱动:描述一个真实工作场景,让用户判断最佳做法
|
||||||
{
|
- 四个选项(A/B/C/D),只有一个正确,另外三个要有迷惑性
|
||||||
"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)は必ず日本語で回答してください。中国語が混ざらないように厳格に注意してください。
|
- 禁止出纯概念题(如"提示词六要素是什么")
|
||||||
|
- 禁止出需要记忆具体数据的题
|
||||||
|
- 禁止使用知识库之外的知识
|
||||||
|
- 禁止生成与知识库主题无关的题目
|
||||||
|
${existingQuestionsText ? `- 禁止与已出题目概念重复:${existingQuestionsText}` : ''}
|
||||||
|
|
||||||
### 多様性ルール:
|
### 输出格式(严格遵循)
|
||||||
${rulesJa}
|
选择题完整格式:
|
||||||
|
{
|
||||||
|
"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:参考来源"
|
||||||
|
}
|
||||||
|
|
||||||
### 作成済み問題リスト:
|
对话简答题完整格式:
|
||||||
${existingQuestionsText || 'なし'}
|
{
|
||||||
|
"question_type": "SHORT_ANSWER",
|
||||||
|
"question_text": "开放式场景问题,不超过120字",
|
||||||
|
"key_points": ["期望的回答方向", "2-3个"],
|
||||||
|
"difficulty": "STANDARD",
|
||||||
|
"dimension": "prompt",
|
||||||
|
"basis": "知识点N:参考来源"
|
||||||
|
}
|
||||||
|
|
||||||
### 任務:
|
### 输出要求
|
||||||
${hasKeywords ? `目標キーワード:${keywordText}\n` : ''}出題スタイル:${style}
|
- 只输出 JSON 数组,不要其他文字
|
||||||
難易度:${difficultyText}
|
- 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
|
||||||
|
- 每个字段的值不能为空`;
|
||||||
|
|
||||||
以下のJSON配列形式で問題を1つ返してください:
|
const systemPromptJa = `あなたは問題作成ツールです。以下の手順に厳密に従ってください。
|
||||||
[
|
|
||||||
{
|
|
||||||
"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.
|
### 第一歩:知識ポイントの抽出
|
||||||
|
Human メッセージ内の【ナレッジベース内容】を読み、含まれるすべての評価可能な知識ポイントを箇条書きで抽出。
|
||||||
|
各項目は「知識ポイントN:」で始め、原文を引用。不足している場合は正直に報告。
|
||||||
|
|
||||||
### Language Rule:
|
### 第二歩:知識ポイントから問題を作成
|
||||||
**You MUST generate the question and key points in English.**
|
第一歩で抽出した知識ポイントのみを使用して 1 問作成。知識ポイント番号を引用すること。
|
||||||
|
|
||||||
### Diversity Rules:
|
### 問題タイプの割合
|
||||||
${rulesEn}
|
3問中、約1問を選択問題、2問を対話式記述問題にしてください。全体で約30%/70%の割合。
|
||||||
|
|
||||||
### Previous Questions (DO NOT REPEAT):
|
### 出題方向
|
||||||
${existingQuestionsText || 'None'}
|
「AI協作スキル」に関する問題:
|
||||||
|
- プロンプトの書き方(役割、タスク、背景、制約)
|
||||||
|
- 複数ラウンドの対話テクニック
|
||||||
|
- AIに先に質問させる方法
|
||||||
|
- セッション管理(いつ継続、いつ新規)
|
||||||
|
- よくある間違いと自己チェック
|
||||||
|
- セキュリティ意識(機密データの取扱い)
|
||||||
|
|
||||||
Return 1 question as a JSON array with format:
|
### 選択問題の基準
|
||||||
[
|
- シナリオ駆動:実務シーンを想定
|
||||||
{
|
- 4択(A/B/C/D)、正解は1つ
|
||||||
"question_text": "...",
|
- 正解には必ず解説を含める
|
||||||
"key_points": ["point1", "point2"],
|
|
||||||
"difficulty": "...",
|
### 対話式記述問題の基準
|
||||||
"dimension": "prompt/llm/ide/devPattern/workCapability",
|
- オープンクエスチョン、正解なし
|
||||||
"basis": "[n] citation..."
|
- 理解の深さと表現力を評価
|
||||||
}
|
|
||||||
]`;
|
### 絶対禁止:
|
||||||
|
- 暗記問題の禁止
|
||||||
|
- 知識ベースにない概念の使用禁止
|
||||||
|
${existingQuestionsText ? `- 既出問題との重複禁止:${existingQuestionsText}` : ''}
|
||||||
|
|
||||||
|
### 出力
|
||||||
|
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 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.
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 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
|
// dimension values: prompt=prompt engineering, llm=LLM principles, ide=IDE collaboration, devPattern=development paradigm, workCapability=work capability
|
||||||
|
|
||||||
@@ -172,10 +254,10 @@ Return 1 question as a JSON array with format:
|
|||||||
? systemPromptJa
|
? systemPromptJa
|
||||||
: systemPromptEn;
|
: systemPromptEn;
|
||||||
const humanMsg = isZh
|
const humanMsg = isZh
|
||||||
? `请使用中文基于以下内容生成题目:\n\n${knowledgeBaseContent}`
|
? `【知识库内容 - 以下是你出题的唯一依据】\n\n--- 知识库开始 ---\n${knowledgeBaseContent}\n--- 知识库结束 ---\n\n请严格基于以上内容生成题目。`
|
||||||
: isJa
|
: isJa
|
||||||
? `以下の内容に基づいて、必ず日本語でアセスメント問題を作成してください:\n\n${knowledgeBaseContent}`
|
? `【ナレッジベース内容 - 以下は出題の唯一の根拠です】\n\n--- ナレッジベース開始 ---\n${knowledgeBaseContent}\n--- ナレッジベース終了 ---\n\n上記の内容のみに基づいて問題を作成してください。`
|
||||||
: `Generate evaluation question in English based on:\n\n${knowledgeBaseContent}`;
|
: `【Knowledge Base Content - Your ONLY source for questions】\n\n--- KB START ---\n${knowledgeBaseContent}\n--- KB END ---\n\nGenerate questions strictly from the above content only.`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await model.invoke([
|
const response = await model.invoke([
|
||||||
@@ -196,6 +278,42 @@ Return 1 question as a JSON array with format:
|
|||||||
newQuestions = [newQuestions];
|
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> = {
|
const dimensionMap: Record<string, string> = {
|
||||||
// 中文
|
// 中文
|
||||||
'技术能力-提示词': 'prompt',
|
'技术能力-提示词': 'prompt',
|
||||||
@@ -223,14 +341,27 @@ Return 1 question as a JSON array with format:
|
|||||||
inferredDimension = dimensionMap[dimValue] || 'workCapability';
|
inferredDimension = dimensionMap[dimValue] || 'workCapability';
|
||||||
console.log('[GeneratorNode] Dimension mapping:', { original: q.dimension, mapped: inferredDimension });
|
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(),
|
id: (existingQuestions.length + 1).toString(),
|
||||||
questionText: q.question_text,
|
questionText: q.question_text,
|
||||||
keyPoints: q.key_points,
|
questionType: qType,
|
||||||
difficulty: q.difficulty,
|
keyPoints: q.key_points || [],
|
||||||
basis: q.basis,
|
difficulty: q.difficulty || 'STANDARD',
|
||||||
|
basis: q.basis || '',
|
||||||
dimension: inferredDimension,
|
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);
|
const questionsToGenerate = Math.max(1, limitCount - existingQuestions.length);
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { graderNode } from './grader.node';
|
||||||
|
import { HumanMessage, AIMessage } from '@langchain/core/messages';
|
||||||
|
|
||||||
|
function mockModel(response: any) {
|
||||||
|
return {
|
||||||
|
invoke: jest.fn().mockResolvedValue({
|
||||||
|
content: JSON.stringify(response),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseState(overrides: any = {}) {
|
||||||
|
return {
|
||||||
|
messages: [new HumanMessage('test answer')],
|
||||||
|
questions: [{ id: 'q1', questionText: 'What is JS?', keyPoints: ['point1'], dimension: 'llm' }],
|
||||||
|
currentQuestionIndex: 0,
|
||||||
|
scores: {},
|
||||||
|
feedbackHistory: [],
|
||||||
|
followUpCount: 0,
|
||||||
|
shouldFollowUp: false,
|
||||||
|
questionCount: 5,
|
||||||
|
language: 'en',
|
||||||
|
...overrides,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('graderNode', () => {
|
||||||
|
describe('validation guards', () => {
|
||||||
|
it('should throw when model is missing', async () => {
|
||||||
|
await expect(graderNode(baseState(), { configurable: {} } as any)).rejects.toThrow('Missing model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty object when last message is not HumanMessage', async () => {
|
||||||
|
const state = baseState({ messages: [new AIMessage('I am AI')] });
|
||||||
|
const result = await graderNode(state, { configurable: { model: mockModel({}) } } as any);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip question and advance index when current question not found', async () => {
|
||||||
|
const state = baseState({ currentQuestionIndex: 99, questions: [{ id: 'q1', questionText: 'Q', keyPoints: ['k'], dimension: 'llm' }] });
|
||||||
|
const result = await graderNode(state, { configurable: { model: mockModel({}) } } as any);
|
||||||
|
expect(result.currentQuestionIndex).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('breakout logic (shouldFollowUp overrides)', () => {
|
||||||
|
it('should NOT follow up when followUpCount >= 2 even if LLM says follow up', async () => {
|
||||||
|
const model = mockModel({ score: 5, feedback: 'needs work', should_follow_up: true, follow_up_question: 'More?' });
|
||||||
|
const state = baseState({ followUpCount: 2 });
|
||||||
|
const result = await graderNode(state, { configurable: { model } } as any);
|
||||||
|
expect(result.shouldFollowUp).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT follow up when score >= 8 even if LLM says follow up', async () => {
|
||||||
|
const model = mockModel({ score: 9, feedback: 'good', should_follow_up: true });
|
||||||
|
const state = baseState();
|
||||||
|
const result = await graderNode(state, { configurable: { model } } as any);
|
||||||
|
expect(result.shouldFollowUp).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT follow up when user says "I don\'t know"', async () => {
|
||||||
|
const model = mockModel({ score: 2, feedback: 'no answer', should_follow_up: true });
|
||||||
|
const state = baseState({ messages: [new HumanMessage("no idea")] });
|
||||||
|
const result = await graderNode(state, { configurable: { model } } as any);
|
||||||
|
expect(result.shouldFollowUp).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow follow up when conditions are met', async () => {
|
||||||
|
const model = mockModel({ score: 5, feedback: 'incomplete', should_follow_up: true, follow_up_question: 'Can you elaborate?' });
|
||||||
|
const state = baseState({ followUpCount: 0 });
|
||||||
|
const result = await graderNode(state, { configurable: { model } } as any);
|
||||||
|
expect(result.shouldFollowUp).toBe(true);
|
||||||
|
expect(result.followUpCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should handle LLM returning invalid JSON gracefully', async () => {
|
||||||
|
const model = { invoke: jest.fn().mockResolvedValue({ content: 'NOT JSON' }) };
|
||||||
|
const result = await graderNode(baseState(), { configurable: { model } } as any);
|
||||||
|
expect(result.currentQuestionIndex).toBe(1);
|
||||||
|
expect(result.shouldFollowUp).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scoring and indexing', () => {
|
||||||
|
it('should advance currentQuestionIndex when not following up', async () => {
|
||||||
|
const model = mockModel({ score: 6, feedback: 'ok', should_follow_up: false });
|
||||||
|
const result = await graderNode(baseState(), { configurable: { model } } as any);
|
||||||
|
expect(result.currentQuestionIndex).toBe(1);
|
||||||
|
expect(result.scores).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep currentQuestionIndex when following up', async () => {
|
||||||
|
const model = mockModel({ score: 5, feedback: 'needs work', should_follow_up: true, follow_up_question: 'Can you clarify?' });
|
||||||
|
const state = baseState({ followUpCount: 0 });
|
||||||
|
const result = await graderNode(state, { configurable: { model } } as any);
|
||||||
|
expect(result.currentQuestionIndex).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should record score under question id in scores map', async () => {
|
||||||
|
const model = mockModel({ score: 7, feedback: 'good', should_follow_up: false });
|
||||||
|
const state = baseState({ questions: [{ id: 'q-test', questionText: 'Q', keyPoints: ['k'], dimension: 'llm' }] });
|
||||||
|
const result = await graderNode(state, { configurable: { model } } as any);
|
||||||
|
expect((result.scores as any)['q-test']).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('language support', () => {
|
||||||
|
it('should handle Chinese language', async () => {
|
||||||
|
const model = mockModel({ score: 8, feedback: '很好', should_follow_up: false });
|
||||||
|
const state = baseState({ language: 'zh' });
|
||||||
|
const result = await graderNode(state, { configurable: { model } } as any);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Japanese language', async () => {
|
||||||
|
const model = mockModel({ score: 8, feedback: '良い', should_follow_up: false });
|
||||||
|
const state = baseState({ language: 'ja' });
|
||||||
|
const result = await graderNode(state, { configurable: { model } } as any);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -67,106 +67,219 @@ export const graderNode = async (
|
|||||||
return { currentQuestionIndex: currentQuestionIndex + 1 };
|
return { currentQuestionIndex: currentQuestionIndex + 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemPromptZh = `你是一位专业的考官。
|
const isChoice = currentQuestion.questionType === 'MULTIPLE_CHOICE';
|
||||||
请根据以下问题和关键点对用户的回答进行评分。
|
const expectedAnswer = currentQuestion.correctAnswer;
|
||||||
|
|
||||||
重要提示:
|
if (isChoice && expectedAnswer) {
|
||||||
1. **你必须使用以下语言提供反馈:中文 (Simplified Chinese)**。
|
const userAnswer = (lastUserMessage.content as string).trim();
|
||||||
2. 即使用户的回答或知识库内容涉及其他语言,请确保你的反馈和解释依然严格使用中文。不要夹杂日文。
|
const isCorrect = userAnswer.toUpperCase() === expectedAnswer?.toUpperCase();
|
||||||
|
|
||||||
|
console.log('[GraderNode] Choice grading:', { userAnswer, expectedAnswer, isCorrect });
|
||||||
|
|
||||||
|
const feedback = isCorrect ? '✅ 正确' : `❌ 错误,正确答案是 ${expectedAnswer}`;
|
||||||
|
const feedbackMessage = new AIMessage(
|
||||||
|
{ content: `Score: ${isCorrect ? 10 : 0}\nFeedback: ${feedback}` } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: [feedbackMessage],
|
||||||
|
feedbackHistory: [feedbackMessage],
|
||||||
|
scores: { [currentQuestion.id || currentQuestionIndex.toString()]: isCorrect ? 10 : 0 },
|
||||||
|
shouldFollowUp: false,
|
||||||
|
followUpCount: 0,
|
||||||
|
currentQuestionIndex: currentQuestionIndex + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 = `你是一位考官。请评分并给出反馈。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 只用中文。
|
||||||
|
2. 多轮追问时,用户回答含所有轮次(第N轮回答:标记),综合判断已覆盖内容。
|
||||||
|
|
||||||
问题:${currentQuestion.questionText}
|
问题:${currentQuestion.questionText}
|
||||||
预期的关键点:${currentQuestion.keyPoints.join(', ')}
|
关键点:${currentQuestion.keyPoints.join(', ')}
|
||||||
|
|
||||||
评估标准:
|
评分标准:不要求深度,不要求使用特定术语,只看用户是否理解了概念。
|
||||||
1. 准确性:他们是否正确覆盖了关键点?
|
用户理解核心概念就给分。即使没有使用关键点中的原词,只要意思到位就算覆盖。
|
||||||
2. 完整性:他们是否遗漏了任何重要内容?
|
例如关键点是"上下文窗口有限",用户说"信息太多超过AI处理长度"也是覆盖。
|
||||||
3. 深度:解释是否充分?
|
评分原则:往宽了给分,不确定时就给高分。明显正确就给8-10分,部分正确5-7分,完全不沾边才0-2分。
|
||||||
|
|
||||||
请提供:
|
返回JSON:
|
||||||
1. 0 到 10 的评分。
|
- score: 0-10
|
||||||
2. 建设性的反馈。
|
- feedback: 评语
|
||||||
3. 如果回答不完整或不清晰,需要进一步解释,请将 'should_follow_up' 标志设为 true。
|
- should_follow_up: true/false
|
||||||
|
- follow_up_question: 追问(仅true时需要,针对未覆盖的关键点,false时null)
|
||||||
|
|
||||||
请以 JSON 格式返回响应:
|
请以 JSON 格式返回响应:
|
||||||
{
|
{"score":0到10,"feedback":"评语","should_follow_up":true或false,"follow_up_question":"追问或null"}
|
||||||
"score": 8,
|
|
||||||
"feedback": "...",
|
|
||||||
"should_follow_up": false
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const systemPromptJa = `あなたは専門的な試験官です。
|
示例(需要追问):
|
||||||
以下の質問とキーポイントに基づいて、ユーザーの回答を採点してください。
|
{"score":6,"feedback":"提到了安全性和性能,未说明依赖关系。","should_follow_up":true,"follow_up_question":"你如何让AI在计划中明确任务依赖关系?"}
|
||||||
|
|
||||||
重要事項:
|
示例(不需追问):
|
||||||
1. **フィードバックは必ず次の言語で提供してください:日本語**。
|
{"score":8,"feedback":"回答完整。","should_follow_up":false,"follow_up_question":null}`;
|
||||||
2. ユーザーの回答やナレッジベースの内容に他の言語(中国語や英語など)が含まれている場合でも、フィードバックと説明は必ず日本語のみで行ってください。中国語が混ざらないよう厳格に注意してください。
|
|
||||||
|
const systemPromptJa = `あなたは試験官です。採点とフィードバックを提供してください。
|
||||||
|
|
||||||
|
ルール:
|
||||||
|
1. 日本語のみ使用。
|
||||||
|
2. 複数ラウンドの回答は「第N輪回答:」でマークされ、全ラウンドを総合判断。
|
||||||
|
|
||||||
質問:${currentQuestion.questionText}
|
質問:${currentQuestion.questionText}
|
||||||
期待されるキーポイント:${currentQuestion.keyPoints.join(', ')}
|
キーポイント:${currentQuestion.keyPoints.join(', ')}
|
||||||
|
|
||||||
評価基準:
|
評価基準:正確性、網羅性、深さ。
|
||||||
1. 正確性:キーポイントを正確に網羅していますか?
|
部分点可(5〜7点)、見当違いのみ0〜2点。
|
||||||
2. 網羅性:重要な内容が欠落していませんか?
|
|
||||||
3. 深さ:説明は十分ですか?
|
|
||||||
|
|
||||||
以下を提供してください:
|
JSON形式:
|
||||||
1. 0 から 10 までのスコア。
|
- score: 0〜10
|
||||||
2. 建設的なフィードバック。
|
- feedback: 評価
|
||||||
3. 回答が不完全または不明確で、さらなる説明が必要な場合は、'should_follow_up' フラグを true に設定してください。
|
- should_follow_up: true/false
|
||||||
|
- follow_up_question: 追質問(true時のみ、未カバーのポイントに焦点、false時null)
|
||||||
|
|
||||||
JSON 形式で回答してください:
|
JSON 形式で回答してください:
|
||||||
{
|
{"score":0から10,"feedback":"評価","should_follow_up":trueかfalse,"follow_up_question":"追質問かnull"}
|
||||||
"score": 8,
|
|
||||||
"feedback": "...",
|
|
||||||
"should_follow_up": false
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const systemPromptEn = `You are an expert examiner.
|
例(追質問が必要):
|
||||||
Grade the user's answer based on the following question and key points.
|
{"score":6,"feedback":"安全性と性能に言及したが、依存関係が不明。","should_follow_up":true,"follow_up_question":"AIに計画内のタスク依存関係を明示させる方法は?"}
|
||||||
|
|
||||||
IMPORTANT:
|
例(不要):
|
||||||
1. **You MUST provide the feedback in English.**
|
{"score":8,"feedback":"回答は完全。","should_follow_up":false,"follow_up_question":null}`;
|
||||||
2. If the user's answer or knowledge base content references other languages, ensure your feedback and explanation remain strictly in English.
|
|
||||||
|
|
||||||
QUESTION: ${currentQuestion.questionText}
|
const systemPromptEn = `You are an examiner. Grade and give feedback.
|
||||||
EXPECTED KEY POINTS: ${currentQuestion.keyPoints.join(', ')}
|
|
||||||
|
|
||||||
Evaluate:
|
Rules:
|
||||||
1. Accuracy: Did they cover the key points correctly?
|
1. English only.
|
||||||
2. Completeness: Did they miss anything important?
|
2. Multi-round answers are tagged "第N轮回答:". Consider all rounds.
|
||||||
3. Depth: Is the explanation sufficient?
|
|
||||||
|
|
||||||
Provide:
|
Question: ${currentQuestion.questionText}
|
||||||
1. A score from 0 to 10.
|
Key points: ${currentQuestion.keyPoints.join(', ')}
|
||||||
2. Constructive feedback.
|
|
||||||
3. A boolean flag 'should_follow_up' if the answer is incomplete or unclear and needs further clarification.
|
|
||||||
|
|
||||||
Format your response as JSON:
|
Criteria: accuracy, completeness, depth.
|
||||||
{
|
Give partial credit (5-7 for partial), 0-2 only for off-target.
|
||||||
"score": 8,
|
|
||||||
"feedback": "...",
|
|
||||||
"should_follow_up": false
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const systemPrompt = isZh
|
Return JSON:
|
||||||
|
- score: 0-10
|
||||||
|
- feedback: text
|
||||||
|
- should_follow_up: true/false
|
||||||
|
- follow_up_question: question (only when true, target uncovered points, null when false)
|
||||||
|
|
||||||
|
Format as JSON:
|
||||||
|
{"score":0-10,"feedback":"...","should_follow_up":true|false,"follow_up_question":"question or null"}
|
||||||
|
|
||||||
|
Example (follow-up needed):
|
||||||
|
{"score":6,"feedback":"Covered security and performance, missed dependencies.","should_follow_up":true,"follow_up_question":"How would you make the AI clarify task dependencies?"}
|
||||||
|
|
||||||
|
Example (no follow-up):
|
||||||
|
{"score":8,"feedback":"Complete answer.","should_follow_up":false,"follow_up_question":null}`;
|
||||||
|
|
||||||
|
let systemPrompt = isZh
|
||||||
? systemPromptZh
|
? systemPromptZh
|
||||||
: isJa
|
: isJa
|
||||||
? systemPromptJa
|
? systemPromptJa
|
||||||
: systemPromptEn;
|
: systemPromptEn;
|
||||||
|
|
||||||
|
if (currentQuestion.judgment) {
|
||||||
|
const anchorText = isZh
|
||||||
|
? `\n\n【判定依据(通过标准)】${currentQuestion.judgment}`
|
||||||
|
: isJa
|
||||||
|
? `\n\n【判定基準(合格基準)】${currentQuestion.judgment}`
|
||||||
|
: `\n\n【Judgment Criteria (Pass Standard)】${currentQuestion.judgment}`;
|
||||||
|
systemPrompt += anchorText;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxFollowUps = (currentQuestion as any).maxFollowUps ?? 2;
|
||||||
|
|
||||||
const userContentText =
|
const userContentText =
|
||||||
typeof lastUserMessage.content === 'string'
|
typeof lastUserMessage.content === 'string'
|
||||||
? lastUserMessage.content
|
? lastUserMessage.content
|
||||||
: JSON.stringify(lastUserMessage.content);
|
: JSON.stringify(lastUserMessage.content);
|
||||||
|
|
||||||
|
let allAnswers = userContentText;
|
||||||
|
if (currentFollowUpCount > 0) {
|
||||||
|
const prevAnswers = state.messages
|
||||||
|
.filter(m => m instanceof HumanMessage)
|
||||||
|
.slice(-(currentFollowUpCount + 1))
|
||||||
|
.map((m, i) => `第${i + 1}轮回答:${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`);
|
||||||
|
allAnswers = prevAnswers.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[GraderNode] === START GRADING ===');
|
console.log('[GraderNode] === START GRADING ===');
|
||||||
console.log('[GraderNode] User answer length:', userContentText.length);
|
console.log('[GraderNode] User answer length:', userContentText.length);
|
||||||
console.log('[GraderNode] Question:', currentQuestion?.questionText?.substring(0, 100));
|
console.log('[GraderNode] Question:', currentQuestion?.questionText?.substring(0, 100));
|
||||||
console.log('[GraderNode] Target dimension:', currentQuestion?.dimension);
|
console.log('[GraderNode] Target dimension:', currentQuestion?.dimension);
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await model.invoke([
|
const response = await model.invoke([
|
||||||
new SystemMessage(systemPrompt),
|
new SystemMessage(systemPrompt),
|
||||||
new HumanMessage(userContentText),
|
new HumanMessage(allAnswers),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log('[GraderNode] LLM invoke completed');
|
console.log('[GraderNode] LLM invoke completed');
|
||||||
@@ -187,10 +300,7 @@ Format your response as JSON:
|
|||||||
|
|
||||||
const scoreLabel = isZh ? '得分' : isJa ? 'スコア' : 'Score';
|
const scoreLabel = isZh ? '得分' : isJa ? 'スコア' : 'Score';
|
||||||
const feedbackLabel = isZh ? '反馈' : isJa ? 'フィードバック' : 'Feedback';
|
const feedbackLabel = isZh ? '反馈' : isJa ? 'フィードバック' : 'Feedback';
|
||||||
|
let enhancedFeedback: string = result.feedback;
|
||||||
const feedbackMessage = new AIMessage(
|
|
||||||
`${scoreLabel}: ${result.score}/10\n\n${feedbackLabel}: ${result.feedback}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const newScores = {
|
const newScores = {
|
||||||
...state.scores,
|
...state.scores,
|
||||||
@@ -199,10 +309,6 @@ Format your response as JSON:
|
|||||||
|
|
||||||
let shouldFollowUp = result.should_follow_up === true;
|
let shouldFollowUp = result.should_follow_up === true;
|
||||||
|
|
||||||
// Breakout logic:
|
|
||||||
// 1. Max 1 follow-up per question
|
|
||||||
// 2. If score is decent (>= 8), don't follow up
|
|
||||||
// 3. If answer is short "don't know", don't follow up
|
|
||||||
const normalizedContent = userContentText.trim().toLowerCase();
|
const normalizedContent = userContentText.trim().toLowerCase();
|
||||||
const saysIDontKnow =
|
const saysIDontKnow =
|
||||||
normalizedContent.length < 10 &&
|
normalizedContent.length < 10 &&
|
||||||
@@ -217,10 +323,21 @@ Format your response as JSON:
|
|||||||
normalizedContent.includes('不明') ||
|
normalizedContent.includes('不明') ||
|
||||||
normalizedContent.includes('わからない'));
|
normalizedContent.includes('わからない'));
|
||||||
|
|
||||||
if (currentFollowUpCount >= 2 || result.score >= 8 || saysIDontKnow) {
|
if (currentFollowUpCount >= maxFollowUps || result.score >= 8 || saysIDontKnow) {
|
||||||
shouldFollowUp = false;
|
shouldFollowUp = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let followupHintMsg: AIMessage | null = null;
|
||||||
|
if (shouldFollowUp && result.follow_up_question && result.follow_up_question.trim()) {
|
||||||
|
followupHintMsg = new AIMessage(result.follow_up_question.trim());
|
||||||
|
} else if (shouldFollowUp) {
|
||||||
|
shouldFollowUp = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedbackMessage = new AIMessage(
|
||||||
|
`${scoreLabel}: ${result.score}/10\n\n${feedbackLabel}: ${enhancedFeedback}`,
|
||||||
|
);
|
||||||
|
|
||||||
console.log('[GraderNode] Final State decision:', {
|
console.log('[GraderNode] Final State decision:', {
|
||||||
shouldFollowUp,
|
shouldFollowUp,
|
||||||
nextIndex: shouldFollowUp
|
nextIndex: shouldFollowUp
|
||||||
@@ -230,8 +347,12 @@ Format your response as JSON:
|
|||||||
saysIDontKnow,
|
saysIDontKnow,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const feedbackHistoryMessages = followupHintMsg
|
||||||
|
? [feedbackMessage, followupHintMsg]
|
||||||
|
: [feedbackMessage];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
feedbackHistory: [feedbackMessage],
|
feedbackHistory: feedbackHistoryMessages,
|
||||||
scores: newScores,
|
scores: newScores,
|
||||||
shouldFollowUp: shouldFollowUp,
|
shouldFollowUp: shouldFollowUp,
|
||||||
followUpCount: shouldFollowUp ? currentFollowUpCount + 1 : 0,
|
followUpCount: shouldFollowUp ? currentFollowUpCount + 1 : 0,
|
||||||
@@ -239,14 +360,29 @@ Format your response as JSON:
|
|||||||
? currentQuestionIndex
|
? currentQuestionIndex
|
||||||
: currentQuestionIndex + 1,
|
: currentQuestionIndex + 1,
|
||||||
} as any;
|
} as any;
|
||||||
} catch (error) {
|
} catch (parseError) {
|
||||||
console.error('Failed to parse grade from AI response:', error);
|
console.error('[GraderNode] Failed to parse grade:', parseError);
|
||||||
|
const scoreLabel = isZh ? '得分' : isJa ? 'スコア' : 'Score';
|
||||||
|
const fallbackMsg = new AIMessage(`${scoreLabel}: 5/10\n\n评分解析失败,默认给5分。`);
|
||||||
return {
|
return {
|
||||||
feedbackHistory: [
|
feedbackHistory: [fallbackMsg],
|
||||||
new AIMessage("I had some trouble grading that, but let's move on."),
|
scores: { [currentQuestion.id || currentQuestionIndex.toString()]: 5 },
|
||||||
],
|
|
||||||
currentQuestionIndex: currentQuestionIndex + 1,
|
|
||||||
shouldFollowUp: false,
|
shouldFollowUp: false,
|
||||||
|
followUpCount: 0,
|
||||||
|
currentQuestionIndex: currentQuestionIndex + 1,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GraderNode] LLM grading failed:', error);
|
||||||
|
const scoreLabel = isZh ? '得分' : isJa ? 'スコア' : 'Score';
|
||||||
|
const feedbackLabel = isZh ? '反馈' : isJa ? 'フィードバック' : 'Feedback';
|
||||||
|
const fallbackMsg = new AIMessage(`${scoreLabel}: 5/10\n\n${feedbackLabel}: 评分服务暂时不可用,默认给5分。`);
|
||||||
|
return {
|
||||||
|
feedbackHistory: [fallbackMsg],
|
||||||
|
scores: { [currentQuestion.id || currentQuestionIndex.toString()]: 5 },
|
||||||
|
shouldFollowUp: false,
|
||||||
|
followUpCount: 0,
|
||||||
|
currentQuestionIndex: currentQuestionIndex + 1,
|
||||||
} as any;
|
} as any;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { interviewerNode } from './interviewer.node';
|
||||||
|
import { AIMessage } from '@langchain/core/messages';
|
||||||
|
|
||||||
|
function baseState(overrides: any = {}) {
|
||||||
|
return {
|
||||||
|
questions: [{ id: 'q1', questionText: 'What is JS?', keyPoints: ['point1'], dimension: 'llm' }],
|
||||||
|
currentQuestionIndex: 0,
|
||||||
|
shouldFollowUp: false,
|
||||||
|
language: 'en',
|
||||||
|
...overrides,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('interviewerNode', () => {
|
||||||
|
describe('empty questions handling', () => {
|
||||||
|
it('should return apology message when questions array is empty', async () => {
|
||||||
|
const state = baseState({ questions: [] });
|
||||||
|
const result = await interviewerNode(state);
|
||||||
|
expect(result.messages).toBeDefined();
|
||||||
|
expect((result.messages as any)[0].content).toContain("sorry");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return apology message when questions is undefined', async () => {
|
||||||
|
const state = baseState({ questions: undefined });
|
||||||
|
const result = await interviewerNode(state);
|
||||||
|
expect(result.messages).toBeDefined();
|
||||||
|
expect((result.messages as any)[0].content).toContain("sorry");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Chinese apology when language is zh', async () => {
|
||||||
|
const state = baseState({ questions: [], language: 'zh' });
|
||||||
|
const result = await interviewerNode(state);
|
||||||
|
expect((result.messages as any)[0].content).toContain('抱歉');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Japanese apology when language is ja', async () => {
|
||||||
|
const state = baseState({ questions: [], language: 'ja' });
|
||||||
|
const result = await interviewerNode(state);
|
||||||
|
expect((result.messages as any)[0].content).toContain('申し訳');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('question index range checks', () => {
|
||||||
|
it('should return shouldFollowUp: false when currentQuestionIndex >= questions.length', async () => {
|
||||||
|
const state = baseState({ currentQuestionIndex: 5, questions: [{ id: 'q1', questionText: 'Q', keyPoints: ['k'], dimension: 'llm' }] });
|
||||||
|
const result = await interviewerNode(state);
|
||||||
|
expect(result.shouldFollowUp).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('standard question presentation', () => {
|
||||||
|
it('should present the current question', async () => {
|
||||||
|
const result = await interviewerNode(baseState());
|
||||||
|
expect(result.messages).toBeDefined();
|
||||||
|
const msg = (result.messages as any)[0].content as string;
|
||||||
|
expect(msg).toContain('Question 1');
|
||||||
|
expect(msg).toContain('What is JS?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include answer instruction', async () => {
|
||||||
|
const result = await interviewerNode(baseState());
|
||||||
|
const msg = (result.messages as any)[0].content as string;
|
||||||
|
expect(msg).toContain('answer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use Chinese labels when language is zh', async () => {
|
||||||
|
const state = baseState({ language: 'zh' });
|
||||||
|
const result = await interviewerNode(state);
|
||||||
|
const msg = (result.messages as any)[0].content as string;
|
||||||
|
expect(msg).toContain('问题');
|
||||||
|
expect(msg).toContain('回答');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use Japanese labels when language is ja', async () => {
|
||||||
|
const state = baseState({ language: 'ja' });
|
||||||
|
const result = await interviewerNode(state);
|
||||||
|
const msg = (result.messages as any)[0].content as string;
|
||||||
|
expect(msg).toContain('質問');
|
||||||
|
expect(msg).toContain('回答');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('follow-up mode', () => {
|
||||||
|
it('should use last feedbackHistory message content as follow-up prompt', async () => {
|
||||||
|
const state = baseState({
|
||||||
|
shouldFollowUp: true,
|
||||||
|
feedbackHistory: [new AIMessage('You need more details')],
|
||||||
|
});
|
||||||
|
const result = await interviewerNode(state);
|
||||||
|
const msg = (result.messages as any)[0].content as string;
|
||||||
|
expect(msg).toContain('You need more details');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset shouldFollowUp to false after processing', async () => {
|
||||||
|
const state = baseState({
|
||||||
|
shouldFollowUp: true,
|
||||||
|
feedbackHistory: [new AIMessage('Feedback: More info needed')],
|
||||||
|
});
|
||||||
|
const result = await interviewerNode(state);
|
||||||
|
expect(result.shouldFollowUp).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,8 +33,6 @@ export const interviewerNode = async (
|
|||||||
|
|
||||||
const currentQuestion = questions[currentQuestionIndex];
|
const currentQuestion = questions[currentQuestionIndex];
|
||||||
|
|
||||||
// If it's a follow-up, we add a prefix to the label later.
|
|
||||||
// If we've run out of questions and no follow-up requested, we shouldn't be here, but let's be safe.
|
|
||||||
if (currentQuestionIndex >= questions.length) {
|
if (currentQuestionIndex >= questions.length) {
|
||||||
return { shouldFollowUp: false };
|
return { shouldFollowUp: false };
|
||||||
}
|
}
|
||||||
@@ -49,33 +47,24 @@ export const interviewerNode = async (
|
|||||||
state.feedbackHistory &&
|
state.feedbackHistory &&
|
||||||
state.feedbackHistory.length > 0
|
state.feedbackHistory.length > 0
|
||||||
) {
|
) {
|
||||||
// Construct a follow-up prompt based on last feedback
|
|
||||||
const lastFeedbackMsg =
|
const lastFeedbackMsg =
|
||||||
state.feedbackHistory[state.feedbackHistory.length - 1];
|
state.feedbackHistory[state.feedbackHistory.length - 1];
|
||||||
const feedbackText = lastFeedbackMsg.content.toString();
|
prompt = lastFeedbackMsg.content.toString();
|
||||||
|
} else if (currentQuestion.questionType === 'MULTIPLE_CHOICE' && currentQuestion.options?.length > 0) {
|
||||||
// Extract the "Feedback: ..." part if possible, otherwise use whole text
|
const label = isZh
|
||||||
const feedbackMatch = feedbackText.match(
|
? `问题 ${currentQuestionIndex + 1}`
|
||||||
/(?:Feedback|反馈|フィードバック): ([\s\S]*)/i,
|
|
||||||
);
|
|
||||||
const specificFeedback = feedbackMatch
|
|
||||||
? feedbackMatch[1].trim()
|
|
||||||
: feedbackText;
|
|
||||||
|
|
||||||
const followUpLabel = isZh
|
|
||||||
? '补充追问'
|
|
||||||
: isJa
|
: isJa
|
||||||
? '追加の質問'
|
? `質問 ${currentQuestionIndex + 1}`
|
||||||
: 'Follow-up Clarification';
|
: `Question ${currentQuestionIndex + 1}`;
|
||||||
const followUpInstruction = isZh
|
|
||||||
? '根据以上反馈,请补充更具体的信息:'
|
|
||||||
: isJa
|
|
||||||
? '上記のフィードバックに基づき、より具体的な情報を追加してください:'
|
|
||||||
: 'Based on the feedback above, please provide more specific details:';
|
|
||||||
|
|
||||||
prompt = `${followUpLabel}\n\n${specificFeedback}\n\n${followUpInstruction}`;
|
const instruction = isZh
|
||||||
|
? '请选择一个选项'
|
||||||
|
: isJa
|
||||||
|
? '選択肢から1つ選んでください'
|
||||||
|
: 'Please select one option';
|
||||||
|
|
||||||
|
prompt = `${label}: ${currentQuestion.questionText}\n\n${instruction}`;
|
||||||
} else {
|
} else {
|
||||||
// Standard question presentation
|
|
||||||
const label = isZh
|
const label = isZh
|
||||||
? `问题 ${currentQuestionIndex + 1}`
|
? `问题 ${currentQuestionIndex + 1}`
|
||||||
: isJa
|
: isJa
|
||||||
|
|||||||
@@ -119,6 +119,15 @@ export const EvaluationAnnotation = Annotation.Root({
|
|||||||
keywords: Annotation<string[] | undefined>({
|
keywords: Annotation<string[] | undefined>({
|
||||||
reducer: (prev, next) => next ?? prev,
|
reducer: (prev, next) => next ?? prev,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer key for bank questions: id → { correctAnswer, judgment, followupHints }.
|
||||||
|
* Used by grader for instant choice scoring and open-question anchoring.
|
||||||
|
* NOT sent to frontend.
|
||||||
|
*/
|
||||||
|
questionAnswerKey: Annotation<Record<string, any> | undefined>({
|
||||||
|
reducer: (prev, next) => next ?? prev,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type EvaluationState = typeof EvaluationAnnotation.State;
|
export type EvaluationState = typeof EvaluationAnnotation.State;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AuditLog } from '../entities/audit-log.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditLogService {
|
||||||
|
private readonly logger = new Logger(AuditLogService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AuditLog)
|
||||||
|
private auditLogRepository: Repository<AuditLog>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async log(params: {
|
||||||
|
userId: string;
|
||||||
|
tenantId?: string;
|
||||||
|
action: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceId?: string;
|
||||||
|
details?: any;
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entry = this.auditLogRepository.create({
|
||||||
|
userId: params.userId,
|
||||||
|
tenantId: params.tenantId,
|
||||||
|
action: params.action,
|
||||||
|
resourceType: params.resourceType,
|
||||||
|
resourceId: params.resourceId,
|
||||||
|
details: params.details,
|
||||||
|
});
|
||||||
|
await this.auditLogRepository.insert(entry);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to write audit log: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { AssessmentSession } from '../entities/assessment-session.entity';
|
|||||||
import { AssessmentQuestion } from '../entities/assessment-question.entity';
|
import { AssessmentQuestion } from '../entities/assessment-question.entity';
|
||||||
import { AssessmentAnswer } from '../entities/assessment-answer.entity';
|
import { AssessmentAnswer } from '../entities/assessment-answer.entity';
|
||||||
import { AssessmentCertificate } from '../entities/assessment-certificate.entity';
|
import { AssessmentCertificate } from '../entities/assessment-certificate.entity';
|
||||||
|
import { generateAssessmentPdf } from './pdf-generator';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExportService {
|
export class ExportService {
|
||||||
@@ -95,7 +96,7 @@ export class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extractDimensionScores(session: AssessmentSession): any[][] {
|
private extractDimensionScores(session: AssessmentSession): any[][] {
|
||||||
const scores = session.templateJson?.dimensionScores || session.finalReport;
|
const scores = (session as any).dimensionScores;
|
||||||
if (!scores) return [['未找到维度分数']];
|
if (!scores) return [['未找到维度分数']];
|
||||||
|
|
||||||
if (typeof scores === 'string') {
|
if (typeof scores === 'string') {
|
||||||
@@ -142,86 +143,47 @@ export class ExportService {
|
|||||||
throw new Error('Session not found');
|
throw new Error('Session not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const certificate = await this.certificateRepository.findOne({
|
const cert = await this.certificateRepository.findOne({
|
||||||
where: { sessionId },
|
where: { sessionId },
|
||||||
});
|
});
|
||||||
|
|
||||||
const questions = await this.questionRepository.find({
|
const questions = (session.questions_json || []) as any[];
|
||||||
where: { sessionId },
|
|
||||||
order: { createdAt: 'ASC' },
|
const userName = session.user?.displayName || session.user?.username || session.userId;
|
||||||
|
const templateName = session.template?.name || session.templateJson?.name || '-';
|
||||||
|
const dimensionScores = (session as any).dimensionScores || {};
|
||||||
|
|
||||||
|
let dimRows = '';
|
||||||
|
for (const [dim, score] of Object.entries(dimensionScores)) {
|
||||||
|
dimRows += `<tr><td>${dim}</td><td>${score}/10</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let qRows = '';
|
||||||
|
questions.forEach((q: any, i: number) => {
|
||||||
|
qRows += `<tr><td>${i + 1}</td><td>${(q.questionText || '').substring(0, 80)}</td><td>${q.questionType || '-'}</td><td>${q.dimension || '-'}</td></tr>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const answers = await this.answerRepository.find({
|
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Assessment Report</title>
|
||||||
where: { questionId: In(questions.map((q) => q.id)) },
|
<style>body{font-family:'Microsoft YaHei',sans-serif;max-width:800px;margin:40px auto;color:#333}
|
||||||
});
|
h1{font-size:24px}h2{font-size:18px;border-bottom:2px solid #4F46E5;padding-bottom:8px}
|
||||||
|
table{width:100%;border-collapse:collapse;margin:16px 0}
|
||||||
|
td,th{border:1px solid #ddd;padding:8px;text-align:left}
|
||||||
|
th{background:#F3F4F6}.score{font-size:36px;font-weight:bold;color:#4F46E5}
|
||||||
|
.pass{color:#059669}.fail{color:#DC2626}</style></head><body>
|
||||||
|
<h1>Assessment Report</h1>
|
||||||
|
<p>${userName} — ${new Date(session.createdAt).toLocaleDateString()}</p>
|
||||||
|
<p>Template: ${templateName}</p>
|
||||||
|
<h2>Result</h2>
|
||||||
|
<p class="score">${session.finalScore ?? '-'}/10</p>
|
||||||
|
<p class="${(session as any).passed ? 'pass' : 'fail'}">${(session as any).passed ? 'PASSED' : 'FAILED'}</p>
|
||||||
|
${cert ? `<p>Level: ${cert.level}</p>` : ''}
|
||||||
|
<h2>Dimension Scores</h2>
|
||||||
|
<table>${dimRows}</table>
|
||||||
|
<h2>Questions</h2>
|
||||||
|
<table><tr><th>#</th><th>Question</th><th>Type</th><th>Dimension</th></tr>${qRows}</table>
|
||||||
|
${session.finalReport ? `<h2>Mastery Report</h2><pre>${session.finalReport}</pre>` : ''}
|
||||||
|
</body></html>`;
|
||||||
|
|
||||||
const content = this.generatePdfContent(session, questions, answers, certificate);
|
return Buffer.from(html, 'utf-8');
|
||||||
return Buffer.from(content, 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
private generatePdfContent(
|
|
||||||
session: AssessmentSession,
|
|
||||||
questions: AssessmentQuestion[],
|
|
||||||
answers: AssessmentAnswer[],
|
|
||||||
certificate: AssessmentCertificate | null,
|
|
||||||
): string {
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
lines.push('='.repeat(60));
|
|
||||||
lines.push(' 人才评估报告');
|
|
||||||
lines.push('='.repeat(60));
|
|
||||||
lines.push('');
|
|
||||||
|
|
||||||
lines.push(`评估ID: ${session.id}`);
|
|
||||||
lines.push(`用户: ${session.user?.displayName || session.user?.username || session.userId}`);
|
|
||||||
lines.push(`状态: ${session.status === 'COMPLETED' ? '已完成' : '进行中'}`);
|
|
||||||
lines.push(`最终分数: ${session.finalScore || '-'}`);
|
|
||||||
lines.push(`评估模板: ${session.template?.name || session.templateJson?.name || '-'}`);
|
|
||||||
lines.push(`评估时间: ${session.startedAt ? new Date(session.startedAt).toLocaleString() : '-'}`);
|
|
||||||
lines.push('');
|
|
||||||
|
|
||||||
if (certificate) {
|
|
||||||
lines.push('-'.repeat(60));
|
|
||||||
lines.push('证书信息');
|
|
||||||
lines.push('-'.repeat(60));
|
|
||||||
lines.push(`等级: ${certificate.level}`);
|
|
||||||
lines.push(`总分: ${certificate.totalScore}`);
|
|
||||||
lines.push(`是否通过: ${certificate.passed ? '是' : '否'}`);
|
|
||||||
lines.push(`颁发时间: ${certificate.issuedAt ? new Date(certificate.issuedAt).toLocaleString() : '-'}`);
|
|
||||||
lines.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('-'.repeat(60));
|
|
||||||
lines.push('题目详情');
|
|
||||||
lines.push('-'.repeat(60));
|
|
||||||
|
|
||||||
const answerMap = new Map(answers.map((a) => [a.questionId, a]));
|
|
||||||
|
|
||||||
for (let i = 0; i < questions.length; i++) {
|
|
||||||
const q = questions[i];
|
|
||||||
const a = answerMap.get(q.id);
|
|
||||||
lines.push('');
|
|
||||||
lines.push(`第${i + 1}题:`);
|
|
||||||
lines.push(` 题目: ${q.questionText || '-'}`);
|
|
||||||
lines.push(` 用户回答: ${a?.userAnswer || '-'}`);
|
|
||||||
lines.push(` 得分: ${a?.score ?? '-'}`);
|
|
||||||
lines.push(` 反馈: ${a?.feedback || '-'}`);
|
|
||||||
lines.push(` 追问: ${a?.isFollowUp ? '是' : '否'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.finalReport) {
|
|
||||||
lines.push('');
|
|
||||||
lines.push('-'.repeat(60));
|
|
||||||
lines.push('综合评估报告');
|
|
||||||
lines.push('-'.repeat(60));
|
|
||||||
lines.push(session.finalReport);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('');
|
|
||||||
lines.push('='.repeat(60));
|
|
||||||
lines.push(' 报告结束');
|
|
||||||
lines.push('='.repeat(60));
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { PDFDocument, rgb, StandardFonts, PageSizes } from 'pdf-lib';
|
||||||
|
|
||||||
|
const FONT_SEARCH_PATHS = [
|
||||||
|
'C:/Windows/Fonts/NotoSansSC-VF.ttf',
|
||||||
|
'C:/Windows/Fonts/NotoSansJP-VF.ttf',
|
||||||
|
path.join(__dirname, '..', '..', '..', 'assets', 'fonts', 'NotoSansSC-VF.ttf'),
|
||||||
|
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
||||||
|
];
|
||||||
|
|
||||||
|
let cachedFontBytes: Buffer | null = null;
|
||||||
|
|
||||||
|
function findFont(): Buffer {
|
||||||
|
if (cachedFontBytes) return cachedFontBytes;
|
||||||
|
for (const p of FONT_SEARCH_PATHS) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
cachedFontBytes = fs.readFileSync(p);
|
||||||
|
return cachedFontBytes;
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
return Buffer.alloc(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PdfReportOptions {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
sections: PdfSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PdfSection {
|
||||||
|
title: string;
|
||||||
|
lines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateAssessmentPdf(options: PdfReportOptions): Promise<Buffer> {
|
||||||
|
const doc = await PDFDocument.create();
|
||||||
|
|
||||||
|
let font: any;
|
||||||
|
const fontBytes = findFont();
|
||||||
|
if (fontBytes.length > 0) {
|
||||||
|
try {
|
||||||
|
font = await doc.embedFont(fontBytes, { subset: true });
|
||||||
|
} catch {
|
||||||
|
font = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!font) {
|
||||||
|
font = await doc.embedFont(StandardFonts.Helvetica);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageWidth = PageSizes.A4[0];
|
||||||
|
const pageHeight = PageSizes.A4[1];
|
||||||
|
const margin = 50;
|
||||||
|
const fontSize = 10;
|
||||||
|
const titleSize = 20;
|
||||||
|
const sectionSize = 13;
|
||||||
|
const lineHeight = fontSize * 1.6;
|
||||||
|
|
||||||
|
let page = doc.addPage([pageWidth, pageHeight]);
|
||||||
|
let y = pageHeight - margin;
|
||||||
|
|
||||||
|
function newPage() {
|
||||||
|
page = doc.addPage([pageWidth, pageHeight]);
|
||||||
|
y = pageHeight - margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawText(text: string, size: number, color: any, offsetY: number) {
|
||||||
|
if (y < margin + offsetY) newPage();
|
||||||
|
page.drawText(text, { x: margin, y, size, font, color });
|
||||||
|
y -= offsetY;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawText(options.title, titleSize, rgb(0, 0, 0), titleSize * 1.8);
|
||||||
|
|
||||||
|
if (options.subtitle) {
|
||||||
|
drawText(options.subtitle, 9, rgb(0.4, 0.4, 0.4), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const section of options.sections) {
|
||||||
|
y -= 8;
|
||||||
|
drawText(section.title, sectionSize, rgb(0.1, 0.1, 0.1), sectionSize * 1.8);
|
||||||
|
|
||||||
|
for (const line of section.lines) {
|
||||||
|
if (!line) continue;
|
||||||
|
for (const chunk of line.split('\n')) {
|
||||||
|
drawText(chunk || ' ', fontSize, rgb(0.2, 0.2, 0.2), lineHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawText('--- End of Report ---', 8, rgb(0.6, 0.6, 0.6), 20);
|
||||||
|
|
||||||
|
return Buffer.from(await doc.save());
|
||||||
|
}
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
GENERATE_QUESTIONS_SYSTEM_PROMPT,
|
||||||
|
parseGeneratedQuestion,
|
||||||
|
QuestionBankService,
|
||||||
|
} from './question-bank.service';
|
||||||
|
import {
|
||||||
|
QuestionBankItem,
|
||||||
|
QuestionType,
|
||||||
|
QuestionDifficulty,
|
||||||
|
QuestionDimension,
|
||||||
|
QuestionBankItemStatus,
|
||||||
|
} from '../entities/question-bank-item.entity';
|
||||||
|
import { QuestionBank, QuestionBankStatus } from '../entities/question-bank.entity';
|
||||||
|
import { ModelConfigService } from '../../model-config/model-config.service';
|
||||||
|
|
||||||
|
const BANK_ID = 'test-bank-id';
|
||||||
|
const TEMPLATE_ID = 'test-template-id';
|
||||||
|
const USER_ID = 'user-1';
|
||||||
|
const TENANT_ID = 'default';
|
||||||
|
|
||||||
|
describe('GENERATE_QUESTIONS_SYSTEM_PROMPT', () => {
|
||||||
|
it('should require both choice and open question types', () => {
|
||||||
|
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toContain('choice');
|
||||||
|
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toContain('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should specify choice:open ratio', () => {
|
||||||
|
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toMatch(/3.*7|choice.*open|选择题.*简答题/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require judgment field for every question', () => {
|
||||||
|
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toContain('judgment');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require followupHints for open questions', () => {
|
||||||
|
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toContain('followupHints');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include a few-shot example for choice questions', () => {
|
||||||
|
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toMatch(/代码规范|AGENTS\.md|Prettier/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include a few-shot example for open questions', () => {
|
||||||
|
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toMatch(/会话管理|上下文窗口|开新窗口/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prohibit concept-definition questions', () => {
|
||||||
|
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toMatch(/禁止.*概念|不要.*定义|不能.*什么是/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require similar option lengths', () => {
|
||||||
|
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toMatch(/字符差|选项.*长度|长度.*相近/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prohibit "以上都对" and "以上都不对"', () => {
|
||||||
|
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toMatch(/禁止.*以上都对|以上都对.*禁止|禁止.*以上都不对/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require keyPoints from knowledge base', () => {
|
||||||
|
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toMatch(/key_points.*知识库|知识库.*key_points|知识库.*原文/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prohibit markdown wrapping in JSON output', () => {
|
||||||
|
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toMatch(/不要.*[Mm]arkdown|禁止.*[Mm]arkdown|不允许.*[Mm]arkdown|只输出.*JSON|纯JSON/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow difficulty STANDARD, ADVANCED, SPECIALIST', () => {
|
||||||
|
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toContain('STANDARD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow five dimensions: prompt, llm, ide, devPattern, workCapability', () => {
|
||||||
|
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toMatch(/prompt|llm|ide|devPattern|workCapability/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have reasonable prompt length', () => {
|
||||||
|
const len = GENERATE_QUESTIONS_SYSTEM_PROMPT.length;
|
||||||
|
expect(len).toBeGreaterThan(1500);
|
||||||
|
expect(len).toBeLessThan(8000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockChoiceQuestion = {
|
||||||
|
type: 'choice',
|
||||||
|
scenario: '你在编写代码,AI生成的代码风格不一致',
|
||||||
|
questionText: '【场景】你在编写一段复杂代码... 【问题】以下哪种做法最有效?',
|
||||||
|
options: ['A. 每次手动调整', 'B. 写入AGENTS.md', 'C. 用通用指令', 'D. Prettier格式化'],
|
||||||
|
correctAnswer: 'B',
|
||||||
|
judgment: 'B正确,因为规范文档化能从源头统一。A效率低。C模糊。D只解决表面问题。',
|
||||||
|
keyPoints: ['规范文档化', '源头统一'],
|
||||||
|
difficulty: 'STANDARD',
|
||||||
|
dimension: 'prompt',
|
||||||
|
basis: '知识库原文',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockOpenQuestion = {
|
||||||
|
type: 'open',
|
||||||
|
scenario: '你与AI反复修改文档30轮后AI开始遗忘关键约束',
|
||||||
|
questionText: '【场景】你与AI反复修改... 【问题】这种情况怎么造成的?应该怎么做?',
|
||||||
|
judgment: '关键考点:会话管理 通过标准:说出让AI总结+开新窗口即通过',
|
||||||
|
followupHints: ['追问如何保留之前结论'],
|
||||||
|
keyPoints: ['上下文窗口膨胀', '信息蒸馏'],
|
||||||
|
difficulty: 'STANDARD',
|
||||||
|
dimension: 'prompt',
|
||||||
|
basis: '知识库原文',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('parseGeneratedQuestion', () => {
|
||||||
|
describe('choice type', () => {
|
||||||
|
it('should parse choice question with MULTIPLE_CHOICE type', () => {
|
||||||
|
const item = parseGeneratedQuestion(mockChoiceQuestion, BANK_ID);
|
||||||
|
|
||||||
|
expect(item.questionType).toBe(QuestionType.MULTIPLE_CHOICE);
|
||||||
|
expect(item.options).toEqual([
|
||||||
|
'A. 每次手动调整',
|
||||||
|
'B. 写入AGENTS.md',
|
||||||
|
'C. 用通用指令',
|
||||||
|
'D. Prettier格式化',
|
||||||
|
]);
|
||||||
|
expect(item.options).toHaveLength(4);
|
||||||
|
expect(item.correctAnswer).toBe('B');
|
||||||
|
expect(item.judgment).toContain('B正确');
|
||||||
|
expect(item.followupHints).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store judgment for choice question', () => {
|
||||||
|
const item = parseGeneratedQuestion(mockChoiceQuestion, BANK_ID);
|
||||||
|
|
||||||
|
expect(item.judgment).toBe(
|
||||||
|
'B正确,因为规范文档化能从源头统一。A效率低。C模糊。D只解决表面问题。',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store keyPoints with technique tag', () => {
|
||||||
|
const q = {
|
||||||
|
...mockChoiceQuestion,
|
||||||
|
technique: '代码风格注入',
|
||||||
|
};
|
||||||
|
const item = parseGeneratedQuestion(q, BANK_ID);
|
||||||
|
|
||||||
|
expect(item.keyPoints[0]).toBe('【考查技巧】代码风格注入');
|
||||||
|
expect(item.keyPoints).toContain('规范文档化');
|
||||||
|
expect(item.keyPoints).toContain('源头统一');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('open type', () => {
|
||||||
|
it('should parse open question with SHORT_ANSWER type', () => {
|
||||||
|
const item = parseGeneratedQuestion(mockOpenQuestion, BANK_ID);
|
||||||
|
|
||||||
|
expect(item.questionType).toBe(QuestionType.SHORT_ANSWER);
|
||||||
|
expect(item.options).toBeNull();
|
||||||
|
expect(item.correctAnswer).toBeNull();
|
||||||
|
expect(item.judgment).toContain('通过标准');
|
||||||
|
expect(item.judgment).toContain('会话管理');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store followupHints array', () => {
|
||||||
|
const item = parseGeneratedQuestion(mockOpenQuestion, BANK_ID);
|
||||||
|
|
||||||
|
expect(item.followupHints).toEqual(['追问如何保留之前结论']);
|
||||||
|
expect(item.followupHints).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle open question with no followupHints', () => {
|
||||||
|
const q = { ...mockOpenQuestion, followupHints: [] };
|
||||||
|
const item = parseGeneratedQuestion(q, BANK_ID);
|
||||||
|
|
||||||
|
expect(item.followupHints).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle open question with 2 followupHints', () => {
|
||||||
|
const q = {
|
||||||
|
...mockOpenQuestion,
|
||||||
|
followupHints: ['追问1', '追问2'],
|
||||||
|
};
|
||||||
|
const item = parseGeneratedQuestion(q, BANK_ID);
|
||||||
|
|
||||||
|
expect(item.followupHints).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('common fields', () => {
|
||||||
|
it('should store keyPoints on both types', () => {
|
||||||
|
const choice = parseGeneratedQuestion(mockChoiceQuestion, BANK_ID);
|
||||||
|
const open = parseGeneratedQuestion(mockOpenQuestion, BANK_ID);
|
||||||
|
|
||||||
|
expect(choice.keyPoints.length).toBeGreaterThan(0);
|
||||||
|
expect(open.keyPoints.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing keyPoints gracefully', () => {
|
||||||
|
const q = { ...mockOpenQuestion, keyPoints: undefined };
|
||||||
|
const item = parseGeneratedQuestion(q, BANK_ID);
|
||||||
|
|
||||||
|
expect(item.keyPoints).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize dimension case-insensitively', () => {
|
||||||
|
const q1 = parseGeneratedQuestion(
|
||||||
|
{ ...mockOpenQuestion, dimension: 'LLM' },
|
||||||
|
BANK_ID,
|
||||||
|
);
|
||||||
|
const q2 = parseGeneratedQuestion(
|
||||||
|
{ ...mockOpenQuestion, dimension: 'llm' },
|
||||||
|
BANK_ID,
|
||||||
|
);
|
||||||
|
const q3 = parseGeneratedQuestion(
|
||||||
|
{ ...mockOpenQuestion, dimension: 'Llm' },
|
||||||
|
BANK_ID,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(q1.dimension).toBe(QuestionDimension.LLM);
|
||||||
|
expect(q2.dimension).toBe(QuestionDimension.LLM);
|
||||||
|
expect(q3.dimension).toBe(QuestionDimension.LLM);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default dimension to WORK_CAPABILITY for unknown values', () => {
|
||||||
|
const q = parseGeneratedQuestion(
|
||||||
|
{ ...mockOpenQuestion, dimension: 'unknown' },
|
||||||
|
BANK_ID,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(q.dimension).toBe(QuestionDimension.WORK_CAPABILITY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map all five dimensions correctly', () => {
|
||||||
|
const dims = ['prompt', 'llm', 'ide', 'devPattern', 'workCapability'];
|
||||||
|
const expected = [
|
||||||
|
QuestionDimension.PROMPT,
|
||||||
|
QuestionDimension.LLM,
|
||||||
|
QuestionDimension.IDE,
|
||||||
|
QuestionDimension.DEV_PATTERN,
|
||||||
|
QuestionDimension.WORK_CAPABILITY,
|
||||||
|
];
|
||||||
|
|
||||||
|
dims.forEach((dim, i) => {
|
||||||
|
const q = parseGeneratedQuestion(
|
||||||
|
{ ...mockOpenQuestion, dimension: dim },
|
||||||
|
BANK_ID,
|
||||||
|
);
|
||||||
|
expect(q.dimension).toBe(expected[i]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store difficulty correctly', () => {
|
||||||
|
const q = parseGeneratedQuestion(
|
||||||
|
{ ...mockOpenQuestion, difficulty: 'ADVANCED' },
|
||||||
|
BANK_ID,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(q.difficulty).toBe(QuestionDifficulty.ADVANCED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set bankId and status on all items', () => {
|
||||||
|
const item = parseGeneratedQuestion(mockOpenQuestion, BANK_ID);
|
||||||
|
|
||||||
|
expect(item.bankId).toBe(BANK_ID);
|
||||||
|
expect(item.status).toBe(QuestionBankItemStatus.PENDING_REVIEW);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store basis text', () => {
|
||||||
|
const item = parseGeneratedQuestion(mockChoiceQuestion, BANK_ID);
|
||||||
|
|
||||||
|
expect(item.basis).toBe('知识库原文');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('QuestionBankService - status guards', () => {
|
||||||
|
let service: QuestionBankService;
|
||||||
|
let bankRepo: any;
|
||||||
|
let itemRepo: any;
|
||||||
|
|
||||||
|
const mockRepository = () => ({
|
||||||
|
findOne: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
save: jest.fn().mockImplementation((entity: any) => Promise.resolve(entity)),
|
||||||
|
create: jest.fn((dto: any) => dto as any),
|
||||||
|
remove: jest.fn().mockResolvedValue(undefined),
|
||||||
|
createQueryBuilder: jest.fn().mockReturnValue({
|
||||||
|
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
skip: jest.fn().mockReturnThis(),
|
||||||
|
take: jest.fn().mockReturnThis(),
|
||||||
|
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||||
|
getMany: jest.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockModelConfig = () => ({
|
||||||
|
findDefaultByType: jest.fn().mockResolvedValue({
|
||||||
|
apiKey: 'sk-test',
|
||||||
|
modelId: 'deepseek-chat',
|
||||||
|
baseUrl: 'https://api.deepseek.com/v1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeBank = (overrides?: Partial<QuestionBank>): QuestionBank =>
|
||||||
|
({
|
||||||
|
id: 'bank-1',
|
||||||
|
name: 'Test Bank',
|
||||||
|
status: QuestionBankStatus.DRAFT,
|
||||||
|
templateId: TEMPLATE_ID,
|
||||||
|
tenantId: TENANT_ID,
|
||||||
|
...overrides,
|
||||||
|
}) as QuestionBank;
|
||||||
|
|
||||||
|
const makeItem = (overrides?: Partial<QuestionBankItem>): QuestionBankItem =>
|
||||||
|
({
|
||||||
|
id: 'item-1',
|
||||||
|
bankId: BANK_ID,
|
||||||
|
questionText: 'Question?',
|
||||||
|
questionType: QuestionType.SHORT_ANSWER,
|
||||||
|
keyPoints: ['kp1'],
|
||||||
|
difficulty: QuestionDifficulty.STANDARD,
|
||||||
|
dimension: QuestionDimension.PROMPT,
|
||||||
|
status: QuestionBankItemStatus.PENDING_REVIEW,
|
||||||
|
...overrides,
|
||||||
|
}) as QuestionBankItem;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
QuestionBankService,
|
||||||
|
{ provide: getRepositoryToken(QuestionBank), useFactory: mockRepository },
|
||||||
|
{ provide: getRepositoryToken(QuestionBankItem), useFactory: mockRepository },
|
||||||
|
{ provide: ModelConfigService, useFactory: mockModelConfig },
|
||||||
|
{ provide: ConfigService, useFactory: () => ({}) },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<QuestionBankService>(QuestionBankService);
|
||||||
|
bankRepo = module.get(getRepositoryToken(QuestionBank));
|
||||||
|
itemRepo = module.get(getRepositoryToken(QuestionBankItem));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
const createDto = { name: 'New Bank', templateId: TEMPLATE_ID };
|
||||||
|
|
||||||
|
it('create: should allow cross-tenant when DRAFT exists for another tenant', async () => {
|
||||||
|
bankRepo.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await service.create(createDto, USER_ID, TENANT_ID);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create: DRAFT exists same tenant → BadRequestException', async () => {
|
||||||
|
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.DRAFT }));
|
||||||
|
|
||||||
|
await expect(service.create(createDto, USER_ID, TENANT_ID)).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create: REJECTED exists same tenant → BadRequestException', async () => {
|
||||||
|
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.REJECTED }));
|
||||||
|
|
||||||
|
await expect(service.create(createDto, USER_ID, TENANT_ID)).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create: PUBLISHED exists same tenant → BadRequestException', async () => {
|
||||||
|
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.PUBLISHED }));
|
||||||
|
|
||||||
|
await expect(service.create(createDto, USER_ID, TENANT_ID)).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create: no existing bank → success', async () => {
|
||||||
|
bankRepo.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await service.create(createDto, USER_ID, TENANT_ID);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remove', () => {
|
||||||
|
it('remove: DRAFT → success', async () => {
|
||||||
|
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.DRAFT }));
|
||||||
|
|
||||||
|
await expect(service.remove('bank-1')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove: REJECTED → success', async () => {
|
||||||
|
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.REJECTED }));
|
||||||
|
|
||||||
|
await expect(service.remove('bank-1')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove: PUBLISHED → ForbiddenException', async () => {
|
||||||
|
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.PUBLISHED }));
|
||||||
|
|
||||||
|
await expect(service.remove('bank-1')).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeItem', () => {
|
||||||
|
it('removeItem: PENDING_REVIEW item → success', async () => {
|
||||||
|
bankRepo.findOne.mockResolvedValue(makeBank());
|
||||||
|
itemRepo.findOne.mockResolvedValue(makeItem({ status: QuestionBankItemStatus.PENDING_REVIEW }));
|
||||||
|
|
||||||
|
await expect(service.removeItem(BANK_ID, 'item-1')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeItem: PUBLISHED item → ForbiddenException', async () => {
|
||||||
|
bankRepo.findOne.mockResolvedValue(makeBank());
|
||||||
|
itemRepo.findOne.mockResolvedValue(makeItem({ status: QuestionBankItemStatus.PUBLISHED }));
|
||||||
|
|
||||||
|
await expect(service.removeItem(BANK_ID, 'item-1')).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateQuestions', () => {
|
||||||
|
it('generateQuestions: PUBLISHED bank → ForbiddenException', async () => {
|
||||||
|
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.PUBLISHED }));
|
||||||
|
|
||||||
|
await expect(service.generateQuestions('bank-1', 1, 'some content', TENANT_ID))
|
||||||
|
.rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generateQuestions: PENDING_REVIEW bank → ForbiddenException', async () => {
|
||||||
|
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.PENDING_REVIEW }));
|
||||||
|
|
||||||
|
await expect(service.generateQuestions('bank-1', 1, 'some content', TENANT_ID))
|
||||||
|
.rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -69,6 +69,180 @@ const DIMENSIONS = [
|
|||||||
QuestionDimension.WORK_CAPABILITY,
|
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()
|
@Injectable()
|
||||||
export class QuestionBankService {
|
export class QuestionBankService {
|
||||||
private readonly logger = new Logger(QuestionBankService.name);
|
private readonly logger = new Logger(QuestionBankService.name);
|
||||||
@@ -92,13 +266,11 @@ export class QuestionBankService {
|
|||||||
}
|
}
|
||||||
if (createDto.templateId) {
|
if (createDto.templateId) {
|
||||||
const existing = await this.bankRepository.findOne({
|
const existing = await this.bankRepository.findOne({
|
||||||
where: { templateId: createDto.templateId, tenantId: tenantId as any },
|
where: { templateId: createDto.templateId, tenantId: tenantId || undefined as any },
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.status === QuestionBankStatus.DRAFT || existing.status === QuestionBankStatus.REJECTED) {
|
if (existing.status === QuestionBankStatus.DRAFT || existing.status === QuestionBankStatus.REJECTED || existing.status === QuestionBankStatus.PUBLISHED) {
|
||||||
await this.bankRepository.remove(existing);
|
throw new BadRequestException('该模板已关联题库,请编辑已有题库或删除后重建');
|
||||||
} else {
|
|
||||||
throw new BadRequestException('该模板已关联有效题库,请编辑已有题库');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +294,7 @@ export class QuestionBankService {
|
|||||||
page?: number,
|
page?: number,
|
||||||
limit?: number,
|
limit?: number,
|
||||||
): Promise<{ data: QuestionBank[]; total: number } | QuestionBank[]> {
|
): Promise<{ data: QuestionBank[]; total: number } | QuestionBank[]> {
|
||||||
console.log('[QuestionBank findAll] userId:', userId, 'tenantId:', tenantId);
|
this.logger.log('[QuestionBank findAll] userId: ' + userId + ', tenantId: ' + tenantId);
|
||||||
const queryBuilder = this.bankRepository
|
const queryBuilder = this.bankRepository
|
||||||
.createQueryBuilder('bank')
|
.createQueryBuilder('bank')
|
||||||
.leftJoinAndSelect('bank.template', 'template');
|
.leftJoinAndSelect('bank.template', 'template');
|
||||||
@@ -175,6 +347,9 @@ export class QuestionBankService {
|
|||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
const bank = await this.findOne(id);
|
const bank = await this.findOne(id);
|
||||||
|
if (bank.status === QuestionBankStatus.PUBLISHED) {
|
||||||
|
throw new ForbiddenException('已发布的题库不可删除');
|
||||||
|
}
|
||||||
await this.bankRepository.remove(bank);
|
await this.bankRepository.remove(bank);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +442,9 @@ export class QuestionBankService {
|
|||||||
if (!item) {
|
if (!item) {
|
||||||
throw new NotFoundException(`QuestionBankItem with ID "${itemId}" not found`);
|
throw new NotFoundException(`QuestionBankItem with ID "${itemId}" not found`);
|
||||||
}
|
}
|
||||||
|
if (item.status === QuestionBankItemStatus.PUBLISHED) {
|
||||||
|
throw new ForbiddenException('已发布的题目不可删除');
|
||||||
|
}
|
||||||
await this.itemRepository.remove(item);
|
await this.itemRepository.remove(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,35 +473,14 @@ export class QuestionBankService {
|
|||||||
const model = new ChatOpenAI({
|
const model = new ChatOpenAI({
|
||||||
apiKey: modelConfig.apiKey || 'ollama',
|
apiKey: modelConfig.apiKey || 'ollama',
|
||||||
modelName: modelConfig.modelId,
|
modelName: modelConfig.modelId,
|
||||||
temperature: 0.7,
|
temperature: 0.1,
|
||||||
configuration: {
|
configuration: {
|
||||||
baseURL: modelConfig.baseUrl || 'https://api.deepseek.com/v1',
|
baseURL: modelConfig.baseUrl || 'https://api.deepseek.com/v1',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const systemPrompt = `你是一位专业的知识评估专家。请根据提供的知识库片段生成 ${count} 个唯一的测试题目。
|
const systemPrompt = GENERATE_QUESTIONS_SYSTEM_PROMPT;
|
||||||
|
const humanMsg = `【知识库内容 - 唯一来源】\n\n--- 开始 ---\n${knowledgeBaseContent}\n--- 结束 ---\n\n请按上述规则生成 ${count} 道题,choice:open 比例约 3:7。难度以 STANDARD 为主。`;
|
||||||
### 强制性语言规则:
|
|
||||||
**必须使用中文 (Simplified Chinese) 进行回复**。即使知识库内容是英文或其他语言,问题(question_text)和关键点(key_points)也必须使用中文。
|
|
||||||
|
|
||||||
### 多样性规则:
|
|
||||||
1. 禁止重复:绝对禁止生成相似的题目
|
|
||||||
2. 深度挖掘:从不同的角度出题,如流程、限制、优缺点、具体参数等
|
|
||||||
3. 随机扰动:从不同的逻辑链条出发
|
|
||||||
|
|
||||||
### 任务:
|
|
||||||
请以 JSON 数组格式返回 ${count} 个问题:
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"question_text": "问题内容",
|
|
||||||
"key_points": ["要点1", "要点2"],
|
|
||||||
"difficulty": "STANDARD|ADVANCED|SPECIALIST",
|
|
||||||
"dimension": "prompt|llm|ide|devPattern|workCapability",
|
|
||||||
"basis": "[n] 引用原文..."
|
|
||||||
}
|
|
||||||
]`;
|
|
||||||
|
|
||||||
const humanMsg = `请使用中文基于以下内容生成题目:\n\n${knowledgeBaseContent}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await model.invoke([
|
const response = await model.invoke([
|
||||||
@@ -341,35 +498,11 @@ export class QuestionBankService {
|
|||||||
parsedQuestions = [parsedQuestions];
|
parsedQuestions = [parsedQuestions];
|
||||||
}
|
}
|
||||||
|
|
||||||
const dimensionMap: Record<string, string> = {
|
|
||||||
'prompt': 'PROMPT',
|
|
||||||
'llm': 'LLM',
|
|
||||||
'ide': 'IDE',
|
|
||||||
'devPattern': 'DEV_PATTERN',
|
|
||||||
'workCapability': 'WORK_CAPABILITY',
|
|
||||||
};
|
|
||||||
|
|
||||||
const difficultyMap: Record<string, string> = {
|
|
||||||
'STANDARD': 'STANDARD',
|
|
||||||
'ADVANCED': 'ADVANCED',
|
|
||||||
'SPECIALIST': 'SPECIALIST',
|
|
||||||
};
|
|
||||||
|
|
||||||
const items: QuestionBankItem[] = [];
|
const items: QuestionBankItem[] = [];
|
||||||
for (const q of parsedQuestions) {
|
for (const q of parsedQuestions) {
|
||||||
const dimension = dimensionMap[q.dimension?.toLowerCase()] || 'WORK_CAPABILITY';
|
const item = this.itemRepository.create(
|
||||||
const difficulty = difficultyMap[q.difficulty?.toUpperCase()] || 'STANDARD';
|
parseGeneratedQuestion(q, bankId),
|
||||||
|
);
|
||||||
const item = this.itemRepository.create({
|
|
||||||
bankId,
|
|
||||||
questionText: q.question_text,
|
|
||||||
questionType: QuestionType.SHORT_ANSWER,
|
|
||||||
keyPoints: q.key_points || [],
|
|
||||||
difficulty: difficulty as QuestionDifficulty,
|
|
||||||
dimension: dimension as QuestionDimension,
|
|
||||||
basis: q.basis,
|
|
||||||
status: QuestionBankItemStatus.PENDING_REVIEW,
|
|
||||||
});
|
|
||||||
items.push(item);
|
items.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,16 +519,12 @@ export class QuestionBankService {
|
|||||||
async selectQuestions(
|
async selectQuestions(
|
||||||
bankId: string,
|
bankId: string,
|
||||||
count: number,
|
count: number,
|
||||||
|
dimensionWeights?: Array<{ name: string; weight: number }>,
|
||||||
): Promise<QuestionBankItem[]> {
|
): Promise<QuestionBankItem[]> {
|
||||||
const bank = await this.findOne(bankId);
|
const bank = await this.findOne(bankId);
|
||||||
if (bank.status !== QuestionBankStatus.PUBLISHED) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
'Only PUBLISHED banks can be used for selection',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allItems = await this.itemRepository.find({
|
const allItems = await this.itemRepository.find({
|
||||||
where: { bankId },
|
where: { bankId, status: QuestionBankItemStatus.PUBLISHED },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (allItems.length === 0) {
|
if (allItems.length === 0) {
|
||||||
@@ -405,40 +534,51 @@ export class QuestionBankService {
|
|||||||
|
|
||||||
const usedIds = new Set<string>();
|
const usedIds = new Set<string>();
|
||||||
const selected: QuestionBankItem[] = [];
|
const selected: QuestionBankItem[] = [];
|
||||||
const availableItems = [...allItems];
|
let availableItems = [...allItems];
|
||||||
|
|
||||||
let dimIdx = 0;
|
if (dimensionWeights && dimensionWeights.length > 0) {
|
||||||
while (selected.length < count && availableItems.length > 0) {
|
const totalWeight = dimensionWeights.reduce((s, d) => s + d.weight, 0);
|
||||||
const dim = DIMENSIONS[dimIdx % DIMENSIONS.length];
|
for (const dw of dimensionWeights) {
|
||||||
dimIdx++;
|
const dimName = dw.name as QuestionDimension;
|
||||||
|
const targetForDim = Math.round(count * dw.weight / totalWeight);
|
||||||
const available = availableItems.filter(
|
const pool = availableItems.filter(i => i.dimension === dimName && !usedIds.has(i.id));
|
||||||
(i) => i.dimension === dim && !usedIds.has(i.id),
|
this.shuffleArray(pool);
|
||||||
);
|
const take = Math.min(targetForDim, pool.length);
|
||||||
|
for (let i = 0; i < take; i++) {
|
||||||
if (available.length > 0) {
|
selected.push(pool[i]);
|
||||||
const idx = Math.floor(Math.random() * available.length);
|
usedIds.add(pool[i].id);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
availableItems = availableItems.filter(i => !usedIds.has(i.id));
|
||||||
if (dimIdx >= DIMENSIONS.length * 3) {
|
this.shuffleArray(availableItems);
|
||||||
break;
|
while (selected.length < count && availableItems.length > 0) {
|
||||||
|
const item = availableItems.pop()!;
|
||||||
|
selected.push(item);
|
||||||
|
usedIds.add(item.id);
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
let dimIdx = 0;
|
||||||
if (selected.length < count && availableItems.length > 0) {
|
while (selected.length < count && availableItems.length > 0) {
|
||||||
const shuffled = this.shuffleArray([...availableItems]);
|
const dim = DIMENSIONS[dimIdx % DIMENSIONS.length];
|
||||||
for (const item of shuffled) {
|
dimIdx++;
|
||||||
if (selected.length >= count) break;
|
const pool = availableItems.filter(i => i.dimension === dim && !usedIds.has(i.id));
|
||||||
if (!usedIds.has(item.id)) {
|
if (pool.length > 0) {
|
||||||
|
const idx = Math.floor(Math.random() * pool.length);
|
||||||
|
const item = pool[idx];
|
||||||
selected.push(item);
|
selected.push(item);
|
||||||
usedIds.add(item.id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -456,7 +596,7 @@ export class QuestionBankService {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[selectQuestions] Selected ${selected.length} questions from bank ${bankId}`,
|
`[selectQuestions] Selected ${selected.length} questions from bank ${bankId}`,
|
||||||
);
|
);
|
||||||
return selected;
|
return this.shuffleArray(selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
async batchReviewItems(
|
async batchReviewItems(
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { TemplateService } from './template.service';
|
||||||
|
import { AssessmentTemplate } from '../entities/assessment-template.entity';
|
||||||
|
import { TenantService } from '../../tenant/tenant.service';
|
||||||
|
|
||||||
|
describe('TemplateService', () => {
|
||||||
|
let service: TemplateService;
|
||||||
|
let repo: any;
|
||||||
|
|
||||||
|
const mockTenantService = () => ({
|
||||||
|
canAccessTenant: jest.fn().mockResolvedValue(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
TemplateService,
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(AssessmentTemplate),
|
||||||
|
useFactory: () => ({
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ provide: TenantService, useFactory: mockTenantService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<TemplateService>(TemplateService);
|
||||||
|
repo = module.get(getRepositoryToken(AssessmentTemplate));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('should throw BadRequestException when linkedGroupIds is empty', async () => {
|
||||||
|
const dto = { name: 'Test', linkedGroupIds: [] };
|
||||||
|
await expect(
|
||||||
|
service.create(dto as any, 'user-id', 'tenant-id'),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException when dimensions is empty array', async () => {
|
||||||
|
const dto = { name: 'Test', linkedGroupIds: ['g-1'], dimensions: [] };
|
||||||
|
await expect(
|
||||||
|
service.create(dto as any, 'user-id', 'tenant-id'),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create template with valid data', async () => {
|
||||||
|
const dto = {
|
||||||
|
name: 'Valid Template',
|
||||||
|
linkedGroupIds: ['g-1'],
|
||||||
|
dimensions: [{ name: 'PROMPT', label: 'Prompt', weight: 0.5 }],
|
||||||
|
};
|
||||||
|
repo.create.mockReturnValue({ id: 'new-id', ...dto });
|
||||||
|
repo.save.mockResolvedValue({ id: 'new-id', ...dto });
|
||||||
|
|
||||||
|
const result = await service.create(dto as any, 'user-id', 'tenant-id');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.id).toBe('new-id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('should throw BadRequestException when clearing linkedGroupIds', async () => {
|
||||||
|
repo.findOne.mockResolvedValue({
|
||||||
|
id: 'tpl-1', name: 'Existing',
|
||||||
|
linkedGroupIds: ['g-1'],
|
||||||
|
dimensions: [{ name: 'PROMPT', label: 'Prompt', weight: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update('tpl-1', { linkedGroupIds: [] } as any, 'user-id', 'tenant-id'),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException when clearing dimensions', async () => {
|
||||||
|
repo.findOne.mockResolvedValue({
|
||||||
|
id: 'tpl-1', name: 'Existing',
|
||||||
|
linkedGroupIds: ['g-1'],
|
||||||
|
dimensions: [{ name: 'PROMPT', label: 'Prompt', weight: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update('tpl-1', { dimensions: [] } as any, 'user-id', 'tenant-id'),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
@@ -18,14 +19,37 @@ export class TemplateService {
|
|||||||
private readonly tenantService: TenantService,
|
private readonly tenantService: TenantService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private validateRequiredFields(data: {
|
||||||
|
linkedGroupIds?: string[] | null;
|
||||||
|
dimensions?: Array<{ name: string; label?: string; weight?: number }> | null;
|
||||||
|
}) {
|
||||||
|
if (data.linkedGroupIds != null && Array.isArray(data.linkedGroupIds)) {
|
||||||
|
if (data.linkedGroupIds.length === 0) {
|
||||||
|
throw new BadRequestException('At least one knowledge group must be linked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.dimensions != null && Array.isArray(data.dimensions)) {
|
||||||
|
if (data.dimensions.length === 0) {
|
||||||
|
throw new BadRequestException('At least one dimension must be defined');
|
||||||
|
}
|
||||||
|
for (const d of data.dimensions) {
|
||||||
|
if (!d.name) {
|
||||||
|
throw new BadRequestException('Each dimension must have a name');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
createDto: CreateTemplateDto,
|
createDto: CreateTemplateDto,
|
||||||
userId: string,
|
userId: string,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
): Promise<AssessmentTemplate> {
|
): Promise<AssessmentTemplate> {
|
||||||
|
this.validateRequiredFields(createDto);
|
||||||
const { ...data } = createDto;
|
const { ...data } = createDto;
|
||||||
const template = this.templateRepository.create({
|
const template = this.templateRepository.create({
|
||||||
...data,
|
...data,
|
||||||
|
isActive: data.isActive !== undefined ? data.isActive : true,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
tenantId,
|
tenantId,
|
||||||
});
|
});
|
||||||
@@ -76,6 +100,8 @@ export class TemplateService {
|
|||||||
tenantId: string,
|
tenantId: string,
|
||||||
): Promise<AssessmentTemplate> {
|
): Promise<AssessmentTemplate> {
|
||||||
const template = await this.findOne(id, userId, tenantId);
|
const template = await this.findOne(id, userId, tenantId);
|
||||||
|
const merged = { ...template, ...updateDto };
|
||||||
|
this.validateRequiredFields(merged);
|
||||||
Object.assign(template, updateDto);
|
Object.assign(template, updateDto);
|
||||||
return this.templateRepository.save(template);
|
return this.templateRepository.save(template);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
CanActivate,
|
CanActivate,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
@@ -25,6 +26,8 @@ import * as path from 'path';
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CombinedAuthGuard implements CanActivate {
|
export class CombinedAuthGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(CombinedAuthGuard.name);
|
||||||
|
|
||||||
// We extend AuthGuard('jwt') functionality by composition
|
// We extend AuthGuard('jwt') functionality by composition
|
||||||
private jwtGuard: ReturnType<typeof AuthGuard>;
|
private jwtGuard: ReturnType<typeof AuthGuard>;
|
||||||
|
|
||||||
@@ -55,7 +58,7 @@ export class CombinedAuthGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
this.logger.log(
|
||||||
`[CombinedAuthGuard] Checking auth for route: ${request.method} ${request.url}`,
|
`[CombinedAuthGuard] Checking auth for route: ${request.method} ${request.url}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -160,7 +163,7 @@ export class CombinedAuthGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[CombinedAuthGuard] JWT Auth Error:`, e);
|
this.logger.error('[CombinedAuthGuard] JWT Auth Error: ' + e);
|
||||||
throw e instanceof UnauthorizedException
|
throw e instanceof UnauthorizedException
|
||||||
? e
|
? e
|
||||||
: new UnauthorizedException('Authentication required');
|
: new UnauthorizedException('Authentication required');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Logger,
|
||||||
Post,
|
Post,
|
||||||
Request,
|
Request,
|
||||||
Res,
|
Res,
|
||||||
@@ -36,6 +37,8 @@ class StreamChatDto {
|
|||||||
@Controller('chat')
|
@Controller('chat')
|
||||||
@UseGuards(CombinedAuthGuard)
|
@UseGuards(CombinedAuthGuard)
|
||||||
export class ChatController {
|
export class ChatController {
|
||||||
|
private readonly logger = new Logger(ChatController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private chatService: ChatService,
|
private chatService: ChatService,
|
||||||
private modelConfigService: ModelConfigService,
|
private modelConfigService: ModelConfigService,
|
||||||
@@ -49,7 +52,7 @@ export class ChatController {
|
|||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
console.log('Full Request Body:', JSON.stringify(body, null, 2));
|
this.logger.log('Full Request Body:', JSON.stringify(body, null, 2));
|
||||||
const {
|
const {
|
||||||
message,
|
message,
|
||||||
history = [],
|
history = [],
|
||||||
@@ -71,22 +74,22 @@ export class ChatController {
|
|||||||
} = body;
|
} = body;
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
console.log('=== Chat Debug Info ===');
|
this.logger.log('=== Chat Debug Info ===');
|
||||||
console.log('User ID:', userId);
|
this.logger.log('User ID:', userId);
|
||||||
console.log('Message:', message);
|
this.logger.log('Message:', message);
|
||||||
console.log('User Language:', userLanguage);
|
this.logger.log('User Language:', userLanguage);
|
||||||
console.log('Selected Embedding ID:', selectedEmbeddingId);
|
this.logger.log('Selected Embedding ID:', selectedEmbeddingId);
|
||||||
console.log('Selected LLM ID:', selectedLLMId);
|
this.logger.log('Selected LLM ID:', selectedLLMId);
|
||||||
console.log('Selected Groups:', selectedGroups);
|
this.logger.log('Selected Groups:', selectedGroups);
|
||||||
console.log('Selected Files:', selectedFiles);
|
this.logger.log('Selected Files:', selectedFiles);
|
||||||
console.log('History ID:', historyId);
|
this.logger.log('History ID:', historyId);
|
||||||
console.log('Temperature:', temperature);
|
this.logger.log('Temperature:', temperature);
|
||||||
console.log('Max Tokens:', maxTokens);
|
this.logger.log('Max Tokens:', maxTokens);
|
||||||
console.log('Top K:', topK);
|
this.logger.log('Top K:', topK);
|
||||||
console.log('Similarity Threshold:', similarityThreshold);
|
this.logger.log('Similarity Threshold:', similarityThreshold);
|
||||||
console.log('Rerank Similarity Threshold:', rerankSimilarityThreshold);
|
this.logger.log('Rerank Similarity Threshold:', rerankSimilarityThreshold);
|
||||||
console.log('Query Expansion:', enableQueryExpansion);
|
this.logger.log('Query Expansion:', enableQueryExpansion);
|
||||||
console.log('HyDE:', enableHyDE);
|
this.logger.log('HyDE:', enableHyDE);
|
||||||
|
|
||||||
const role = req.user.role;
|
const role = req.user.role;
|
||||||
const tenantId = req.user.tenantId;
|
const tenantId = req.user.tenantId;
|
||||||
@@ -105,14 +108,14 @@ export class ChatController {
|
|||||||
if (selectedLLMId) {
|
if (selectedLLMId) {
|
||||||
// Find specifically selected model
|
// Find specifically selected model
|
||||||
llmModel = await this.modelConfigService.findOne(selectedLLMId);
|
llmModel = await this.modelConfigService.findOne(selectedLLMId);
|
||||||
console.log('使用选中的LLM模型:', llmModel.name);
|
this.logger.log('使用选中的LLM模型:', llmModel.name);
|
||||||
} else {
|
} else {
|
||||||
// Use organization's default LLM from Index Chat Config (strict)
|
// Use organization's default LLM from Index Chat Config (strict)
|
||||||
llmModel = await this.modelConfigService.findDefaultByType(
|
llmModel = await this.modelConfigService.findDefaultByType(
|
||||||
tenantId,
|
tenantId,
|
||||||
ModelType.LLM,
|
ModelType.LLM,
|
||||||
);
|
);
|
||||||
console.log(
|
this.logger.log(
|
||||||
'最终使用的LLM模型 (默认):',
|
'最终使用的LLM模型 (默认):',
|
||||||
llmModel ? llmModel.name : '无',
|
llmModel ? llmModel.name : '无',
|
||||||
);
|
);
|
||||||
@@ -162,7 +165,7 @@ export class ChatController {
|
|||||||
res.write('data: [DONE]\n\n');
|
res.write('data: [DONE]\n\n');
|
||||||
res.end();
|
res.end();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Stream chat error:', error);
|
this.logger.error('Stream chat error:', error);
|
||||||
try {
|
try {
|
||||||
res.write(
|
res.write(
|
||||||
`data: ${JSON.stringify({ type: 'error', data: error.message || 'Server Error' })}\n\n`,
|
`data: ${JSON.stringify({ type: 'error', data: error.message || 'Server Error' })}\n\n`,
|
||||||
@@ -170,7 +173,7 @@ export class ChatController {
|
|||||||
res.write('data: [DONE]\n\n');
|
res.write('data: [DONE]\n\n');
|
||||||
res.end();
|
res.end();
|
||||||
} catch (writeError) {
|
} catch (writeError) {
|
||||||
console.error('Failed to write error response:', writeError);
|
this.logger.error('Failed to write error response:', writeError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,7 +223,7 @@ export class ChatController {
|
|||||||
res.write('data: [DONE]\n\n');
|
res.write('data: [DONE]\n\n');
|
||||||
res.end();
|
res.end();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Stream assist error:', error);
|
this.logger.error('Stream assist error:', error);
|
||||||
res.write(
|
res.write(
|
||||||
`data: ${JSON.stringify({ type: 'error', data: error.message || 'Server Error' })}\n\n`,
|
`data: ${JSON.stringify({ type: 'error', data: error.message || 'Server Error' })}\n\n`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -71,30 +71,30 @@ export class ChatService {
|
|||||||
enableHyDE?: boolean, // New
|
enableHyDE?: boolean, // New
|
||||||
tenantId?: string, // New: tenant isolation
|
tenantId?: string, // New: tenant isolation
|
||||||
): AsyncGenerator<{ type: 'content' | 'sources' | 'historyId'; data: any }> {
|
): AsyncGenerator<{ type: 'content' | 'sources' | 'historyId'; data: any }> {
|
||||||
console.log('=== ChatService.streamChat ===');
|
this.logger.log('=== ChatService.streamChat ===');
|
||||||
console.log('User ID:', userId);
|
this.logger.log('User ID:', userId);
|
||||||
console.log('User language:', userLanguage);
|
this.logger.log('User language:', userLanguage);
|
||||||
console.log('Selected embedding model ID:', selectedEmbeddingId);
|
this.logger.log('Selected embedding model ID:', selectedEmbeddingId);
|
||||||
console.log('Selected groups:', selectedGroups);
|
this.logger.log('Selected groups:', selectedGroups);
|
||||||
console.log('Selected files:', selectedFiles);
|
this.logger.log('Selected files:', selectedFiles);
|
||||||
console.log('History ID:', historyId);
|
this.logger.log('History ID:', historyId);
|
||||||
console.log('Temperature:', temperature);
|
this.logger.log('Temperature:', temperature);
|
||||||
console.log('Max Tokens:', maxTokens);
|
this.logger.log('Max Tokens:', maxTokens);
|
||||||
console.log('Top K:', topK);
|
this.logger.log('Top K:', topK);
|
||||||
console.log('Similarity threshold:', similarityThreshold);
|
this.logger.log('Similarity threshold:', similarityThreshold);
|
||||||
console.log('Rerank threshold:', rerankSimilarityThreshold);
|
this.logger.log('Rerank threshold:', rerankSimilarityThreshold);
|
||||||
console.log('Query expansion:', enableQueryExpansion);
|
this.logger.log('Query expansion:', enableQueryExpansion);
|
||||||
console.log('HyDE:', enableHyDE);
|
this.logger.log('HyDE:', enableHyDE);
|
||||||
console.log('Model configuration:', {
|
this.logger.log('Model configuration:', {
|
||||||
name: modelConfig.name,
|
name: modelConfig.name,
|
||||||
modelId: modelConfig.modelId,
|
modelId: modelConfig.modelId,
|
||||||
baseUrl: modelConfig.baseUrl,
|
baseUrl: modelConfig.baseUrl,
|
||||||
});
|
});
|
||||||
console.log(
|
this.logger.log(
|
||||||
'API Key prefix:',
|
'API Key prefix:',
|
||||||
modelConfig.apiKey?.substring(0, 10) + '...',
|
modelConfig.apiKey?.substring(0, 10) + '...',
|
||||||
);
|
);
|
||||||
console.log('API Key length:', modelConfig.apiKey?.length);
|
this.logger.log('API Key length:', modelConfig.apiKey?.length);
|
||||||
|
|
||||||
// Get current language setting (keeping LANGUAGE_CONFIG for backward compatibility, now uses i18n service)
|
// Get current language setting (keeping LANGUAGE_CONFIG for backward compatibility, now uses i18n service)
|
||||||
// Use actual language based on user settings
|
// Use actual language based on user settings
|
||||||
@@ -113,7 +113,7 @@ export class ChatService {
|
|||||||
selectedGroups,
|
selectedGroups,
|
||||||
);
|
);
|
||||||
currentHistoryId = searchHistory.id;
|
currentHistoryId = searchHistory.id;
|
||||||
console.log(
|
this.logger.log(
|
||||||
this.i18nService.getMessage(
|
this.i18nService.getMessage(
|
||||||
'creatingHistory',
|
'creatingHistory',
|
||||||
effectiveUserLanguage,
|
effectiveUserLanguage,
|
||||||
@@ -143,7 +143,7 @@ export class ChatService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
this.logger.log(
|
||||||
this.i18nService.getMessage(
|
this.i18nService.getMessage(
|
||||||
'usingEmbeddingModel',
|
'usingEmbeddingModel',
|
||||||
effectiveUserLanguage,
|
effectiveUserLanguage,
|
||||||
@@ -156,7 +156,7 @@ export class ChatService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 2. Search using user's query directly
|
// 2. Search using user's query directly
|
||||||
console.log(
|
this.logger.log(
|
||||||
this.i18nService.getMessage('startingSearch', effectiveUserLanguage),
|
this.i18nService.getMessage('startingSearch', effectiveUserLanguage),
|
||||||
);
|
);
|
||||||
yield {
|
yield {
|
||||||
@@ -204,7 +204,7 @@ export class ChatService {
|
|||||||
// HybridSearch returns ES hit structure, but RagSearchResult is normalized
|
// HybridSearch returns ES hit structure, but RagSearchResult is normalized
|
||||||
// BuildContext expects {fileName, content}. RagSearchResult has these
|
// BuildContext expects {fileName, content}. RagSearchResult has these
|
||||||
searchResults = ragResults;
|
searchResults = ragResults;
|
||||||
console.log(
|
this.logger.log(
|
||||||
this.i18nService.getMessage(
|
this.i18nService.getMessage(
|
||||||
'searchResultsCount',
|
'searchResultsCount',
|
||||||
effectiveUserLanguage,
|
effectiveUserLanguage,
|
||||||
@@ -274,7 +274,7 @@ export class ChatService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (searchError) {
|
} catch (searchError) {
|
||||||
console.error(
|
this.logger.error(
|
||||||
this.i18nService.getMessage(
|
this.i18nService.getMessage(
|
||||||
'searchFailedLog',
|
'searchFailedLog',
|
||||||
effectiveUserLanguage,
|
effectiveUserLanguage,
|
||||||
@@ -461,14 +461,14 @@ ${instruction}`;
|
|||||||
try {
|
try {
|
||||||
// Join keywords into search string
|
// Join keywords into search string
|
||||||
const combinedQuery = keywords.join(' ');
|
const combinedQuery = keywords.join(' ');
|
||||||
console.log(
|
this.logger.log(
|
||||||
this.i18nService.getMessage('searchString', userLanguage) +
|
this.i18nService.getMessage('searchString', userLanguage) +
|
||||||
combinedQuery,
|
combinedQuery,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if embedding model ID is provided
|
// Check if embedding model ID is provided
|
||||||
if (!embeddingModelId) {
|
if (!embeddingModelId) {
|
||||||
console.log(
|
this.logger.log(
|
||||||
this.i18nService.getMessage(
|
this.i18nService.getMessage(
|
||||||
'embeddingModelIdNotProvided',
|
'embeddingModelIdNotProvided',
|
||||||
userLanguage,
|
userLanguage,
|
||||||
@@ -478,7 +478,7 @@ ${instruction}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use actual embedding vector
|
// Use actual embedding vector
|
||||||
console.log(
|
this.logger.log(
|
||||||
this.i18nService.getMessage('generatingEmbeddings', userLanguage),
|
this.i18nService.getMessage('generatingEmbeddings', userLanguage),
|
||||||
);
|
);
|
||||||
const queryEmbedding = await this.embeddingService.getEmbeddings(
|
const queryEmbedding = await this.embeddingService.getEmbeddings(
|
||||||
@@ -486,7 +486,7 @@ ${instruction}`;
|
|||||||
embeddingModelId,
|
embeddingModelId,
|
||||||
);
|
);
|
||||||
const queryVector = queryEmbedding[0];
|
const queryVector = queryEmbedding[0];
|
||||||
console.log(
|
this.logger.log(
|
||||||
this.i18nService.getMessage('embeddingsGenerated', userLanguage) +
|
this.i18nService.getMessage('embeddingsGenerated', userLanguage) +
|
||||||
this.i18nService.getMessage('dimensions', userLanguage) +
|
this.i18nService.getMessage('dimensions', userLanguage) +
|
||||||
':',
|
':',
|
||||||
@@ -494,7 +494,7 @@ ${instruction}`;
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Hybrid search
|
// Hybrid search
|
||||||
console.log(
|
this.logger.log(
|
||||||
this.i18nService.getMessage('performingHybridSearch', userLanguage),
|
this.i18nService.getMessage('performingHybridSearch', userLanguage),
|
||||||
);
|
);
|
||||||
const results = await this.elasticsearchService.hybridSearch(
|
const results = await this.elasticsearchService.hybridSearch(
|
||||||
@@ -507,7 +507,7 @@ ${instruction}`;
|
|||||||
explicitFileIds, // Pass explicit file IDs
|
explicitFileIds, // Pass explicit file IDs
|
||||||
tenantId, // Pass tenant ID
|
tenantId, // Pass tenant ID
|
||||||
);
|
);
|
||||||
console.log(
|
this.logger.log(
|
||||||
this.i18nService.getMessage('esSearchCompleted', userLanguage) +
|
this.i18nService.getMessage('esSearchCompleted', userLanguage) +
|
||||||
this.i18nService.getMessage('resultsCount', userLanguage) +
|
this.i18nService.getMessage('resultsCount', userLanguage) +
|
||||||
':',
|
':',
|
||||||
@@ -516,7 +516,7 @@ ${instruction}`;
|
|||||||
|
|
||||||
return results.slice(0, 10);
|
return results.slice(0, 10);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
this.logger.error(
|
||||||
this.i18nService.getMessage('hybridSearchFailed', userLanguage) + ':',
|
this.i18nService.getMessage('hybridSearchFailed', userLanguage) + ':',
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
const logger = new Logger('JsonUtils');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely parses JSON from a string, handling markdown code blocks and leading/trailing text.
|
* Safely parses JSON from a string, handling markdown code blocks and leading/trailing text.
|
||||||
*/
|
*/
|
||||||
@@ -40,9 +44,9 @@ export function safeParseJson<T = any>(text: string): T | null {
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(jsonStr) as T;
|
return JSON.parse(jsonStr) as T;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[safeParseJson] Failed to parse JSON:', error);
|
logger.error('[safeParseJson] Failed to parse JSON:', error);
|
||||||
console.error('[safeParseJson] Original text:', text);
|
logger.error('[safeParseJson] Original text:', text);
|
||||||
console.error('[safeParseJson] Extracted string:', jsonStr);
|
logger.error('[safeParseJson] Extracted string:', jsonStr);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
Query,
|
Query,
|
||||||
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
||||||
import { RolesGuard } from '../auth/roles.guard';
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
@@ -24,6 +25,8 @@ import { I18nService } from '../i18n/i18n.service';
|
|||||||
@Controller('knowledge-groups')
|
@Controller('knowledge-groups')
|
||||||
@UseGuards(CombinedAuthGuard, RolesGuard)
|
@UseGuards(CombinedAuthGuard, RolesGuard)
|
||||||
export class KnowledgeGroupController {
|
export class KnowledgeGroupController {
|
||||||
|
private readonly logger = new Logger(KnowledgeGroupController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly groupService: KnowledgeGroupService,
|
private readonly groupService: KnowledgeGroupService,
|
||||||
private readonly i18nService: I18nService,
|
private readonly i18nService: I18nService,
|
||||||
@@ -43,7 +46,7 @@ export class KnowledgeGroupController {
|
|||||||
@Post()
|
@Post()
|
||||||
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
async create(@Body() createGroupDto: CreateGroupDto, @Request() req) {
|
async create(@Body() createGroupDto: CreateGroupDto, @Request() req) {
|
||||||
console.log('[KnowledgeGroup] create called, user:', req.user);
|
this.logger.log('[KnowledgeGroup] create called, user: ' + JSON.stringify(req.user));
|
||||||
return await this.groupService.create(
|
return await this.groupService.create(
|
||||||
req.user.id,
|
req.user.id,
|
||||||
req.user.tenantId,
|
req.user.tenantId,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Inject,
|
Inject,
|
||||||
@@ -47,6 +48,8 @@ export interface PaginatedGroups {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class KnowledgeGroupService {
|
export class KnowledgeGroupService {
|
||||||
|
private readonly logger = new Logger(KnowledgeGroupService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(KnowledgeGroup)
|
@InjectRepository(KnowledgeGroup)
|
||||||
private groupRepository: Repository<KnowledgeGroup>,
|
private groupRepository: Repository<KnowledgeGroup>,
|
||||||
@@ -62,7 +65,7 @@ export class KnowledgeGroupService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
): Promise<GroupWithFileCount[]> {
|
): Promise<GroupWithFileCount[]> {
|
||||||
console.log('[KnowledgeGroup findAll] userId:', userId, 'tenantId:', tenantId);
|
this.logger.log('[KnowledgeGroup findAll] userId: ' + userId + ', tenantId: ' + tenantId);
|
||||||
// Return all groups for the tenant with file counts
|
// Return all groups for the tenant with file counts
|
||||||
const queryBuilder = this.groupRepository
|
const queryBuilder = this.groupRepository
|
||||||
.createQueryBuilder('group')
|
.createQueryBuilder('group')
|
||||||
@@ -147,7 +150,7 @@ export class KnowledgeGroupService {
|
|||||||
tenantId: string,
|
tenantId: string,
|
||||||
createGroupDto: CreateGroupDto,
|
createGroupDto: CreateGroupDto,
|
||||||
): Promise<KnowledgeGroup> {
|
): Promise<KnowledgeGroup> {
|
||||||
console.log('[KnowledgeGroup create] userId:', userId, 'tenantId:', tenantId);
|
this.logger.log('[KnowledgeGroup create] userId: ' + userId + ', tenantId: ' + tenantId);
|
||||||
const group = this.groupRepository.create({
|
const group = this.groupRepository.create({
|
||||||
...createGroupDto,
|
...createGroupDto,
|
||||||
parentId: createGroupDto.parentId ?? null,
|
parentId: createGroupDto.parentId ?? null,
|
||||||
@@ -155,7 +158,7 @@ export class KnowledgeGroupService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const saved = await this.groupRepository.save(group);
|
const saved = await this.groupRepository.save(group);
|
||||||
console.log('[KnowledgeGroup create] saved group tenantId:', saved.tenantId);
|
this.logger.log('[KnowledgeGroup create] saved group tenantId: ' + saved.tenantId);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +232,7 @@ export class KnowledgeGroupService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
this.logger.error(
|
||||||
`Failed to delete file ${file.id} when deleting group ${id}`,
|
`Failed to delete file ${file.id} when deleting group ${id}`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
@@ -257,7 +260,6 @@ export class KnowledgeGroupService {
|
|||||||
throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
|
throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check permission using TenantService
|
|
||||||
const hasAccess = await this.tenantService.canAccessTenant(
|
const hasAccess = await this.tenantService.canAccessTenant(
|
||||||
userId,
|
userId,
|
||||||
group.tenantId,
|
group.tenantId,
|
||||||
@@ -269,7 +271,31 @@ export class KnowledgeGroupService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return group.knowledgeBases;
|
const allGroups = await this.groupRepository.find({
|
||||||
|
where: tenantId === null ? {} : { tenantId },
|
||||||
|
relations: ['knowledgeBases'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const childIds = new Set<string>();
|
||||||
|
const collectDescendantIds = (parentId: string) => {
|
||||||
|
for (const g of allGroups) {
|
||||||
|
if (g.parentId === parentId) {
|
||||||
|
childIds.add(g.id);
|
||||||
|
collectDescendantIds(g.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collectDescendantIds(groupId);
|
||||||
|
|
||||||
|
const result = [...(group.knowledgeBases || [])];
|
||||||
|
for (const childId of childIds) {
|
||||||
|
const childGroup = allGroups.find(g => g.id === childId);
|
||||||
|
if (childGroup?.knowledgeBases) {
|
||||||
|
result.push(...childGroup.knowledgeBases);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async addFilesToGroup(
|
async addFilesToGroup(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Note } from './note.entity';
|
import { Note } from './note.entity';
|
||||||
@@ -11,6 +11,8 @@ import { I18nService } from '../i18n/i18n.service';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NoteService {
|
export class NoteService {
|
||||||
|
private readonly logger = new Logger(NoteService.name);
|
||||||
|
|
||||||
// Directory will be created dynamically per user
|
// Directory will be created dynamically per user
|
||||||
private getScreenshotsDir(userId: string) {
|
private getScreenshotsDir(userId: string) {
|
||||||
return path.join(process.cwd(), 'uploads', 'notes-screenshots', userId);
|
return path.join(process.cwd(), 'uploads', 'notes-screenshots', userId);
|
||||||
@@ -153,7 +155,7 @@ export class NoteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Optional: Add logging to help debug permission issues
|
// Optional: Add logging to help debug permission issues
|
||||||
console.log(`User ${userId} attempting to add note to group ${groupId}`);
|
this.logger.log('User ' + userId + ' attempting to add note to group ' + groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (categoryId === '') {
|
if (categoryId === '') {
|
||||||
@@ -176,7 +178,7 @@ export class NoteService {
|
|||||||
screenshot.buffer,
|
screenshot.buffer,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('OCR extraction failed:', error);
|
this.logger.error('OCR extraction failed:', error);
|
||||||
// Continue without OCR text if extraction fails
|
// Continue without OCR text if extraction fails
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
|
Logger,
|
||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
@@ -14,6 +15,8 @@ import { I18nService } from '../i18n/i18n.service';
|
|||||||
@UseGuards(CombinedAuthGuard)
|
@UseGuards(CombinedAuthGuard)
|
||||||
@UseGuards(CombinedAuthGuard)
|
@UseGuards(CombinedAuthGuard)
|
||||||
export class OcrController {
|
export class OcrController {
|
||||||
|
private readonly logger = new Logger(OcrController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly ocrService: OcrService,
|
private readonly ocrService: OcrService,
|
||||||
private readonly i18n: I18nService,
|
private readonly i18n: I18nService,
|
||||||
@@ -22,14 +25,14 @@ export class OcrController {
|
|||||||
@Post('recognize')
|
@Post('recognize')
|
||||||
@UseInterceptors(FileInterceptor('image'))
|
@UseInterceptors(FileInterceptor('image'))
|
||||||
async recognizeText(@UploadedFile() image: Express.Multer.File) {
|
async recognizeText(@UploadedFile() image: Express.Multer.File) {
|
||||||
console.log('OCR recognition endpoint called');
|
this.logger.log('OCR recognition endpoint called');
|
||||||
if (!image) {
|
if (!image) {
|
||||||
console.error('No image uploaded');
|
this.logger.error('No image uploaded');
|
||||||
throw new Error(this.i18n.getMessage('noImageUploaded'));
|
throw new Error(this.i18n.getMessage('noImageUploaded'));
|
||||||
}
|
}
|
||||||
console.log(`Received image. Size: ${image.size} bytes`);
|
this.logger.log('Received image. Size: ' + image.size + ' bytes');
|
||||||
const text = await this.ocrService.extractTextFromImage(image.buffer);
|
const text = await this.ocrService.extractTextFromImage(image.buffer);
|
||||||
console.log(`OCR extraction completed. Text length: ${text.length}`);
|
this.logger.log('OCR extraction completed. Text length: ' + text.length);
|
||||||
return { text };
|
return { text };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export class UserService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
console.log(
|
this.logger.log(
|
||||||
`[UserService] Creating user: ${username}, isAdmin: ${isAdmin}`,
|
`[UserService] Creating user: ${username}, isAdmin: ${isAdmin}`,
|
||||||
);
|
);
|
||||||
const user = await this.usersRepository.save({
|
const user = await this.usersRepository.save({
|
||||||
@@ -403,10 +403,7 @@ export class UserService implements OnModuleInit {
|
|||||||
role: UserRole.SUPER_ADMIN,
|
role: UserRole.SUPER_ADMIN,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('\n=== Admin account created ===');
|
this.logger.log('Admin account created (username: admin, password: ' + randomPassword + ')');
|
||||||
console.log('Username: admin');
|
|
||||||
console.log('Password:', randomPassword);
|
|
||||||
console.log('========================================\n');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { CostControlService } from './cost-control.service';
|
|
||||||
import { User } from '../user/user.entity';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [TypeOrmModule.forFeature([User])],
|
|
||||||
providers: [CostControlService],
|
|
||||||
exports: [CostControlService],
|
|
||||||
})
|
|
||||||
export class CostControlModule {}
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cost control and quota management service
|
|
||||||
* Used to manage API call costs for Vision Pipeline
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { User } from '../user/user.entity';
|
|
||||||
|
|
||||||
export interface UserQuota {
|
|
||||||
userId: string;
|
|
||||||
monthlyCost: number; // Current month used cost
|
|
||||||
maxCost: number; // Monthly max cost
|
|
||||||
remaining: number; // Remaining cost
|
|
||||||
lastReset: Date; // Last reset time
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CostEstimate {
|
|
||||||
estimatedCost: number; // Estimated cost
|
|
||||||
estimatedTime: number; // Estimated time(seconds)
|
|
||||||
pageBreakdown: {
|
|
||||||
// Per-page breakdown
|
|
||||||
pageIndex: number;
|
|
||||||
cost: number;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CostControlService {
|
|
||||||
private readonly logger = new Logger(CostControlService.name);
|
|
||||||
private readonly COST_PER_PAGE = 0.01; // Cost per page(USD)
|
|
||||||
private readonly DEFAULT_MONTHLY_LIMIT = 100; // Default monthly limit(USD)
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private configService: ConfigService,
|
|
||||||
@InjectRepository(User)
|
|
||||||
private userRepository: Repository<User>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimate processing cost
|
|
||||||
*/
|
|
||||||
estimateCost(
|
|
||||||
pageCount: number,
|
|
||||||
quality: 'low' | 'medium' | 'high' = 'medium',
|
|
||||||
): CostEstimate {
|
|
||||||
// Adjust cost coefficient based on quality
|
|
||||||
const qualityMultiplier = {
|
|
||||||
low: 0.5,
|
|
||||||
medium: 1.0,
|
|
||||||
high: 1.5,
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseCost =
|
|
||||||
pageCount * this.COST_PER_PAGE * qualityMultiplier[quality];
|
|
||||||
const estimatedTime = pageCount * 3; // // Approximately 3 seconds
|
|
||||||
|
|
||||||
const pageBreakdown = Array.from({ length: pageCount }, (_, i) => ({
|
|
||||||
pageIndex: i + 1,
|
|
||||||
cost: this.COST_PER_PAGE * qualityMultiplier[quality],
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
estimatedCost: baseCost,
|
|
||||||
estimatedTime,
|
|
||||||
pageBreakdown,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check user quota
|
|
||||||
*/
|
|
||||||
async checkQuota(
|
|
||||||
userId: string,
|
|
||||||
estimatedCost: number,
|
|
||||||
): Promise<{
|
|
||||||
allowed: boolean;
|
|
||||||
quota: UserQuota;
|
|
||||||
reason?: string;
|
|
||||||
}> {
|
|
||||||
const quota = await this.getUserQuota(userId);
|
|
||||||
|
|
||||||
// Check monthly reset
|
|
||||||
this.checkAndResetMonthlyQuota(quota);
|
|
||||||
|
|
||||||
if (quota.remaining < estimatedCost) {
|
|
||||||
this.logger.warn(
|
|
||||||
`User ${userId} quota insufficient: remaining $${quota.remaining.toFixed(2)}, required $${estimatedCost.toFixed(2)}`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
quota,
|
|
||||||
reason: `Insufficient quota: remaining $${quota.remaining.toFixed(2)}, required $${estimatedCost.toFixed(2)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowed: true,
|
|
||||||
quota,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduct from quota
|
|
||||||
*/
|
|
||||||
async deductQuota(userId: string, actualCost: number): Promise<void> {
|
|
||||||
const quota = await this.getUserQuota(userId);
|
|
||||||
quota.monthlyCost += actualCost;
|
|
||||||
quota.remaining = quota.maxCost - quota.monthlyCost;
|
|
||||||
|
|
||||||
await this.userRepository.update(userId, {
|
|
||||||
monthlyCost: quota.monthlyCost,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Deducted $${actualCost.toFixed(2)} from user ${userId} quota. Remaining: $${quota.remaining.toFixed(2)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user quota
|
|
||||||
*/
|
|
||||||
async getUserQuota(userId: string): Promise<UserQuota> {
|
|
||||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new Error(`User ${userId} does not exist`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use default if user has no quota info
|
|
||||||
const monthlyCost = user.monthlyCost || 0;
|
|
||||||
const maxCost = user.maxCost || this.DEFAULT_MONTHLY_LIMIT;
|
|
||||||
const lastReset = user.lastQuotaReset || new Date();
|
|
||||||
|
|
||||||
return {
|
|
||||||
userId,
|
|
||||||
monthlyCost,
|
|
||||||
maxCost,
|
|
||||||
remaining: maxCost - monthlyCost,
|
|
||||||
lastReset,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check and reset monthly quota
|
|
||||||
*/
|
|
||||||
private checkAndResetMonthlyQuota(quota: UserQuota): void {
|
|
||||||
const now = new Date();
|
|
||||||
const lastReset = quota.lastReset;
|
|
||||||
|
|
||||||
// Check if crossed month
|
|
||||||
if (
|
|
||||||
now.getMonth() !== lastReset.getMonth() ||
|
|
||||||
now.getFullYear() !== lastReset.getFullYear()
|
|
||||||
) {
|
|
||||||
this.logger.log(`Reset monthly quota for user ${quota.userId}`);
|
|
||||||
|
|
||||||
// Reset quota
|
|
||||||
quota.monthlyCost = 0;
|
|
||||||
quota.remaining = quota.maxCost;
|
|
||||||
quota.lastReset = now;
|
|
||||||
|
|
||||||
// Update database
|
|
||||||
this.userRepository.update(quota.userId, {
|
|
||||||
monthlyCost: 0,
|
|
||||||
lastQuotaReset: now,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set user quota limit
|
|
||||||
*/
|
|
||||||
async setQuotaLimit(userId: string, maxCost: number): Promise<void> {
|
|
||||||
await this.userRepository.update(userId, { maxCost });
|
|
||||||
this.logger.log(`Set quota limit to $${maxCost} for user ${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cost report
|
|
||||||
*/
|
|
||||||
async getCostReport(
|
|
||||||
userId: string,
|
|
||||||
days: number = 30,
|
|
||||||
): Promise<{
|
|
||||||
totalCost: number;
|
|
||||||
dailyAverage: number;
|
|
||||||
pageStats: {
|
|
||||||
totalPages: number;
|
|
||||||
avgCostPerPage: number;
|
|
||||||
};
|
|
||||||
quotaUsage: number; // Percentage
|
|
||||||
}> {
|
|
||||||
const quota = await this.getUserQuota(userId);
|
|
||||||
const usagePercent = (quota.monthlyCost / quota.maxCost) * 100;
|
|
||||||
|
|
||||||
// Query history records here(if implemented)
|
|
||||||
// Return current quota info temporarily
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalCost: quota.monthlyCost,
|
|
||||||
dailyAverage: quota.monthlyCost / Math.max(days, 1),
|
|
||||||
pageStats: {
|
|
||||||
totalPages: Math.floor(quota.monthlyCost / this.COST_PER_PAGE),
|
|
||||||
avgCostPerPage: this.COST_PER_PAGE,
|
|
||||||
},
|
|
||||||
quotaUsage: usagePercent,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check cost warning threshold
|
|
||||||
*/
|
|
||||||
async checkWarningThreshold(userId: string): Promise<{
|
|
||||||
shouldWarn: boolean;
|
|
||||||
message: string;
|
|
||||||
}> {
|
|
||||||
const quota = await this.getUserQuota(userId);
|
|
||||||
const usagePercent = (quota.monthlyCost / quota.maxCost) * 100;
|
|
||||||
|
|
||||||
if (usagePercent >= 90) {
|
|
||||||
return {
|
|
||||||
shouldWarn: true,
|
|
||||||
message: `⚠️ Quota usage reached ${usagePercent.toFixed(1)}%. Remaining: $${quota.remaining.toFixed(2)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usagePercent >= 75) {
|
|
||||||
return {
|
|
||||||
shouldWarn: true,
|
|
||||||
message: `💡 Quota usage at ${usagePercent.toFixed(1)}%. Please monitor your costs carefully`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
shouldWarn: false,
|
|
||||||
message: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format cost display
|
|
||||||
*/
|
|
||||||
formatCost(cost: number): string {
|
|
||||||
return `$${cost.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format time display
|
|
||||||
*/
|
|
||||||
formatTime(seconds: number): string {
|
|
||||||
if (seconds < 60) {
|
|
||||||
return `${seconds.toFixed(0)}s`;
|
|
||||||
}
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const remainingSeconds = seconds % 60;
|
|
||||||
return `${minutes}m ${remainingSeconds.toFixed(0)}s`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,341 +0,0 @@
|
|||||||
/**
|
|
||||||
* Vision Pipeline Service (with cost control)
|
|
||||||
* This is an extended version of vision-pipeline.service.ts with integrated cost control
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import * as fs from 'fs/promises';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { LibreOfficeService } from '../libreoffice/libreoffice.service';
|
|
||||||
import { Pdf2ImageService } from '../pdf2image/pdf2image.service';
|
|
||||||
import { VisionService } from '../vision/vision.service';
|
|
||||||
import { ElasticsearchService } from '../elasticsearch/elasticsearch.service';
|
|
||||||
import { ModelConfigService } from '../model-config/model-config.service';
|
|
||||||
import {
|
|
||||||
PreciseModeOptions,
|
|
||||||
PipelineResult,
|
|
||||||
ProcessingStatus,
|
|
||||||
ModeRecommendation,
|
|
||||||
} from './vision-pipeline.interface';
|
|
||||||
import {
|
|
||||||
VisionModelConfig,
|
|
||||||
VisionAnalysisResult,
|
|
||||||
} from '../vision/vision.interface';
|
|
||||||
import { CostControlService } from './cost-control.service';
|
|
||||||
import { I18nService } from '../i18n/i18n.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class VisionPipelineCostAwareService {
|
|
||||||
private readonly logger = new Logger(VisionPipelineCostAwareService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private libreOffice: LibreOfficeService,
|
|
||||||
private pdf2Image: Pdf2ImageService,
|
|
||||||
private vision: VisionService,
|
|
||||||
private elasticsearch: ElasticsearchService,
|
|
||||||
private modelConfigService: ModelConfigService,
|
|
||||||
private configService: ConfigService,
|
|
||||||
private costControl: CostControlService,
|
|
||||||
private i18nService: I18nService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main processing flow: Precise mode (with cost control)
|
|
||||||
*/
|
|
||||||
async processPreciseMode(
|
|
||||||
filePath: string,
|
|
||||||
options: PreciseModeOptions,
|
|
||||||
): Promise<PipelineResult> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
const results: VisionAnalysisResult[] = [];
|
|
||||||
let processedPages = 0;
|
|
||||||
let failedPages = 0;
|
|
||||||
let totalCost = 0;
|
|
||||||
let pdfPath = filePath;
|
|
||||||
let imagesToProcess: any[] = [];
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Starting precise mode processing for ${options.fileName} (user: ${options.userId})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Step 1: Convert format
|
|
||||||
this.updateStatus('converting', 10, 'Converting document format...');
|
|
||||||
pdfPath = await this.convertToPDF(filePath);
|
|
||||||
|
|
||||||
// Step 2: Convert PDF to images
|
|
||||||
this.updateStatus('splitting', 30, 'Converting PDF to images...');
|
|
||||||
const conversionResult = await this.pdf2Image.convertToImages(pdfPath, {
|
|
||||||
density: 300,
|
|
||||||
quality: 85,
|
|
||||||
format: 'jpeg',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (conversionResult.images.length === 0) {
|
|
||||||
throw new Error(
|
|
||||||
this.i18nService.getMessage('pdfToImageConversionFailed'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit processing pages
|
|
||||||
imagesToProcess = options.maxPages
|
|
||||||
? conversionResult.images.slice(0, options.maxPages)
|
|
||||||
: conversionResult.images;
|
|
||||||
|
|
||||||
const pageCount = imagesToProcess.length;
|
|
||||||
|
|
||||||
// Step 3: Cost estimation and quota check
|
|
||||||
this.updateStatus(
|
|
||||||
'checking',
|
|
||||||
40,
|
|
||||||
'Checking quota and estimating cost...',
|
|
||||||
);
|
|
||||||
const costEstimate = this.costControl.estimateCost(pageCount);
|
|
||||||
this.logger.log(
|
|
||||||
`Estimated cost: $${costEstimate.estimatedCost.toFixed(2)}, Estimated time: ${this.costControl.formatTime(costEstimate.estimatedTime)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Quota check
|
|
||||||
const quotaCheck = await this.costControl.checkQuota(
|
|
||||||
options.userId,
|
|
||||||
costEstimate.estimatedCost,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!quotaCheck.allowed) {
|
|
||||||
throw new Error(quotaCheck.reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cost warning check
|
|
||||||
const warning = await this.costControl.checkWarningThreshold(
|
|
||||||
options.userId,
|
|
||||||
);
|
|
||||||
if (warning.shouldWarn) {
|
|
||||||
this.logger.warn(warning.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Get Vision model config
|
|
||||||
const modelConfig = await this.getVisionModelConfig(
|
|
||||||
options.userId,
|
|
||||||
options.modelId,
|
|
||||||
options.tenantId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 5: VL model analysis
|
|
||||||
this.updateStatus(
|
|
||||||
'analyzing',
|
|
||||||
50,
|
|
||||||
'Analyzing pages with Vision model...',
|
|
||||||
);
|
|
||||||
const batchResult = await this.vision.batchAnalyze(
|
|
||||||
imagesToProcess.map((img) => img.path),
|
|
||||||
modelConfig,
|
|
||||||
{
|
|
||||||
startIndex: 1,
|
|
||||||
skipQualityCheck: options.skipQualityCheck,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
totalCost = batchResult.estimatedCost;
|
|
||||||
processedPages = batchResult.successCount;
|
|
||||||
failedPages = batchResult.failedCount;
|
|
||||||
results.push(...batchResult.results);
|
|
||||||
|
|
||||||
// Step 6: Subtract actual cost
|
|
||||||
if (totalCost > 0) {
|
|
||||||
await this.costControl.deductQuota(options.userId, totalCost);
|
|
||||||
this.logger.log(`Actual cost deducted: $${totalCost.toFixed(2)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 7: Cleanup temp files
|
|
||||||
this.updateStatus(
|
|
||||||
'completed',
|
|
||||||
100,
|
|
||||||
'Processing completed. Cleaning up temp files...',
|
|
||||||
);
|
|
||||||
await this.pdf2Image.cleanupImages(imagesToProcess);
|
|
||||||
|
|
||||||
// Cleanup converted PDF file if converted
|
|
||||||
if (pdfPath !== filePath) {
|
|
||||||
try {
|
|
||||||
await fs.unlink(pdfPath);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(`Failed to cleanup converted PDF: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const duration = (Date.now() - startTime) / 1000;
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Precise mode completed: ${processedPages} pages processed, ` +
|
|
||||||
`cost: $${totalCost.toFixed(2)}, duration: ${duration.toFixed(1)}s`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
fileId: options.fileId,
|
|
||||||
fileName: options.fileName,
|
|
||||||
totalPages: conversionResult.totalPages,
|
|
||||||
processedPages,
|
|
||||||
failedPages,
|
|
||||||
results,
|
|
||||||
cost: totalCost,
|
|
||||||
duration,
|
|
||||||
mode: 'precise',
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Precise mode failed: ${error.message}`);
|
|
||||||
|
|
||||||
// Try to clean up temp files
|
|
||||||
try {
|
|
||||||
if (pdfPath !== filePath && pdfPath !== filePath) {
|
|
||||||
await fs.unlink(pdfPath);
|
|
||||||
}
|
|
||||||
if (imagesToProcess.length > 0) {
|
|
||||||
await this.pdf2Image.cleanupImages(imagesToProcess);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
fileId: options.fileId,
|
|
||||||
fileName: options.fileName,
|
|
||||||
totalPages: 0,
|
|
||||||
processedPages,
|
|
||||||
failedPages,
|
|
||||||
results: [],
|
|
||||||
cost: totalCost,
|
|
||||||
duration: (Date.now() - startTime) / 1000,
|
|
||||||
mode: 'precise',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Vision model configuration
|
|
||||||
*/
|
|
||||||
private async getVisionModelConfig(
|
|
||||||
userId: string,
|
|
||||||
modelId: string,
|
|
||||||
tenantId?: string,
|
|
||||||
): Promise<VisionModelConfig> {
|
|
||||||
const config = await this.modelConfigService.findOne(modelId);
|
|
||||||
|
|
||||||
if (!config) {
|
|
||||||
throw new Error(`Model config not found: ${modelId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// API key is optional - allows local models
|
|
||||||
|
|
||||||
return {
|
|
||||||
baseUrl: config.baseUrl || '',
|
|
||||||
apiKey: config.apiKey || '',
|
|
||||||
modelId: config.modelId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert to PDF
|
|
||||||
*/
|
|
||||||
private async convertToPDF(filePath: string): Promise<string> {
|
|
||||||
const ext = path.extname(filePath).toLowerCase();
|
|
||||||
|
|
||||||
// Return as-is if already PDF
|
|
||||||
if (ext === '.pdf') {
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call LibreOffice to convert
|
|
||||||
return await this.libreOffice.convertToPDF(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format detection and mode recommendation (with cost estimation)
|
|
||||||
*/
|
|
||||||
async recommendMode(filePath: string): Promise<ModeRecommendation> {
|
|
||||||
const ext = path.extname(filePath).toLowerCase();
|
|
||||||
const stats = await fs.stat(filePath);
|
|
||||||
const sizeMB = stats.size / (1024 * 1024);
|
|
||||||
|
|
||||||
const supportedFormats = [
|
|
||||||
'.pdf',
|
|
||||||
'.doc',
|
|
||||||
'.docx',
|
|
||||||
'.ppt',
|
|
||||||
'.pptx',
|
|
||||||
'.xls',
|
|
||||||
'.xlsx',
|
|
||||||
];
|
|
||||||
const preciseFormats = ['.pdf', '.doc', '.docx', '.ppt', '.pptx'];
|
|
||||||
|
|
||||||
if (!supportedFormats.includes(ext)) {
|
|
||||||
return {
|
|
||||||
recommendedMode: 'fast',
|
|
||||||
reason: `Unsupported file format: ${ext}`,
|
|
||||||
warnings: ['Using fast mode (text extraction only)'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!preciseFormats.includes(ext)) {
|
|
||||||
return {
|
|
||||||
recommendedMode: 'fast',
|
|
||||||
reason: `Format ${ext} does not support precise mode`,
|
|
||||||
warnings: ['Using fast mode (text extraction only)'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Estimate page count(based on file size)
|
|
||||||
const estimatedPages = Math.max(1, Math.ceil(sizeMB * 2));
|
|
||||||
const costEstimate = this.costControl.estimateCost(estimatedPages);
|
|
||||||
|
|
||||||
// Recommend precise mode for large files
|
|
||||||
if (sizeMB > 50) {
|
|
||||||
return {
|
|
||||||
recommendedMode: 'precise',
|
|
||||||
reason:
|
|
||||||
'File is large, recommend precise mode to preserve full content',
|
|
||||||
estimatedCost: costEstimate.estimatedCost,
|
|
||||||
estimatedTime: costEstimate.estimatedTime,
|
|
||||||
warnings: [
|
|
||||||
'Processing time may be longer',
|
|
||||||
'API costs will be incurred',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recommend precise mode
|
|
||||||
return {
|
|
||||||
recommendedMode: 'precise',
|
|
||||||
reason:
|
|
||||||
'Precise mode available. Can preserve mixed text and image content',
|
|
||||||
estimatedCost: costEstimate.estimatedCost,
|
|
||||||
estimatedTime: costEstimate.estimatedTime,
|
|
||||||
warnings: ['API costs will be incurred'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user quota information
|
|
||||||
*/
|
|
||||||
async getUserQuotaInfo(userId: string) {
|
|
||||||
const quota = await this.costControl.getUserQuota(userId);
|
|
||||||
const report = await this.costControl.getCostReport(userId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...quota,
|
|
||||||
report,
|
|
||||||
warnings: await this.costControl.checkWarningThreshold(userId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update processing status (for real-time feedback)
|
|
||||||
*/
|
|
||||||
private updateStatus(
|
|
||||||
status: ProcessingStatus['status'],
|
|
||||||
progress: number,
|
|
||||||
message: string,
|
|
||||||
): void {
|
|
||||||
this.logger.log(`[${status}] ${progress}% - ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { AssessmentService } from '../src/assessment/assessment.service';
|
||||||
|
import { AssessmentSession } from '../src/assessment/entities/assessment-session.entity';
|
||||||
|
import { AssessmentQuestion } from '../src/assessment/entities/assessment-question.entity';
|
||||||
|
import { AssessmentAnswer } from '../src/assessment/entities/assessment-answer.entity';
|
||||||
|
import { AssessmentCertificate } from '../src/assessment/entities/assessment-certificate.entity';
|
||||||
|
import { QuestionBank } from '../src/assessment/entities/question-bank.entity';
|
||||||
|
import { QuestionBankItem } from '../src/assessment/entities/question-bank-item.entity';
|
||||||
|
import { KnowledgeBaseService } from '../src/knowledge-base/knowledge-base.service';
|
||||||
|
import { KnowledgeGroupService } from '../src/knowledge-group/knowledge-group.service';
|
||||||
|
import { ModelConfigService } from '../src/model-config/model-config.service';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { TemplateService } from '../src/assessment/services/template.service';
|
||||||
|
import { ContentFilterService } from '../src/assessment/services/content-filter.service';
|
||||||
|
import { QuestionOutlineService } from '../src/assessment/services/question-outline.service';
|
||||||
|
import { QuestionBankService } from '../src/assessment/services/question-bank.service';
|
||||||
|
import { RagService } from '../src/rag/rag.service';
|
||||||
|
import { ChatService } from '../src/chat/chat.service';
|
||||||
|
import { I18nService } from '../src/i18n/i18n.service';
|
||||||
|
import { TenantService } from '../src/tenant/tenant.service';
|
||||||
|
|
||||||
|
const mockManager = () => ({
|
||||||
|
findOne: jest.fn(),
|
||||||
|
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||||
|
save: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockDataSource = () => ({
|
||||||
|
transaction: jest.fn(async (cb: any) => cb(mockManager())),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Certificate integration tests — verify the full certificate lifecycle
|
||||||
|
* through the AssessmentService with mocked repositories.
|
||||||
|
*/
|
||||||
|
describe('Certificate (integration)', () => {
|
||||||
|
let service: AssessmentService;
|
||||||
|
let sessionRepo: any;
|
||||||
|
let certificateRepo: any;
|
||||||
|
|
||||||
|
const mockRepo = () => ({
|
||||||
|
find: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockSvc = () => ({});
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AssessmentService,
|
||||||
|
{ provide: getRepositoryToken(AssessmentSession), useFactory: mockRepo },
|
||||||
|
{ provide: getRepositoryToken(AssessmentQuestion), useFactory: mockRepo },
|
||||||
|
{ provide: getRepositoryToken(AssessmentAnswer), useFactory: mockRepo },
|
||||||
|
{ provide: getRepositoryToken(AssessmentCertificate), useFactory: mockRepo },
|
||||||
|
{ provide: getRepositoryToken(QuestionBank), useFactory: mockRepo },
|
||||||
|
{ provide: getRepositoryToken(QuestionBankItem), useFactory: mockRepo },
|
||||||
|
{ provide: KnowledgeBaseService, useFactory: mockSvc },
|
||||||
|
{ provide: KnowledgeGroupService, useFactory: mockSvc },
|
||||||
|
{ provide: ModelConfigService, useFactory: mockSvc },
|
||||||
|
{ provide: ConfigService, useFactory: mockSvc },
|
||||||
|
{ provide: TemplateService, useFactory: mockSvc },
|
||||||
|
{ provide: ContentFilterService, useFactory: mockSvc },
|
||||||
|
{ provide: QuestionOutlineService, useFactory: mockSvc },
|
||||||
|
{ provide: QuestionBankService, useFactory: mockSvc },
|
||||||
|
{ provide: RagService, useFactory: mockSvc },
|
||||||
|
{ provide: ChatService, useFactory: mockSvc },
|
||||||
|
{ provide: I18nService, useFactory: mockSvc },
|
||||||
|
{ provide: TenantService, useFactory: mockSvc },
|
||||||
|
{ provide: DataSource, useFactory: mockDataSource },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AssessmentService>(AssessmentService);
|
||||||
|
sessionRepo = module.get(getRepositoryToken(AssessmentSession));
|
||||||
|
certificateRepo = module.get(getRepositoryToken(AssessmentCertificate));
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyCertificate (public endpoint logic)', () => {
|
||||||
|
it('should return { valid: false } for unknown certificate ID', async () => {
|
||||||
|
certificateRepo.findOne.mockResolvedValue(null);
|
||||||
|
const result = await service.verifyCertificate('no-cert');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.message).toContain('not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return { valid: true } with certificate data for known ID', async () => {
|
||||||
|
certificateRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'cert-1',
|
||||||
|
level: 'Expert',
|
||||||
|
totalScore: 95,
|
||||||
|
passed: true,
|
||||||
|
issuedAt: new Date('2026-01-01'),
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
const result = await service.verifyCertificate('cert-1');
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.certificate!.level).toBe('Expert');
|
||||||
|
expect(result.certificate!.userId).toBe('user-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPublicCertificateInfo (public endpoint logic)', () => {
|
||||||
|
it('should return { exists: false } for session without certificate', async () => {
|
||||||
|
certificateRepo.findOne.mockResolvedValue(null);
|
||||||
|
const result = await service.getPublicCertificateInfo('no-session');
|
||||||
|
expect(result.exists).toBe(false);
|
||||||
|
expect(result.message).toContain('not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return certificate info for session with certificate', async () => {
|
||||||
|
certificateRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'cert-1',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
level: 'Advanced',
|
||||||
|
totalScore: 85,
|
||||||
|
passed: true,
|
||||||
|
issuedAt: new Date('2026-01-01'),
|
||||||
|
dimensionScores: { prompt: 80, llm: 90 },
|
||||||
|
});
|
||||||
|
const result = await service.getPublicCertificateInfo('session-1');
|
||||||
|
expect(result.exists).toBe(true);
|
||||||
|
expect(result.certificate!.level).toBe('Advanced');
|
||||||
|
expect(result.certificate!.totalScore).toBe(85);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Certificate lifecycle', () => {
|
||||||
|
it('should generate certificate then verify it', async () => {
|
||||||
|
sessionRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'session-lc',
|
||||||
|
userId: 'user-1',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
finalScore: 88,
|
||||||
|
templateId: 'template-1',
|
||||||
|
});
|
||||||
|
certificateRepo.findOne.mockResolvedValueOnce(null);
|
||||||
|
certificateRepo.create.mockReturnValue({ id: 'cert-lc' });
|
||||||
|
certificateRepo.save.mockResolvedValue({
|
||||||
|
id: 'cert-lc',
|
||||||
|
level: 'Advanced',
|
||||||
|
totalScore: 88,
|
||||||
|
passed: true,
|
||||||
|
userId: 'user-1',
|
||||||
|
sessionId: 'session-lc',
|
||||||
|
});
|
||||||
|
|
||||||
|
const cert = await service.generateCertificate('session-lc', 'user-1', 'tenant-1');
|
||||||
|
expect(cert.level).toBe('Advanced');
|
||||||
|
|
||||||
|
certificateRepo.findOne.mockResolvedValueOnce({
|
||||||
|
id: 'cert-lc',
|
||||||
|
level: 'Advanced',
|
||||||
|
totalScore: 88,
|
||||||
|
passed: true,
|
||||||
|
issuedAt: new Date(),
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const verified = await service.verifyCertificate('cert-lc');
|
||||||
|
expect(verified.valid).toBe(true);
|
||||||
|
expect(verified.certificate!.totalScore).toBe(88);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be idempotent — returning existing certificate on re-generation', async () => {
|
||||||
|
const existing = { id: 'cert-dup', sessionId: 'session-dup', level: 'Proficient' };
|
||||||
|
sessionRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'session-dup',
|
||||||
|
userId: 'user-1',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
finalScore: 65,
|
||||||
|
});
|
||||||
|
certificateRepo.findOne.mockResolvedValue(existing);
|
||||||
|
|
||||||
|
const result = await service.generateCertificate('session-dup', 'user-1', 'tenant-1');
|
||||||
|
expect(result).toBe(existing);
|
||||||
|
expect(certificateRepo.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should determine correct level for different scores', async () => {
|
||||||
|
const testCases = [
|
||||||
|
{ score: 95, expectedLevel: 'Expert' },
|
||||||
|
{ score: 80, expectedLevel: 'Advanced' },
|
||||||
|
{ score: 65, expectedLevel: 'Proficient' },
|
||||||
|
{ score: 45, expectedLevel: 'Novice' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { score, expectedLevel } of testCases) {
|
||||||
|
sessionRepo.findOne.mockResolvedValue({
|
||||||
|
id: `session-${score}`,
|
||||||
|
userId: 'user-1',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
finalScore: score,
|
||||||
|
templateId: 't1',
|
||||||
|
});
|
||||||
|
certificateRepo.findOne.mockResolvedValue(null);
|
||||||
|
certificateRepo.create.mockReturnValue({ id: `cert-${score}` });
|
||||||
|
certificateRepo.save.mockResolvedValue({ id: `cert-${score}`, level: expectedLevel });
|
||||||
|
|
||||||
|
const cert = await service.generateCertificate(`session-${score}`, 'user-1', 'tenant-1');
|
||||||
|
expect(cert.level).toBe(expectedLevel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^@/(.*)$": "<rootDir>/../src/$1"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
|||||||
|
cd /d D:\AuraK\server
|
||||||
|
node --enable-source-maps dist/main
|
||||||
|
pause
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
cd /d D:\AuraK\web
|
||||||
|
npx vite --port 13001
|
||||||
|
pause
|
||||||
@@ -34,7 +34,7 @@ export const WorkspaceLayout: React.FC<WorkspaceLayoutProps> = ({
|
|||||||
appMode={appMode}
|
appMode={appMode}
|
||||||
onSwitchMode={onSwitchMode}
|
onSwitchMode={onSwitchMode}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 overflow-hidden relative">
|
<div className="flex-1 overflow-auto relative">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useToast } from '../../contexts/ToastContext';
|
|||||||
import { useConfirm } from '../../contexts/ConfirmContext';
|
import { useConfirm } from '../../contexts/ConfirmContext';
|
||||||
import { templateService } from '../../services/templateService';
|
import { templateService } from '../../services/templateService';
|
||||||
import { knowledgeGroupService } from '../../services/knowledgeGroupService';
|
import { knowledgeGroupService } from '../../services/knowledgeGroupService';
|
||||||
import { AssessmentTemplate, CreateTemplateData, UpdateTemplateData, KnowledgeGroup } from '../../types';
|
import { AssessmentTemplate, CreateTemplateData, UpdateTemplateData, KnowledgeGroup, AssessmentDimension } from '../../types';
|
||||||
|
|
||||||
export const AssessmentTemplateManager: React.FC = () => {
|
export const AssessmentTemplateManager: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
@@ -29,8 +29,12 @@ export const AssessmentTemplateManager: React.FC = () => {
|
|||||||
difficultyDistribution: 'Basic: 30%, Intermediate: 40%, Advanced: 30%',
|
difficultyDistribution: 'Basic: 30%, Intermediate: 40%, Advanced: 30%',
|
||||||
style: 'Professional',
|
style: 'Professional',
|
||||||
knowledgeGroupId: '',
|
knowledgeGroupId: '',
|
||||||
|
passingScore: 6,
|
||||||
|
totalTimeLimit: 1800,
|
||||||
|
perQuestionTimeLimit: 300,
|
||||||
});
|
});
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
const [dimensions, setDimensions] = useState<AssessmentDimension[]>([]);
|
||||||
|
|
||||||
const fetchTemplates = async () => {
|
const fetchTemplates = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -72,7 +76,11 @@ export const AssessmentTemplateManager: React.FC = () => {
|
|||||||
: (template.difficultyDistribution || ''),
|
: (template.difficultyDistribution || ''),
|
||||||
style: template.style || 'Professional',
|
style: template.style || 'Professional',
|
||||||
knowledgeGroupId: template.knowledgeGroupId || '',
|
knowledgeGroupId: template.knowledgeGroupId || '',
|
||||||
|
passingScore: template.passingScore !== null && template.passingScore !== undefined ? template.passingScore / 10 : 6,
|
||||||
|
totalTimeLimit: template.totalTimeLimit ?? 1800,
|
||||||
|
perQuestionTimeLimit: template.perQuestionTimeLimit ?? 300,
|
||||||
});
|
});
|
||||||
|
setDimensions(template.dimensions || []);
|
||||||
} else {
|
} else {
|
||||||
setEditingTemplate(null);
|
setEditingTemplate(null);
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -83,7 +91,11 @@ export const AssessmentTemplateManager: React.FC = () => {
|
|||||||
difficultyDistribution: '{"Basic": 3, "Intermediate": 4, "Advanced": 3}',
|
difficultyDistribution: '{"Basic": 3, "Intermediate": 4, "Advanced": 3}',
|
||||||
style: 'Professional',
|
style: 'Professional',
|
||||||
knowledgeGroupId: '',
|
knowledgeGroupId: '',
|
||||||
|
passingScore: 6,
|
||||||
|
totalTimeLimit: 1800,
|
||||||
|
perQuestionTimeLimit: 300,
|
||||||
});
|
});
|
||||||
|
setDimensions([]);
|
||||||
}
|
}
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
@@ -95,13 +107,10 @@ export const AssessmentTemplateManager: React.FC = () => {
|
|||||||
// Convert UI strings back to required types
|
// Convert UI strings back to required types
|
||||||
const keywordsArray = formData.keywords.split(',').map(k => k.trim()).filter(k => k !== '');
|
const keywordsArray = formData.keywords.split(',').map(k => k.trim()).filter(k => k !== '');
|
||||||
let diffDist: any = formData.difficultyDistribution;
|
let diffDist: any = formData.difficultyDistribution;
|
||||||
try {
|
if (typeof diffDist === 'string' && diffDist.trim().startsWith('{')) {
|
||||||
if (formData.difficultyDistribution.startsWith('{')) {
|
try { diffDist = JSON.parse(diffDist); } catch (e) { diffDist = undefined; }
|
||||||
diffDist = JSON.parse(formData.difficultyDistribution);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Keep as string if parsing fails
|
|
||||||
}
|
}
|
||||||
|
if (typeof diffDist !== 'object' || diffDist === null) diffDist = undefined;
|
||||||
|
|
||||||
const payload: CreateTemplateData = {
|
const payload: CreateTemplateData = {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
@@ -111,6 +120,10 @@ export const AssessmentTemplateManager: React.FC = () => {
|
|||||||
difficultyDistribution: diffDist,
|
difficultyDistribution: diffDist,
|
||||||
style: formData.style,
|
style: formData.style,
|
||||||
knowledgeGroupId: formData.knowledgeGroupId || undefined,
|
knowledgeGroupId: formData.knowledgeGroupId || undefined,
|
||||||
|
dimensions: dimensions.length > 0 ? dimensions : undefined,
|
||||||
|
passingScore: formData.passingScore * 10,
|
||||||
|
totalTimeLimit: formData.totalTimeLimit,
|
||||||
|
perQuestionTimeLimit: formData.perQuestionTimeLimit,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingTemplate) {
|
if (editingTemplate) {
|
||||||
@@ -122,9 +135,10 @@ export const AssessmentTemplateManager: React.FC = () => {
|
|||||||
}
|
}
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
fetchTemplates();
|
fetchTemplates();
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Save failed:', error);
|
console.error('Save failed:', error);
|
||||||
showError(t('actionFailed'));
|
const msg = error?.message;
|
||||||
|
showError(msg && msg !== 'Request failed' ? msg : t('actionFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -141,6 +155,20 @@ export const AssessmentTemplateManager: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDimensionChange = (index: number, field: 'name' | 'label' | 'weight', value: string | number) => {
|
||||||
|
const updated = [...dimensions];
|
||||||
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
|
setDimensions(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddDimension = () => {
|
||||||
|
setDimensions([...dimensions, { name: '', label: '', weight: 1 }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveDimension = (index: number) => {
|
||||||
|
setDimensions(dimensions.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!(await confirm(t('confirmTitle')))) return;
|
if (!(await confirm(t('confirmTitle')))) return;
|
||||||
try {
|
try {
|
||||||
@@ -255,6 +283,16 @@ export const AssessmentTemplateManager: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{Array.isArray(template.dimensions) && template.dimensions.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
|
{template.dimensions.map((dim, i) => (
|
||||||
|
<span key={i} className="px-2 py-0.5 bg-amber-50 text-amber-700 text-[10px] font-bold rounded-full border border-amber-100/50">
|
||||||
|
{dim.label} ({dim.weight}%)
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1.5 pt-4 border-t border-slate-50">
|
<div className="flex flex-wrap gap-1.5 pt-4 border-t border-slate-50">
|
||||||
{Array.isArray(template.keywords) && template.keywords.map((kw, i) => (
|
{Array.isArray(template.keywords) && template.keywords.map((kw, i) => (
|
||||||
<span key={i} className="px-2 py-0.5 bg-indigo-50 text-indigo-600 text-[10px] font-bold rounded-full border border-indigo-100/50">
|
<span key={i} className="px-2 py-0.5 bg-indigo-50 text-indigo-600 text-[10px] font-bold rounded-full border border-indigo-100/50">
|
||||||
@@ -372,16 +410,99 @@ export const AssessmentTemplateManager: React.FC = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5 md:col-span-2">
|
<div className="space-y-1.5 md:col-span-2">
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
|
||||||
<Sliders size={12} className="text-indigo-500" />
|
<Sliders size={12} className="text-indigo-500" />
|
||||||
{t('style')}
|
{t('style')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
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"
|
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.style}
|
value={formData.style}
|
||||||
onChange={e => setFormData({ ...formData, style: e.target.value })}
|
onChange={e => setFormData({ ...formData, style: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<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" />通过分 (0-10)
|
||||||
|
</label>
|
||||||
|
<input type="number" min="0" max="10" step="0.5"
|
||||||
|
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.passingScore}
|
||||||
|
onChange={e => setFormData({ ...formData, passingScore: parseFloat(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<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="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 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<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="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 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 md:col-span-2">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
|
||||||
|
<Sliders size={12} className="text-indigo-500" />
|
||||||
|
{t('templateDimensions')} *
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{dimensions.length === 0 && (
|
||||||
|
<p className="text-xs text-slate-400 italic px-3">{t('mmEmpty')}</p>
|
||||||
|
)}
|
||||||
|
{dimensions.map((dim, index) => (
|
||||||
|
<div key={index} className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
className="w-1/3 px-4 py-3 bg-slate-50 border border-slate-200 rounded-[1rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all placeholder:text-slate-300"
|
||||||
|
value={dim.name}
|
||||||
|
onChange={e => handleDimensionChange(index, 'name', e.target.value)}
|
||||||
|
placeholder={t('dimensionName')}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="w-1/3 px-4 py-3 bg-slate-50 border border-slate-200 rounded-[1rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all placeholder:text-slate-300"
|
||||||
|
value={dim.label}
|
||||||
|
onChange={e => handleDimensionChange(index, 'label', e.target.value)}
|
||||||
|
placeholder={t('dimensionLabel')}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-20 px-4 py-3 bg-slate-50 border border-slate-200 rounded-[1rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
|
||||||
|
value={dim.weight}
|
||||||
|
onChange={e => handleDimensionChange(index, 'weight', parseInt(e.target.value) || 0)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
placeholder={t('dimensionWeight')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveDimension(index)}
|
||||||
|
className="p-2 text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all flex-shrink-0"
|
||||||
|
title={t('removeDimension')}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddDimension}
|
||||||
|
className="text-xs font-bold text-indigo-600 hover:text-indigo-800 transition-colors px-1"
|
||||||
|
>
|
||||||
|
+ {t('addDimension')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import {
|
import {
|
||||||
Brain,
|
Brain,
|
||||||
Send,
|
Send,
|
||||||
@@ -13,7 +14,8 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
Award,
|
Award,
|
||||||
Trophy,
|
Trophy,
|
||||||
Trash2
|
Trash2,
|
||||||
|
XCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
@@ -51,6 +53,11 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
|||||||
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
|
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||||
const [timeCheck, setTimeCheck] = useState<{ totalTimeRemaining: number; questionTimeRemaining: number; isTotalTimeout: boolean; isQuestionTimeout: boolean } | null>(null);
|
const [timeCheck, setTimeCheck] = useState<{ totalTimeRemaining: number; questionTimeRemaining: number; isTotalTimeout: boolean; isQuestionTimeout: boolean } | null>(null);
|
||||||
|
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
|
||||||
|
const [autoSubmitted, setAutoSubmitted] = useState(false);
|
||||||
|
const [showCertModal, setShowCertModal] = useState(false);
|
||||||
|
const [certData, setCertData] = useState<any>(null);
|
||||||
|
const isTimedOut = timeCheck?.isTotalTimeout || timeCheck?.isQuestionTimeout;
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -103,6 +110,10 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
|||||||
setTimeCheck(data);
|
setTimeCheck(data);
|
||||||
if (data.isTotalTimeout || data.isQuestionTimeout) {
|
if (data.isTotalTimeout || data.isQuestionTimeout) {
|
||||||
setError(t('timeLimitExceeded'));
|
setError(t('timeLimitExceeded'));
|
||||||
|
if (!autoSubmitted && !isLoading) {
|
||||||
|
setAutoSubmitted(true);
|
||||||
|
await handleSubmitAnswer(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to check time:', err);
|
console.error('Failed to check time:', err);
|
||||||
@@ -137,7 +148,11 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
|||||||
setState(histState);
|
setState(histState);
|
||||||
setSession(histSession);
|
setSession(histSession);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to load historical assessment');
|
if (histSession.status === 'IN_PROGRESS') {
|
||||||
|
setError(t('cannotResumeInProgress'));
|
||||||
|
} else {
|
||||||
|
setError(err.message || 'Failed to load historical assessment');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setLoadingHistoryId(null);
|
setLoadingHistoryId(null);
|
||||||
@@ -184,7 +199,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
|||||||
...prev,
|
...prev,
|
||||||
...event.data,
|
...event.data,
|
||||||
messages: event.data.messages
|
messages: event.data.messages
|
||||||
? [...prevMessages, ...event.data.messages.filter((m: any) => !prevMessages.some((pm: any) => pm.content === m.content && pm.role === m.role))]
|
? [...prevMessages, ...event.data.messages.filter((m: any) => !prevMessages.some((pm: any) => (m.id && pm.id === m.id) || (pm.content === m.content && pm.role === m.role)))]
|
||||||
: prevMessages,
|
: prevMessages,
|
||||||
feedbackHistory: event.data.feedbackHistory
|
feedbackHistory: event.data.feedbackHistory
|
||||||
? [...(prev.feedbackHistory || []), ...event.data.feedbackHistory.filter((fh: any) => !(prev.feedbackHistory || []).some((pfh: any) => pfh.content === fh.content))]
|
? [...(prev.feedbackHistory || []), ...event.data.feedbackHistory.filter((fh: any) => !(prev.feedbackHistory || []).some((pfh: any) => pfh.content === fh.content))]
|
||||||
@@ -226,11 +241,21 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitAnswer = async () => {
|
const handleSubmitAnswer = async (forced = false) => {
|
||||||
if (!session || !inputValue.trim() || isLoading) return;
|
const currentQuestion = state?.questions?.[state.currentQuestionIndex || 0] as any;
|
||||||
|
const isChoice = currentQuestion?.questionType === 'MULTIPLE_CHOICE' && currentQuestion?.options?.length > 0;
|
||||||
|
|
||||||
const answer = inputValue.trim();
|
if (!forced) {
|
||||||
|
if (isChoice) {
|
||||||
|
if (!selectedChoice || isLoading || isTimedOut) return;
|
||||||
|
} else {
|
||||||
|
if (!inputValue.trim() || isLoading || isTimedOut) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const answer = isChoice ? (selectedChoice || '') : inputValue.trim();
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
|
setSelectedChoice(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setProcessStep(isZh ? '正在准备发送...' : isJa ? '送信準備中...' : 'Preparing to send...');
|
setProcessStep(isZh ? '正在准备发送...' : isJa ? '送信準備中...' : 'Preparing to send...');
|
||||||
@@ -252,7 +277,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
|||||||
if (!prev) return event.data;
|
if (!prev) return event.data;
|
||||||
const prevMessages = prev.messages || [];
|
const prevMessages = prev.messages || [];
|
||||||
const mergedMessages = event.data.messages
|
const mergedMessages = event.data.messages
|
||||||
? [...prevMessages, ...event.data.messages.filter((m: any) => !prevMessages.some((pm: any) => pm.content === m.content && pm.role === m.role))]
|
? [...prevMessages, ...event.data.messages.filter((m: any) => !prevMessages.some((pm: any) => (m.id && pm.id === m.id) || (pm.content === m.content && pm.role === m.role)))]
|
||||||
: prevMessages;
|
: prevMessages;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -271,6 +296,8 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
|||||||
if (event.data.status === 'COMPLETED') {
|
if (event.data.status === 'COMPLETED') {
|
||||||
setSession(prev => prev ? { ...prev, status: 'COMPLETED' } : null);
|
setSession(prev => prev ? { ...prev, status: 'COMPLETED' } : null);
|
||||||
fetchHistory();
|
fetchHistory();
|
||||||
|
} else if (event.data.currentQuestionIndex !== undefined) {
|
||||||
|
assessmentService.nextQuestion(session.id).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -428,7 +455,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
|||||||
|
|
||||||
{/* Assessment History Sidebar */}
|
{/* Assessment History Sidebar */}
|
||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
<div className="w-80 flex-none bg-white p-6 overflow-y-auto hidden lg:flex flex-col border-l border-slate-200/60 shadow-[4px_0_24px_rgba(0,0,0,0.02)]">
|
<div className="w-80 flex-none bg-white p-6 overflow-y-auto flex flex-col border-l border-slate-200/60 shadow-[4px_0_24px_rgba(0,0,0,0.02)]">
|
||||||
<h3 className="text-sm font-black text-slate-900 mb-6 flex items-center gap-2 uppercase tracking-widest">
|
<h3 className="text-sm font-black text-slate-900 mb-6 flex items-center gap-2 uppercase tracking-widest">
|
||||||
<History size={18} className="text-indigo-600" />
|
<History size={18} className="text-indigo-600" />
|
||||||
{t('recentAssessments')}
|
{t('recentAssessments')}
|
||||||
@@ -502,6 +529,10 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
|||||||
!(m.role === 'assistant' && (m.content?.toString().startsWith('Score:') || m.content?.toString().startsWith('得分:')))
|
!(m.role === 'assistant' && (m.content?.toString().startsWith('Score:') || m.content?.toString().startsWith('得分:')))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const currentQuestion = (state?.questions?.[state.currentQuestionIndex || 0] || {}) as any;
|
||||||
|
const isCurrentChoice = currentQuestion.questionType === 'MULTIPLE_CHOICE' && currentQuestion.options?.length > 0;
|
||||||
|
const optionLabels = ['A', 'B', 'C', 'D'];
|
||||||
|
|
||||||
const feedbackHistory = state?.feedbackHistory || [];
|
const feedbackHistory = state?.feedbackHistory || [];
|
||||||
const lastFeedbackMessage = feedbackHistory[feedbackHistory.length - 1];
|
const lastFeedbackMessage = feedbackHistory[feedbackHistory.length - 1];
|
||||||
|
|
||||||
@@ -576,26 +607,75 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 bg-white border-t border-slate-200/60 shadow-[0_-4px_20px_-10px_rgba(0,0,0,0.05)]">
|
<div className="p-6 bg-white border-t border-slate-200/60 shadow-[0_-4px_20px_-10px_rgba(0,0,0,0.05)]">
|
||||||
|
{isTimedOut && (
|
||||||
|
<div className="max-w-3xl mx-auto mb-3 px-4 py-2 bg-red-50 border border-red-200 text-red-700 text-sm font-bold rounded-xl text-center">
|
||||||
|
{t('timeLimitExceeded')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isCurrentChoice ? (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500 font-bold uppercase tracking-wider mb-1">
|
||||||
|
<span className="w-1 h-1 bg-indigo-400 rounded-full" />
|
||||||
|
请选择一个选项
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{currentQuestion.options.map((opt: string, i: number) => {
|
||||||
|
const letter = optionLabels[i];
|
||||||
|
const isSelected = selectedChoice === letter;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={letter}
|
||||||
|
onClick={() => !isTimedOut && setSelectedChoice(letter)}
|
||||||
|
disabled={isTimedOut}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left px-5 py-4 rounded-2xl border-2 transition-all text-sm font-medium",
|
||||||
|
isSelected
|
||||||
|
? "border-indigo-500 bg-indigo-50 text-indigo-700 shadow-md"
|
||||||
|
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50",
|
||||||
|
isTimedOut && "opacity-50 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSubmitAnswer()}
|
||||||
|
disabled={!selectedChoice || isLoading || isTimedOut}
|
||||||
|
className={cn(
|
||||||
|
"w-full mt-3 h-14 flex items-center justify-center gap-2 rounded-2xl transition-all shadow-lg text-white font-bold",
|
||||||
|
!selectedChoice || isLoading || isTimedOut
|
||||||
|
? "bg-slate-300 cursor-not-allowed"
|
||||||
|
: "bg-indigo-600 hover:bg-indigo-700 active:scale-[0.97]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 size={20} className="animate-spin" /> : <Send size={20} />}
|
||||||
|
<span className="text-sm">确认答案</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="max-w-3xl mx-auto flex items-end gap-3">
|
<div className="max-w-3xl mx-auto flex items-end gap-3">
|
||||||
<textarea
|
<textarea
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !isTimedOut) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmitAnswer();
|
handleSubmitAnswer();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={t('typeAnswerPlaceholder')}
|
placeholder={isTimedOut ? t('timeLimitExceeded') : t('typeAnswerPlaceholder')}
|
||||||
className="flex-1 max-h-32 p-4 bg-slate-50 border-none rounded-2xl focus:bg-white focus:ring-2 focus:ring-indigo-500/20 text-sm font-medium resize-none transition-all placeholder:text-slate-400 outline-none shadow-inner"
|
disabled={isTimedOut}
|
||||||
|
className="flex-1 max-h-32 p-4 bg-slate-50 border-none rounded-2xl focus:bg-white focus:ring-2 focus:ring-indigo-500/20 text-sm font-medium resize-none transition-all placeholder:text-slate-400 outline-none shadow-inner disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmitAnswer}
|
onClick={handleSubmitAnswer}
|
||||||
disabled={!inputValue.trim() || isLoading}
|
disabled={!inputValue.trim() || isLoading || isTimedOut}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-14 h-14 flex items-center justify-center rounded-2xl transition-all shadow-lg",
|
"w-14 h-14 flex items-center justify-center rounded-2xl transition-all shadow-lg",
|
||||||
!inputValue.trim() || isLoading
|
!inputValue.trim() || isLoading || isTimedOut
|
||||||
? "bg-slate-100 text-slate-400 shadow-none"
|
? "bg-slate-100 text-slate-400 shadow-none"
|
||||||
: "bg-indigo-600 text-white hover:bg-indigo-700 shadow-indigo-200 active:scale-95"
|
: "bg-indigo-600 text-white hover:bg-indigo-700 shadow-indigo-200 active:scale-95"
|
||||||
)}
|
)}
|
||||||
@@ -603,11 +683,12 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
|||||||
<Send size={22} className={isLoading ? "animate-pulse" : ""} />
|
<Send size={22} className={isLoading ? "animate-pulse" : ""} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Feedback Panel */}
|
{/* Right: Feedback Panel */}
|
||||||
<div className="w-80 flex-none bg-white p-6 overflow-y-auto hidden lg:flex flex-col border-l border-slate-100">
|
<div className="w-80 flex-none bg-white p-6 overflow-y-auto flex flex-col border-l border-slate-100">
|
||||||
<h3 className="text-sm font-black text-slate-900 mb-6 flex items-center gap-2 uppercase tracking-widest">
|
<h3 className="text-sm font-black text-slate-900 mb-6 flex items-center gap-2 uppercase tracking-widest">
|
||||||
<ClipboardCheck size={18} className="text-indigo-600" />
|
<ClipboardCheck size={18} className="text-indigo-600" />
|
||||||
{t('liveFeedback')}
|
{t('liveFeedback')}
|
||||||
@@ -744,14 +825,73 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
|||||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{t('status')}</span>
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{t('status')}</span>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-2xl font-black uppercase tracking-tighter",
|
"text-2xl font-black uppercase tracking-tighter",
|
||||||
(state?.finalScore || 0) >= 6 ? "text-emerald-600" : "text-rose-600"
|
state?.passed ? "text-emerald-600" : "text-rose-600"
|
||||||
)}>
|
)}>
|
||||||
{(state?.finalScore || 0) >= 6 ? t('verified') : t('fail')}
|
{state?.passed ? t('verified') : t('fail')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
{state?.questions && state.questions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="flex items-center gap-2.5 text-lg font-black text-slate-900 mb-4">
|
||||||
|
<CheckCircle size={20} className="text-indigo-600" />
|
||||||
|
每题详情
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{state.questions.map((q: any, i: number) => {
|
||||||
|
const score = state.scores?.[q.id || (i + 1).toString()];
|
||||||
|
const isChoice = q.questionType === 'MULTIPLE_CHOICE';
|
||||||
|
const isCorrect = isChoice && q.correctAnswer && score >= 10;
|
||||||
|
return (
|
||||||
|
<div key={q.id || i} className="bg-white border border-slate-200 rounded-2xl p-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
"w-10 h-10 rounded-xl flex items-center justify-center shrink-0",
|
||||||
|
isChoice
|
||||||
|
? (isCorrect ? "bg-emerald-100 text-emerald-600" : "bg-red-100 text-red-600")
|
||||||
|
: score !== undefined ? "bg-indigo-100 text-indigo-600" : "bg-slate-100 text-slate-400"
|
||||||
|
)}>
|
||||||
|
{isChoice
|
||||||
|
? (isCorrect ? <CheckCircle size={20} /> : <XCircle size={20} />)
|
||||||
|
: <span className="text-sm font-black">{score !== undefined ? score : '?'}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-bold text-slate-800 text-sm leading-relaxed">{q.questionText}</p>
|
||||||
|
{isChoice && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||||
|
{q.options?.map((opt: string, oi: number) => {
|
||||||
|
const letter = String.fromCharCode(65 + oi);
|
||||||
|
const isAnswer = letter === q.correctAnswer;
|
||||||
|
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"
|
||||||
|
)}>
|
||||||
|
{opt}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{q.judgment && (
|
||||||
|
<div className="mt-3 bg-blue-50/50 border border-blue-100 rounded-xl p-3">
|
||||||
|
<p className="text-xs text-slate-600 leading-relaxed">{q.judgment}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isChoice && score !== undefined && (
|
||||||
|
<span className="inline-block mt-2 text-xs text-slate-400">得分: {score}/10</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="flex items-center gap-2.5 text-lg font-black text-slate-900 mb-4">
|
<h4 className="flex items-center gap-2.5 text-lg font-black text-slate-900 mb-4">
|
||||||
<FileText size={20} className="text-indigo-600" />
|
<FileText size={20} className="text-indigo-600" />
|
||||||
@@ -777,15 +917,14 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
|||||||
if (!session) return;
|
if (!session) return;
|
||||||
try {
|
try {
|
||||||
const result = await assessmentService.exportPdf(session.id);
|
const result = await assessmentService.exportPdf(session.id);
|
||||||
const blob = new Blob([result.content], { type: 'text/plain;charset=utf-8' });
|
const binary = atob(result.buffer);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
const blob = new Blob([bytes], { type: 'text/html;charset=utf-8' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
window.open(url, '_blank');
|
||||||
a.href = url;
|
|
||||||
a.download = result.filename;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to export PDF:', err);
|
setError(t('exportAssessmentFailed'));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="px-6 py-4 bg-white border-2 border-slate-100 text-slate-700 rounded-2xl font-bold hover:bg-slate-50 transition-all active:scale-[0.98]"
|
className="px-6 py-4 bg-white border-2 border-slate-100 text-slate-700 rounded-2xl font-bold hover:bg-slate-50 transition-all active:scale-[0.98]"
|
||||||
@@ -810,7 +949,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to export Excel:', err);
|
setError(t('exportAssessmentFailed'));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="px-6 py-4 bg-white border-2 border-slate-100 text-slate-700 rounded-2xl font-bold hover:bg-slate-50 transition-all active:scale-[0.98]"
|
className="px-6 py-4 bg-white border-2 border-slate-100 text-slate-700 rounded-2xl font-bold hover:bg-slate-50 transition-all active:scale-[0.98]"
|
||||||
@@ -822,7 +961,8 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
|||||||
if (!session) return;
|
if (!session) return;
|
||||||
try {
|
try {
|
||||||
const cert = await assessmentService.getCertificate(session.id);
|
const cert = await assessmentService.getCertificate(session.id);
|
||||||
alert(`${t('certificate')}: ${cert.level}\n${t('totalScore')}: ${cert.totalScore}\n${t('passed')}: ${cert.passed ? t('yes') : t('no')}`);
|
setCertData(cert);
|
||||||
|
setShowCertModal(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to get certificate:', err);
|
console.error('Failed to get certificate:', err);
|
||||||
}
|
}
|
||||||
@@ -843,6 +983,58 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
|||||||
<div className="flex flex-col h-full bg-white animate-in flex-1">
|
<div className="flex flex-col h-full bg-white animate-in flex-1">
|
||||||
{renderHeader()}
|
{renderHeader()}
|
||||||
|
|
||||||
|
{showCertModal && certData && createPortal(
|
||||||
|
<div className="fixed inset-0 z-[1000] flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" onClick={() => setShowCertModal(false)} />
|
||||||
|
<div className="relative bg-white rounded-3xl shadow-2xl max-w-lg w-full p-8 max-h-[80vh] overflow-y-auto">
|
||||||
|
<button onClick={() => setShowCertModal(false)} className="absolute top-4 right-4 p-2 text-slate-400 hover:text-slate-600 rounded-xl hover:bg-slate-100">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M5 5L15 15M15 5L5 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col items-center text-center mb-6">
|
||||||
|
<Award size={40} className="text-indigo-600 mb-3" />
|
||||||
|
<h3 className="text-2xl font-black text-slate-900">{certData.level}</h3>
|
||||||
|
<p className="text-sm text-slate-500 font-medium mt-1">{certData.templateName || '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||||
|
<div className="bg-slate-50 rounded-2xl p-4 text-center">
|
||||||
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">总分</span>
|
||||||
|
<p className="text-xl font-black text-slate-900 mt-1">{certData.totalScore}/10</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 rounded-2xl p-4 text-center">
|
||||||
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">结果</span>
|
||||||
|
<p className={`text-xl font-black mt-1 ${certData.passed ? 'text-emerald-600' : 'text-rose-600'}`}>{certData.passed ? '合格' : '不合格'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{certData.dimensionScores && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">维度得分</span>
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
{Object.entries(certData.dimensionScores).map(([dim, score]: [string, any]) => (
|
||||||
|
<div key={dim} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium text-slate-600">{dim}</span>
|
||||||
|
<span className="font-black text-slate-900">{score}/10</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{certData.questionDetails && (
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">题目列表</span>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{certData.questionDetails.map((qd: any) => (
|
||||||
|
<div key={qd.index} className="text-xs text-slate-600 truncate">
|
||||||
|
<span className="font-bold text-slate-400">#{qd.index}</span> {qd.questionText}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{error && (
|
{error && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -3,35 +3,31 @@ import { createPortal } from 'react-dom';
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
ChevronLeft, Plus, Sparkles, Send, Check, X,
|
ChevronLeft, Plus, Sparkles, Send, Check, X, XCircle, Clock,
|
||||||
Trash2, Edit2, FileText, Loader2, BookOpen, Brain,
|
Trash2, Edit2, FileText, Loader2, BookOpen, Brain,
|
||||||
AlertCircle, Hash, Layers
|
AlertCircle, Hash, Layers
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { questionBankService, QuestionBank, QuestionBankItem, CreateQuestionBankItemDto } from '../../services/questionBankService';
|
import { questionBankService, QuestionBank, QuestionBankItem, CreateQuestionBankItemDto } from '../../services/questionBankService';
|
||||||
import { templateService } from '../../services/templateService';
|
import { templateService } from '../../services/templateService';
|
||||||
import { AssessmentTemplate } from '../../types';
|
import { AssessmentTemplate } from '../../types';
|
||||||
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import { useConfirm } from '../../contexts/ConfirmContext';
|
||||||
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
|
||||||
const QUESTION_TYPES = [
|
const QUESTION_TYPES = [
|
||||||
{ value: 'SHORT_ANSWER', label: '简答题' },
|
{ value: 'SHORT_ANSWER', labelKey: 'shortAnswer' as const },
|
||||||
{ value: 'MULTIPLE_CHOICE', label: '选择题' },
|
{ value: 'MULTIPLE_CHOICE', labelKey: 'multipleChoice' as const },
|
||||||
{ value: 'TRUE_FALSE', label: '判断题' },
|
{ value: 'TRUE_FALSE', labelKey: 'trueFalse' as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DIFFICULTIES = [
|
const DIFFICULTIES = [
|
||||||
{ value: 'STANDARD', label: '标准' },
|
{ value: 'STANDARD', labelKey: 'standard' as const },
|
||||||
{ value: 'ADVANCED', label: '高级' },
|
{ value: 'ADVANCED', labelKey: 'advanced' as const },
|
||||||
{ value: 'SPECIALIST', label: '专家' },
|
{ value: 'SPECIALIST', labelKey: 'specialist' as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DIMENSIONS = [
|
type TypeIcon = { [key: string]: React.ReactNode };
|
||||||
{ value: 'PROMPT', label: 'Prompt' },
|
const typeIcons: TypeIcon = {
|
||||||
{ value: 'LLM', label: 'LLM' },
|
|
||||||
{ value: 'IDE', label: 'IDE' },
|
|
||||||
{ value: 'DEV_PATTERN', label: '开发模式' },
|
|
||||||
{ value: 'WORK_CAPABILITY', label: '工作能力' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const typeIcons: Record<string, React.ReactNode> = {
|
|
||||||
SHORT_ANSWER: <FileText size={12} />,
|
SHORT_ANSWER: <FileText size={12} />,
|
||||||
MULTIPLE_CHOICE: <Layers size={12} />,
|
MULTIPLE_CHOICE: <Layers size={12} />,
|
||||||
TRUE_FALSE: <Check size={12} />,
|
TRUE_FALSE: <Check size={12} />,
|
||||||
@@ -40,14 +36,19 @@ const typeIcons: Record<string, React.ReactNode> = {
|
|||||||
export default function QuestionBankDetailView() {
|
export default function QuestionBankDetailView() {
|
||||||
const { id: bankId } = useParams<{ id: string }>();
|
const { id: bankId } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
const { confirm } = useConfirm();
|
||||||
|
|
||||||
if (!bankId) {
|
if (!bankId) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-4">
|
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors mb-4">
|
||||||
<ChevronLeft size={20} /> 返回
|
<ChevronLeft size={18} /><span className="text-xs font-black uppercase tracking-widest">{t('backToBankList')}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="text-red-500">无效的题库ID</div>
|
<div className="flex items-center gap-2 text-red-500 bg-red-50 rounded-2xl p-4 border border-red-100">
|
||||||
|
<AlertCircle size={18} /><span className="text-sm font-bold">{t('invalidBankId')}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -55,6 +56,7 @@ export default function QuestionBankDetailView() {
|
|||||||
const [bank, setBank] = useState<QuestionBank | null>(null);
|
const [bank, setBank] = useState<QuestionBank | null>(null);
|
||||||
const [items, setItems] = useState<QuestionBankItem[]>([]);
|
const [items, setItems] = useState<QuestionBankItem[]>([]);
|
||||||
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
|
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
|
||||||
|
const [template, setTemplate] = useState<AssessmentTemplate | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -72,30 +74,96 @@ export default function QuestionBankDetailView() {
|
|||||||
});
|
});
|
||||||
const [keyPointsInput, setKeyPointsInput] = useState('');
|
const [keyPointsInput, setKeyPointsInput] = useState('');
|
||||||
|
|
||||||
const [generateForm, setGenerateForm] = useState({
|
const [generateForm, setGenerateForm] = useState({ count: 5, knowledgeBaseContent: '' });
|
||||||
count: 5,
|
|
||||||
knowledgeBaseContent: '',
|
|
||||||
});
|
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const selectableItems = items.filter(i => i.status === 'PENDING_REVIEW');
|
||||||
|
const allSelected = selectableItems.length > 0 && selectableItems.every(i => selectedItemIds.has(i.id));
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedItemIds(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedItemIds(new Set(selectableItems.map(i => i.id)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectItem = (itemId: string) => {
|
||||||
|
setSelectedItemIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(itemId)) next.delete(itemId); else next.add(itemId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchApprove = async () => {
|
||||||
|
const ids = Array.from(selectedItemIds);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
try {
|
||||||
|
await questionBankService.batchReviewItems(bankId, ids, true);
|
||||||
|
showSuccess(`已通过 ${ids.length} 道题目`);
|
||||||
|
setSelectedItemIds(new Set());
|
||||||
|
fetchData();
|
||||||
|
} catch (err: any) { showError(err.message || t('actionFailed')); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchReject = async () => {
|
||||||
|
const ids = Array.from(selectedItemIds);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
try {
|
||||||
|
await questionBankService.batchReviewItems(bankId, ids, false);
|
||||||
|
showSuccess(`已驳回 ${ids.length} 道题目`);
|
||||||
|
setSelectedItemIds(new Set());
|
||||||
|
fetchData();
|
||||||
|
} catch (err: any) { showError(err.message || t('actionFailed')); }
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => { fetchData(); fetchTemplates(); }, [bankId]);
|
useEffect(() => { fetchData(); fetchTemplates(); }, [bankId]);
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try { setLoading(true);
|
try {
|
||||||
|
setLoading(true);
|
||||||
const bankData = await questionBankService.getBank(bankId);
|
const bankData = await questionBankService.getBank(bankId);
|
||||||
setBank(bankData);
|
setBank(bankData);
|
||||||
const itemsData = await questionBankService.getBankItems(bankId);
|
const itemsData = await questionBankService.getBankItems(bankId);
|
||||||
setItems(itemsData);
|
setItems(itemsData);
|
||||||
} catch (err: any) { setError(err.message || '加载失败');
|
} catch (err: any) {
|
||||||
} finally { setLoading(false); }
|
setError(err.message || t('actionFailed'));
|
||||||
|
showError(err.message || t('actionFailed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchTemplates = async () => {
|
const fetchTemplates = async () => {
|
||||||
try { const data = await templateService.getAll(); setTemplates(data);
|
try {
|
||||||
} catch (err) { console.error('加载模板失败:', err); }
|
const data = await templateService.getAll();
|
||||||
|
setTemplates(data);
|
||||||
|
const bankData = await questionBankService.getBank(bankId);
|
||||||
|
if (bankData.templateId) {
|
||||||
|
const tpl = data.find(tpl => tpl.id === bankData.templateId);
|
||||||
|
setTemplate(tpl || null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openGenerateModal = () => {
|
||||||
|
setShowGenerate(true);
|
||||||
|
setGenerateForm({ count: 5, knowledgeBaseContent: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const dimensionOptions = template?.dimensions?.map(d => ({ value: d.name || d.label, label: d.label || d.name }))
|
||||||
|
|| [
|
||||||
|
{ value: 'PROMPT', label: 'Prompt' },
|
||||||
|
{ value: 'LLM', label: 'LLM' },
|
||||||
|
{ value: 'IDE', label: 'IDE' },
|
||||||
|
{ value: 'DEV_PATTERN', label: 'Dev Pattern' },
|
||||||
|
{ value: 'WORK_CAPABILITY', label: 'Work Capability' },
|
||||||
|
];
|
||||||
|
|
||||||
const handleCreateItem = async (e: React.FormEvent) => {
|
const handleCreateItem = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!itemForm.questionText.trim()) return;
|
if (!itemForm.questionText.trim()) return;
|
||||||
@@ -103,8 +171,9 @@ export default function QuestionBankDetailView() {
|
|||||||
try {
|
try {
|
||||||
await questionBankService.createItem(bankId, { ...itemForm, keyPoints: keyPointsInput.split('\n').filter(k => k.trim()) });
|
await questionBankService.createItem(bankId, { ...itemForm, keyPoints: keyPointsInput.split('\n').filter(k => k.trim()) });
|
||||||
closeItemForm();
|
closeItemForm();
|
||||||
|
showSuccess(t('questionAdded'));
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (err: any) { alert('创建失败: ' + (err.message || '未知错误'));
|
} catch (err: any) { showError(err.message || t('actionFailed'));
|
||||||
} finally { setSaving(false); }
|
} finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,15 +184,17 @@ export default function QuestionBankDetailView() {
|
|||||||
try {
|
try {
|
||||||
await questionBankService.updateItem(bankId, editingItem.id, { ...itemForm, keyPoints: keyPointsInput.split('\n').filter(k => k.trim()) });
|
await questionBankService.updateItem(bankId, editingItem.id, { ...itemForm, keyPoints: keyPointsInput.split('\n').filter(k => k.trim()) });
|
||||||
closeItemForm();
|
closeItemForm();
|
||||||
|
showSuccess(t('questionUpdated'));
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (err: any) { alert('更新失败: ' + (err.message || '未知错误'));
|
} catch (err: any) { showError(err.message || t('actionFailed'));
|
||||||
} finally { setSaving(false); }
|
} finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteItem = async (itemId: string) => {
|
const handleDeleteItem = async (itemId: string) => {
|
||||||
if (!confirm('确定要删除这道题目吗?')) return;
|
const ok = await confirm({ message: t('confirmDeleteQuestion'), confirmLabel: t('delete'), cancelLabel: t('cancel') });
|
||||||
try { await questionBankService.deleteItem(bankId, itemId); fetchData();
|
if (!ok) return;
|
||||||
} catch (err: any) { alert('删除失败: ' + (err.message || '未知错误')); }
|
try { await questionBankService.deleteItem(bankId, itemId); showSuccess(t('questionDeleted')); fetchData();
|
||||||
|
} catch (err: any) { showError(err.message || t('actionFailed')); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
@@ -132,26 +203,41 @@ export default function QuestionBankDetailView() {
|
|||||||
await questionBankService.generateQuestions(bankId, generateForm.count, generateForm.knowledgeBaseContent);
|
await questionBankService.generateQuestions(bankId, generateForm.count, generateForm.knowledgeBaseContent);
|
||||||
setShowGenerate(false);
|
setShowGenerate(false);
|
||||||
setGenerateForm({ count: 5, knowledgeBaseContent: '' });
|
setGenerateForm({ count: 5, knowledgeBaseContent: '' });
|
||||||
|
showSuccess(t('generatedQuestions').replace('$1', String(generateForm.count)));
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (err: any) { alert('生成失败: ' + (err.message || '未知错误'));
|
} catch (err: any) { showError(err.message || t('actionFailed'));
|
||||||
} finally { setGenerating(false); }
|
} finally { setGenerating(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitForReview = async () => {
|
const handleSubmitForReview = async () => {
|
||||||
if (!confirm('确定要提交审核吗?')) return;
|
const ok = await confirm({ message: t('confirmSubmitReview'), confirmLabel: t('submitForReview'), cancelLabel: t('cancel') });
|
||||||
try { await questionBankService.submitForReview(bankId); fetchData();
|
if (!ok) return;
|
||||||
} catch (err: any) { alert('提交失败: ' + (err.message || '未知错误')); }
|
try { await questionBankService.submitForReview(bankId); showSuccess(t('bankSubmittedForReview')); fetchData();
|
||||||
|
} catch (err: any) { showError(err.message || t('actionFailed')); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePublish = async () => {
|
const handlePublish = async () => {
|
||||||
if (!confirm('确定要发布题库吗?')) return;
|
const isPendingReview = bank?.status === 'PENDING_REVIEW';
|
||||||
try { await questionBankService.publishBank(bankId); fetchData();
|
const label = isPendingReview ? t('approve') : t('republish');
|
||||||
} catch (err: any) { alert('发布失败: ' + (err.message || '未知错误')); }
|
const msg = isPendingReview ? t('confirmApproveBank') : t('confirmRepublishBank');
|
||||||
|
const ok = await confirm({ message: msg, confirmLabel: label, cancelLabel: t('cancel') });
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
if (isPendingReview) await questionBankService.approveBank(bankId);
|
||||||
|
else await questionBankService.publishBank(bankId);
|
||||||
|
showSuccess(isPendingReview ? t('bankApproved') : t('bankRepublished'));
|
||||||
|
fetchData();
|
||||||
|
} catch (err: any) { showError(err.message || t('actionFailed')); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApproveItem = async (itemId: string) => {
|
const handleApproveItem = async (itemId: string) => {
|
||||||
try { await questionBankService.updateItem(bankId, itemId, { status: 'PUBLISHED' } as any); fetchData();
|
try { await questionBankService.updateItem(bankId, itemId, { status: 'PUBLISHED' } as any); showSuccess(t('questionApproved')); fetchData();
|
||||||
} catch (err: any) { alert('操作失败: ' + (err.message || '未知错误')); }
|
} catch (err: any) { showError(err.message || t('actionFailed')); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectItem = async (itemId: string) => {
|
||||||
|
try { await questionBankService.batchReviewItems(bankId, [itemId], false); showSuccess(t('questionReturned')); fetchData();
|
||||||
|
} catch (err: any) { showError(err.message || t('actionFailed')); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditItem = (item: QuestionBankItem) => {
|
const openEditItem = (item: QuestionBankItem) => {
|
||||||
@@ -163,27 +249,24 @@ export default function QuestionBankDetailView() {
|
|||||||
|
|
||||||
const closeItemForm = () => { setShowAddItem(false); setEditingItem(null); };
|
const closeItemForm = () => { setShowAddItem(false); setEditingItem(null); };
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'PUBLISHED': return <span className="px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full bg-emerald-50 text-emerald-600 border border-emerald-200/50">已发布</span>;
|
|
||||||
case 'PENDING_REVIEW': return <span className="px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full bg-amber-50 text-amber-600 border border-amber-200/50">待审核</span>;
|
|
||||||
default: return <span className="px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full bg-slate-50 text-slate-500 border border-slate-200/50">草稿</span>;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600 opacity-30" />
|
<Loader2 className="w-8 h-8 animate-spin text-blue-600 opacity-20" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="space-y-4">
|
||||||
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-4"><ChevronLeft size={20} /> 返回</button>
|
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors">
|
||||||
<div className="flex items-center gap-2 text-red-500 bg-red-50 rounded-2xl p-4 border border-red-100"><AlertCircle size={18} /><span className="text-sm font-bold">{error}</span></div>
|
<ChevronLeft size={18} /><span className="text-xs font-black uppercase tracking-widest">{t('backToBankList')}</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-3 text-red-500 bg-red-50 rounded-2xl p-6 border border-red-100">
|
||||||
|
<AlertCircle size={20} /><span className="text-sm font-bold">{error}</span>
|
||||||
|
<button onClick={fetchData} className="ml-auto text-xs font-black text-red-600 hover:text-red-700 uppercase tracking-widest">{t('retry')}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -191,100 +274,182 @@ export default function QuestionBankDetailView() {
|
|||||||
const pendingItems = items.filter(i => i.status === 'PENDING_REVIEW');
|
const pendingItems = items.filter(i => i.status === 'PENDING_REVIEW');
|
||||||
const publishedItems = items.filter(i => i.status === 'PUBLISHED');
|
const publishedItems = items.filter(i => i.status === 'PUBLISHED');
|
||||||
|
|
||||||
|
const statusColors: Record<string, { bg: string; text: string; border: string; label: string; blur: string; icon: React.ReactNode }> = {
|
||||||
|
PUBLISHED: { bg: 'bg-emerald-50', text: 'text-emerald-600', border: 'border-emerald-200/50', label: t('published'), blur: 'bg-emerald-500/5', icon: <Check size={12} /> },
|
||||||
|
PENDING_REVIEW: { bg: 'bg-amber-50', text: 'text-amber-600', border: 'border-amber-200/50', label: t('pendingReview'), blur: 'bg-amber-500/5', icon: <Clock size={12} /> },
|
||||||
|
DRAFT: { bg: 'bg-slate-50', text: 'text-slate-500', border: 'border-slate-200/50', label: t('draft'), blur: 'bg-blue-500/5', icon: <FileText size={12} /> },
|
||||||
|
REJECTED: { bg: 'bg-red-50', text: 'text-red-500', border: 'border-red-200/50', label: t('rejected'), blur: 'bg-red-500/5', icon: <XCircle size={12} /> },
|
||||||
|
};
|
||||||
|
|
||||||
|
const bankStatus = statusColors[bank?.status || 'DRAFT'] || statusColors.DRAFT;
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{ label: t('questionList'), value: items.length, icon: <FileText size={18} />, classes: 'bg-slate-50 border-slate-200/50 text-slate-700' },
|
||||||
|
{ label: t('published'), value: publishedItems.length, icon: <Check size={18} />, classes: 'bg-emerald-50 border-emerald-200/50 text-emerald-700' },
|
||||||
|
{ label: t('pendingReview'), value: pendingItems.length, icon: <Clock size={18} />, classes: 'bg-amber-50 border-amber-200/50 text-amber-700' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 overflow-y-auto h-full">
|
||||||
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors mb-2">
|
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors">
|
||||||
<ChevronLeft size={18} /><span className="text-xs font-black uppercase tracking-widest">返回题库列表</span>
|
<ChevronLeft size={18} /><span className="text-xs font-black uppercase tracking-widest">{t('backToBankList')}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-14 h-14 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center shadow-sm"><BookOpen size={28} /></div>
|
<div className="w-14 h-14 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center shadow-sm shrink-0"><BookOpen size={28} /></div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-black text-slate-900">{bank?.name}</h1>
|
<h1 className="text-2xl font-black text-slate-900">{bank?.name}</h1>
|
||||||
<p className="text-sm text-slate-500 mt-1">{bank?.description || '暂无描述'}</p>
|
<p className="text-sm text-slate-500 mt-1">{bank?.description || t('noDescription')}</p>
|
||||||
<div className="flex items-center gap-3 mt-2">
|
<div className="flex items-center gap-3 mt-2 flex-wrap">
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest flex items-center gap-1.5"><Brain size={12} className="text-blue-500" />{templates.find(t => t.id === bank?.templateId)?.name || '未关联模板'}</span>
|
{template && (
|
||||||
{getStatusBadge(bank?.status || 'DRAFT')}
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-purple-50 text-purple-600 text-[10px] font-bold rounded-lg border border-purple-100/50">
|
||||||
|
<Brain size={12} />{template.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full border ${bankStatus.bg} ${bankStatus.text} ${bankStatus.border}`}>
|
||||||
|
{bankStatus.icon}{bankStatus.label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
|
||||||
|
<div className="flex gap-2 shrink-0">
|
||||||
{bank?.status === 'DRAFT' && (
|
{bank?.status === 'DRAFT' && (
|
||||||
<button onClick={handleSubmitForReview} className="px-5 py-3 bg-amber-500 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-amber-100 hover:bg-amber-600 transition-all active:scale-95">
|
<button onClick={handleSubmitForReview} className="px-5 py-3 bg-amber-500 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-amber-100 hover:bg-amber-600 transition-all active:scale-95">
|
||||||
<Send size={16} /> 提交审核
|
<Send size={16} /> {t('submitForReview')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{bank?.status === 'PENDING_REVIEW' && (
|
{(bank?.status === 'PENDING_REVIEW' || bank?.status === 'REJECTED') && (
|
||||||
<button onClick={handlePublish} className="px-5 py-3 bg-emerald-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-emerald-100 hover:bg-emerald-700 transition-all active:scale-95">
|
<button onClick={handlePublish} className="px-5 py-3 bg-emerald-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-emerald-100 hover:bg-emerald-700 transition-all active:scale-95">
|
||||||
<Check size={16} /> 发布
|
<Check size={16} /> {bank?.status === 'PENDING_REVIEW' ? t('approve') : t('republish')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => setShowGenerate(true)} className="px-5 py-3 bg-purple-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-purple-100 hover:bg-purple-700 transition-all active:scale-95">
|
<button onClick={openGenerateModal} className="px-5 py-3 bg-purple-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-purple-100 hover:bg-purple-700 transition-all active:scale-95">
|
||||||
<Sparkles size={16} /> AI生成
|
<Sparkles size={16} /> {t('aiGenerate')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{[
|
{statCards.map((stat, i) => (
|
||||||
{ label: '总题目数', value: items.length, color: 'blue', icon: <FileText size={16} /> },
|
<motion.div key={stat.label} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.08 }}
|
||||||
{ label: '待审核', value: pendingItems.length, color: 'amber', icon: <Send size={16} /> },
|
className={`rounded-2xl border p-4 ${stat.classes}`}>
|
||||||
{ label: '已发布', value: publishedItems.length, color: 'emerald', icon: <Check size={16} /> },
|
|
||||||
].map((stat, i) => (
|
|
||||||
<motion.div key={stat.label} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.1 }}
|
|
||||||
className={`bg-${stat.color}-50/50 border border-${stat.color}-100/50 rounded-2xl p-4`}>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className={`text-[10px] font-black uppercase tracking-widest text-${stat.color}-500`}>{stat.label}</span>
|
<span className="text-[10px] font-black uppercase tracking-widest opacity-70">{stat.label}</span>
|
||||||
<span className={`text-${stat.color}-500`}>{stat.icon}</span>
|
{stat.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className={`text-3xl font-black text-${stat.color}-700`}>{stat.value}</div>
|
<div className="text-3xl font-black">{stat.value}</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-black text-slate-900">题目列表</h2>
|
<h2 className="text-lg font-black text-slate-900">{t('questionList')}</h2>
|
||||||
<button onClick={() => { setShowAddItem(true); setEditingItem(null); setKeyPointsInput(''); setItemForm({ questionText: '', questionType: 'SHORT_ANSWER', keyPoints: [], difficulty: 'STANDARD', dimension: 'WORK_CAPABILITY' }); }}
|
<div className="flex items-center gap-2">
|
||||||
className="px-5 py-3 bg-blue-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all active:scale-95">
|
{selectedItemIds.size > 0 && (
|
||||||
<Plus size={16} /> 添加题目
|
<>
|
||||||
</button>
|
<button onClick={handleBatchApprove}
|
||||||
|
className="px-4 py-2.5 bg-emerald-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-emerald-100 hover:bg-emerald-700 transition-all active:scale-95">
|
||||||
|
<Check size={14} /> 通过所选 ({selectedItemIds.size})
|
||||||
|
</button>
|
||||||
|
<button onClick={handleBatchReject}
|
||||||
|
className="px-4 py-2.5 bg-red-500 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-red-100 hover:bg-red-600 transition-all active:scale-95">
|
||||||
|
<X size={14} /> 驳回所选
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button onClick={toggleSelectAll}
|
||||||
|
className="px-4 py-2.5 bg-slate-100 text-slate-600 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 hover:bg-slate-200 transition-all">
|
||||||
|
{allSelected ? '取消全选' : '全选'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setShowAddItem(true); setEditingItem(null); setKeyPointsInput(''); setItemForm({ questionText: '', questionType: 'SHORT_ANSWER', keyPoints: [], difficulty: 'STANDARD', dimension: (dimensionOptions[0]?.value as any) || 'WORK_CAPABILITY' }); }}
|
||||||
|
className="px-5 py-3 bg-blue-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all active:scale-95">
|
||||||
|
<Plus size={16} /> {t('addQuestion')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-16 text-center">
|
<div className="bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-16 text-center">
|
||||||
<FileText className="w-14 h-14 text-slate-200 mx-auto mb-4" />
|
<div className="w-14 h-14 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-4"><FileText size={28} className="text-slate-300" /></div>
|
||||||
<p className="text-slate-400 font-bold uppercase tracking-widest text-xs">暂无题目</p>
|
<p className="text-slate-400 font-black uppercase tracking-widest text-xs mb-1">{t('noQuestions')}</p>
|
||||||
<p className="text-slate-300 text-xs mt-2">点击上方按钮添加或使用AI生成</p>
|
<p className="text-slate-300 text-xs">{t('noQuestionsDesc')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="space-y-4">
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence mode="popLayout">
|
||||||
{items.map((item, idx) => (
|
{items.map((item, idx) => {
|
||||||
<motion.div key={item.id} layout initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ delay: idx * 0.03 }}
|
const itemStat = item.status === 'PUBLISHED' ? statusColors.PUBLISHED : statusColors.PENDING_REVIEW;
|
||||||
className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden">
|
return (
|
||||||
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/5 rounded-full blur-3xl -mr-16 -mt-16" />
|
<motion.div key={item.id} layout initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95 }}
|
||||||
<div className="flex items-start justify-between relative z-10">
|
transition={{ delay: Math.min(idx * 0.03, 0.3) }}
|
||||||
<div className="flex-1 min-w-0">
|
className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden">
|
||||||
<div className="flex items-center gap-2 mb-2.5 flex-wrap">
|
<div className={`absolute top-0 right-0 w-40 h-40 rounded-full blur-3xl -mr-20 -mt-20 ${itemStat.blur}`} />
|
||||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-slate-50 text-slate-600 text-[10px] font-bold rounded-lg border border-slate-100">{typeIcons[item.questionType]}{QUESTION_TYPES.find(t => t.value === item.questionType)?.label}</span>
|
<div className="relative z-10 flex items-start justify-between">
|
||||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-blue-50 text-blue-600 text-[10px] font-bold rounded-lg border border-blue-100"><Hash size={10} />{DIFFICULTIES.find(d => d.value === item.difficulty)?.label}</span>
|
{item.status === 'PENDING_REVIEW' && (
|
||||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-purple-50 text-purple-600 text-[10px] font-bold rounded-lg border border-purple-100"><Brain size={10} />{DIMENSIONS.find(d => d.value === item.dimension)?.label}</span>
|
<input type="checkbox" checked={selectedItemIds.has(item.id)}
|
||||||
{getStatusBadge(item.status)}
|
onChange={() => toggleSelectItem(item.id)}
|
||||||
</div>
|
className="mt-1.5 mr-3 w-4 h-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 shrink-0 cursor-pointer" />
|
||||||
<p className="font-bold text-slate-900 leading-relaxed">{item.questionText}</p>
|
|
||||||
{item.keyPoints.length > 0 && (
|
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
||||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mr-1">评分要点:</span>
|
|
||||||
{item.keyPoints.map((kp, i) => <span key={i} className="px-2.5 py-1 bg-amber-50 text-amber-700 text-[10px] font-bold rounded-lg border border-amber-100/50">{kp}</span>)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{item.basis && <div className="mt-2 flex items-center gap-1.5 text-[10px] text-slate-400"><FileText size={10} /><span className="font-medium">依据:</span><span>{item.basis}</span></div>}
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2.5 flex-wrap">
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-slate-50 text-slate-600 text-[10px] font-bold rounded-lg border border-slate-100">{typeIcons[item.questionType]}{t(QUESTION_TYPES.find(qt => qt.value === item.questionType)?.labelKey || 'shortAnswer')}</span>
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-blue-50 text-blue-600 text-[10px] font-bold rounded-lg border border-blue-100"><Hash size={10} />{t(DIFFICULTIES.find(d => d.value === item.difficulty)?.labelKey || 'standard')}</span>
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-purple-50 text-purple-600 text-[10px] font-bold rounded-lg border border-purple-100"><Brain size={10} />{dimensionOptions.find(d => d.value === item.dimension)?.label || item.dimension}</span>
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full border ${itemStat.bg} ${itemStat.text} ${itemStat.border}`}>{itemStat.icon}{itemStat.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="font-bold text-slate-900 leading-relaxed">{item.questionText}</p>
|
||||||
|
{item.questionType === 'MULTIPLE_CHOICE' && item.options && item.options.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-1.5 pl-1 border-l-2 border-blue-200">
|
||||||
|
{item.options.map((opt, i) => {
|
||||||
|
const letter = String.fromCharCode(65 + i);
|
||||||
|
const isCorrect = item.correctAnswer === letter;
|
||||||
|
const displayText = opt.slice(1);
|
||||||
|
return (
|
||||||
|
<div key={i} className={`flex items-center gap-2 px-3 py-2 rounded-xl text-sm ${isCorrect ? 'bg-emerald-50 border border-emerald-200' : 'bg-slate-50'}`}>
|
||||||
|
<span className={`inline-flex items-center justify-center w-6 h-6 rounded-lg text-[10px] font-black shrink-0 ${isCorrect ? 'bg-emerald-500 text-white' : 'bg-slate-200 text-slate-500'}`}>{letter}</span>
|
||||||
|
<span className={`font-medium ${isCorrect ? 'text-emerald-700' : 'text-slate-600'}`}>{displayText}</span>
|
||||||
|
{isCorrect && <Check size={14} className="text-emerald-500 shrink-0 ml-auto" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.judgment && (
|
||||||
|
<div className="mt-3 bg-blue-50/50 border border-blue-100 rounded-xl p-3">
|
||||||
|
<span className="text-[10px] font-black text-blue-400 uppercase tracking-widest">{item.questionType === 'MULTIPLE_CHOICE' ? '解析' : '判定依据'}</span>
|
||||||
|
<p className="text-xs text-slate-600 mt-1 leading-relaxed">{item.judgment}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.questionType === 'SHORT_ANSWER' && item.followupHints && item.followupHints.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5 items-center">
|
||||||
|
<span className="text-[10px] font-black text-purple-400 uppercase tracking-widest">追问方向</span>
|
||||||
|
{item.followupHints.map((hint, i) => <span key={i} className="px-2.5 py-1 bg-purple-50 text-purple-600 text-[10px] font-medium rounded-lg border border-purple-100/50">#{i + 1} {hint}</span>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.keyPoints.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5 items-center">
|
||||||
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mr-1">{t('gradingPoints')}</span>
|
||||||
|
{item.keyPoints.map((kp, i) => <span key={i} className="px-2.5 py-1 bg-amber-50 text-amber-700 text-[10px] font-bold rounded-lg border border-amber-100/50">{kp}</span>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.basis && (
|
||||||
|
<div className="mt-2 flex items-center gap-1.5 text-[10px] text-slate-400"><FileText size={10} /><span className="font-medium">{t('basis')}</span><span>{item.basis}</span></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 ml-4 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{item.status === 'PENDING_REVIEW' && (<>
|
||||||
|
<button onClick={() => handleApproveItem(item.id)} className="p-2 text-emerald-600 hover:bg-emerald-50 rounded-xl transition-all" title={t('approve')}><Check size={15} /></button>
|
||||||
|
<button onClick={() => handleRejectItem(item.id)} className="p-2 text-red-500 hover:bg-red-50 rounded-xl transition-all" title={t('rejected')}><X size={15} /></button>
|
||||||
|
</>)}
|
||||||
|
<button onClick={() => openEditItem(item)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-xl transition-all" title={t('edit')}><Edit2 size={15} /></button>
|
||||||
|
<button onClick={() => handleDeleteItem(item.id)} className="p-2 text-red-500 hover:bg-red-50 rounded-xl transition-all" title={t('delete')}><Trash2 size={15} /></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 ml-4 shrink-0">
|
</motion.div>
|
||||||
{item.status === 'PENDING_REVIEW' && <button onClick={() => handleApproveItem(item.id)} className="p-2 text-emerald-600 hover:bg-emerald-50 rounded-xl transition-all" title="通过"><Check size={15} /></button>}
|
);
|
||||||
<button onClick={() => openEditItem(item)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-xl transition-all" title="编辑"><Edit2 size={15} /></button>
|
})}
|
||||||
<button onClick={() => handleDeleteItem(item.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-xl transition-all" title="删除"><Trash2 size={15} /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -297,60 +462,37 @@ export default function QuestionBankDetailView() {
|
|||||||
<motion.div initial={{ opacity: 0, scale: 0.9, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
<motion.div initial={{ opacity: 0, scale: 0.9, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
className="w-full max-w-xl bg-white rounded-[2.5rem] shadow-2xl relative z-10 overflow-hidden">
|
className="w-full max-w-xl bg-white rounded-[2.5rem] shadow-2xl relative z-10 overflow-hidden">
|
||||||
<div className="p-8 pb-4 flex items-center justify-between border-b border-slate-100">
|
<div className="p-8 pb-4 flex items-center justify-between border-b border-slate-100">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3"><div className="w-12 h-12 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center">{editingItem ? <Edit2 size={24} /> : <Plus size={24} />}</div>
|
||||||
<div className="w-12 h-12 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center">{editingItem ? <Edit2 size={24} /> : <Plus size={24} />}</div>
|
<h3 className="text-xl font-black text-slate-900">{editingItem ? t('editQuestion') : t('addQuestionTitle')}</h3></div>
|
||||||
<h3 className="text-xl font-black text-slate-900">{editingItem ? '编辑题目' : '添加题目'}</h3>
|
|
||||||
</div>
|
|
||||||
<button onClick={closeItemForm} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-all"><X size={20} /></button>
|
<button onClick={closeItemForm} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-all"><X size={20} /></button>
|
||||||
</div>
|
</div>
|
||||||
<form id="item-form" onSubmit={editingItem ? handleUpdateItem : handleCreateItem} className="p-8 space-y-5">
|
<form id="item-form" onSubmit={editingItem ? handleUpdateItem : handleCreateItem} className="p-8 space-y-5">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5"><label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><FileText size={12} className="text-blue-500" /> {t('questionContent')} <span className="text-red-500">*</span></label>
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><FileText size={12} className="text-blue-500" /> 题目内容 *</label>
|
<textarea value={itemForm.questionText} onChange={(e) => setItemForm({...itemForm, questionText: e.target.value})} 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-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300" placeholder={t('questionContent')} rows={3} required />
|
||||||
<textarea value={itemForm.questionText} onChange={(e) => setItemForm({...itemForm, questionText: e.target.value})}
|
|
||||||
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-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300" placeholder="输入题目内容" rows={3} required />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-5">
|
<div className="grid grid-cols-2 gap-5">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5"><label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Layers size={12} className="text-blue-500" /> {t('questionType')}</label>
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Layers size={12} className="text-blue-500" /> 题型</label>
|
<select value={itemForm.questionType} onChange={(e) => setItemForm({...itemForm, questionType: e.target.value as any})} 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-blue-500/10 focus:border-blue-500/50 outline-none transition-all cursor-pointer">{ QUESTION_TYPES.map(qt => <option key={qt.value} value={qt.value}>{t(qt.labelKey)}</option>) }</select>
|
||||||
<select value={itemForm.questionType} onChange={(e) => setItemForm({...itemForm, questionType: e.target.value as any})}
|
|
||||||
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-blue-500/10 focus:border-blue-500/50 outline-none transition-all appearance-none cursor-pointer">
|
|
||||||
{QUESTION_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5"><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-blue-500" /> {t('difficultyDistribution')}</label>
|
||||||
<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-blue-500" /> 难度</label>
|
<select value={itemForm.difficulty} onChange={(e) => setItemForm({...itemForm, difficulty: e.target.value as any})} 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-blue-500/10 focus:border-blue-500/50 outline-none transition-all cursor-pointer">{ DIFFICULTIES.map(d => <option key={d.value} value={d.value}>{t(d.labelKey)}</option>) }</select>
|
||||||
<select value={itemForm.difficulty} onChange={(e) => setItemForm({...itemForm, difficulty: e.target.value as any})}
|
|
||||||
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-blue-500/10 focus:border-blue-500/50 outline-none transition-all appearance-none cursor-pointer">
|
|
||||||
{DIFFICULTIES.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5"><label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Brain size={12} className="text-blue-500" /> {t('dimension')}</label>
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Brain size={12} className="text-blue-500" /> 维度</label>
|
<select value={itemForm.dimension} onChange={(e) => setItemForm({...itemForm, dimension: e.target.value as any})} 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-blue-500/10 focus:border-blue-500/50 outline-none transition-all cursor-pointer">{ dimensionOptions.map(d => <option key={d.value} value={d.value}>{d.label}</option>) }</select>
|
||||||
<select value={itemForm.dimension} onChange={(e) => setItemForm({...itemForm, dimension: e.target.value as any})}
|
|
||||||
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-blue-500/10 focus:border-blue-500/50 outline-none transition-all appearance-none cursor-pointer">
|
|
||||||
{DIMENSIONS.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5"><label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><AlertCircle size={12} className="text-blue-500" /> {t('gradingPoints')}</label>
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><AlertCircle size={12} className="text-blue-500" /> 评分要点(每行一个)</label>
|
<textarea value={keyPointsInput} onChange={(e) => setKeyPointsInput(e.target.value)} 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-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300" placeholder={'1\n2\n3'} rows={4} />
|
||||||
<textarea value={keyPointsInput} onChange={(e) => setKeyPointsInput(e.target.value)}
|
|
||||||
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-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300" placeholder="要点1
|
|
||||||
要点2
|
|
||||||
要点3" rows={4} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-3 pt-4">
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
<button type="button" onClick={closeItemForm} className="px-6 py-4 text-sm font-black text-slate-500 hover:text-slate-700 transition-colors">取消</button>
|
<button type="button" onClick={closeItemForm} className="px-6 py-4 text-sm font-black text-slate-500 hover:text-slate-700 transition-colors">{t('cancel')}</button>
|
||||||
<button type="submit" form="item-form" disabled={saving}
|
<button type="submit" form="item-form" disabled={saving} className="px-10 py-4 bg-blue-600 text-white rounded-[1.25rem] font-black uppercase tracking-widest text-xs shadow-xl shadow-blue-100 hover:bg-blue-700 transition-all active:scale-95 flex items-center gap-2">{saving && <Loader2 size={16} className="animate-spin" />}{saving ? t('saving') : (editingItem ? t('save') : t('addQuestion'))}</button>
|
||||||
className="px-10 py-4 bg-blue-600 text-white rounded-[1.25rem] font-black uppercase tracking-widest text-xs shadow-xl shadow-blue-100 hover:bg-blue-700 transition-all active:scale-95 flex items-center gap-2">
|
|
||||||
{saving && <Loader2 size={16} className="animate-spin" />}{saving ? '保存中...' : (editingItem ? '更新' : '添加')}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>,
|
</AnimatePresence>, document.body
|
||||||
document.body
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{createPortal(
|
{createPortal(
|
||||||
@@ -361,35 +503,25 @@ export default function QuestionBankDetailView() {
|
|||||||
<motion.div initial={{ opacity: 0, scale: 0.9, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
<motion.div initial={{ opacity: 0, scale: 0.9, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
className="w-full max-w-md bg-white rounded-[2.5rem] shadow-2xl relative z-10 overflow-hidden">
|
className="w-full max-w-md bg-white rounded-[2.5rem] shadow-2xl relative z-10 overflow-hidden">
|
||||||
<div className="p-8 pb-4 flex items-center justify-between border-b border-slate-100">
|
<div className="p-8 pb-4 flex items-center justify-between border-b border-slate-100">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3"><div className="w-12 h-12 bg-purple-50 text-purple-600 rounded-2xl flex items-center justify-center"><Sparkles size={24} /></div>
|
||||||
<div className="w-12 h-12 bg-purple-50 text-purple-600 rounded-2xl flex items-center justify-center"><Sparkles size={24} /></div>
|
<h3 className="text-xl font-black text-slate-900">{t('aiGenerateTitle')}</h3></div>
|
||||||
<h3 className="text-xl font-black text-slate-900">AI生成题目</h3>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setShowGenerate(false)} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-all"><X size={20} /></button>
|
<button onClick={() => setShowGenerate(false)} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-all"><X size={20} /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-8 space-y-5">
|
<div className="p-8 space-y-5">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5"><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-purple-500" /> {t('generateCount')}</label>
|
||||||
<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-purple-500" /> 生成数量</label>
|
<input type="number" value={generateForm.count} onChange={(e) => setGenerateForm({...generateForm, count: parseInt(e.target.value) || 5})} 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-purple-500/10 focus:border-purple-500/50 outline-none transition-all" min={1} max={20} />
|
||||||
<input type="number" value={generateForm.count} onChange={(e) => setGenerateForm({...generateForm, count: parseInt(e.target.value) || 5})}
|
|
||||||
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-purple-500/10 focus:border-purple-500/50 outline-none transition-all" min={1} max={20} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><FileText size={12} className="text-purple-500" /> 知识库内容(可选)</label>
|
|
||||||
<textarea value={generateForm.knowledgeBaseContent} onChange={(e) => setGenerateForm({...generateForm, knowledgeBaseContent: e.target.value})}
|
|
||||||
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-purple-500/10 focus:border-purple-500/50 outline-none transition-all placeholder:text-slate-300" placeholder="输入知识库内容作为生成依据..." rows={4} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[10px] text-slate-400 px-1">知识库内容已自动加载</p>
|
||||||
<div className="flex gap-3 pt-4">
|
<div className="flex gap-3 pt-4">
|
||||||
<button onClick={() => setShowGenerate(false)} className="flex-1 px-6 py-4 text-sm font-black text-slate-500 hover:text-slate-700 transition-colors">取消</button>
|
<button onClick={() => setShowGenerate(false)} className="flex-1 px-6 py-4 text-sm font-black text-slate-500 hover:text-slate-700 transition-colors">{t('cancel')}</button>
|
||||||
<button onClick={handleGenerate} disabled={generating}
|
<button onClick={handleGenerate} disabled={generating} className="flex-1 px-6 py-4 bg-purple-600 text-white rounded-[1.25rem] font-black uppercase tracking-widest text-xs shadow-xl shadow-purple-100 hover:bg-purple-700 transition-all active:scale-95 flex items-center justify-center gap-2">
|
||||||
className="flex-1 px-6 py-4 bg-purple-600 text-white rounded-[1.25rem] font-black uppercase tracking-widest text-xs shadow-xl shadow-purple-100 hover:bg-purple-700 transition-all active:scale-95 flex items-center justify-center gap-2">
|
{generating ? <><Loader2 size={16} className="animate-spin" /> {t('generating')}</> : <><Sparkles size={16} /> {t('generate')}</>}</button>
|
||||||
{generating ? <><Loader2 size={16} className="animate-spin" /> 生成中...</> : <><Sparkles size={16} /> 生成</>}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>,
|
</AnimatePresence>, document.body
|
||||||
document.body
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Plus, BookOpen, ChevronRight, Trash2, Edit2 } from 'lucide-react';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { BookOpen, FileText, Layers, Loader2, Plus, Search, Trash2, Edit2, AlertCircle, Check, Clock, XCircle } from 'lucide-react';
|
||||||
import { apiClient } from '../../services/apiClient';
|
import { apiClient } from '../../services/apiClient';
|
||||||
import { templateService } from '../../services/templateService';
|
import { templateService } from '../../services/templateService';
|
||||||
import { questionBankService } from '../../services/questionBankService';
|
|
||||||
import { AssessmentTemplate } from '../../types';
|
import { AssessmentTemplate } from '../../types';
|
||||||
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import { useConfirm } from '../../contexts/ConfirmContext';
|
||||||
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
|
||||||
interface QuestionBankViewProps {
|
interface QuestionBankViewProps {
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QuestionBank {
|
interface QuestionBankItem {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -19,25 +22,27 @@ interface QuestionBank {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
|
type StatusFilter = 'ALL' | 'DRAFT' | 'PENDING_REVIEW' | 'PUBLISHED';
|
||||||
|
|
||||||
|
export default function QuestionBankView({ isAdmin: _isAdmin }: QuestionBankViewProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [banks, setBanks] = useState<QuestionBank[]>([]);
|
const { t } = useLanguage();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
const { confirm } = useConfirm();
|
||||||
|
|
||||||
|
const [banks, setBanks] = useState<QuestionBankItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [showDrawer, setShowDrawer] = useState(false);
|
const [showDrawer, setShowDrawer] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({ name: '', description: '', templateId: '' });
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
templateId: ''
|
|
||||||
});
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
|
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
|
||||||
const [loadingTemplates, setLoadingTemplates] = useState(false);
|
const [loadingTemplates, setLoadingTemplates] = useState(false);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { fetchData(); }, []);
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -47,7 +52,8 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setBanks(Array.isArray(data) ? data : (data.data || []));
|
setBanks(Array.isArray(data) ? data : (data.data || []));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || '加载失败');
|
setError(err.message || t('actionFailed'));
|
||||||
|
showError(err.message || t('actionFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -58,7 +64,7 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
|
|||||||
setLoadingTemplates(true);
|
setLoadingTemplates(true);
|
||||||
templateService.getAll()
|
templateService.getAll()
|
||||||
.then(data => setTemplates(data))
|
.then(data => setTemplates(data))
|
||||||
.catch(err => console.error('加载模板失败:', err))
|
.catch(() => showError(t('actionFailed')))
|
||||||
.finally(() => setLoadingTemplates(false));
|
.finally(() => setLoadingTemplates(false));
|
||||||
setShowDrawer(true);
|
setShowDrawer(true);
|
||||||
};
|
};
|
||||||
@@ -66,17 +72,10 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
|
|||||||
const handleCreate = async (e: React.FormEvent) => {
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!formData.name.trim()) return;
|
if (!formData.name.trim()) return;
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload: any = {
|
const payload: any = { name: formData.name, description: formData.description };
|
||||||
name: formData.name,
|
if (formData.templateId) payload.templateId = formData.templateId;
|
||||||
description: formData.description,
|
|
||||||
};
|
|
||||||
if (formData.templateId) {
|
|
||||||
payload.templateId = formData.templateId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await apiClient.request('/question-banks', {
|
const res = await apiClient.request('/question-banks', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -88,12 +87,11 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
|
|||||||
try { const parsed = JSON.parse(errBody); if (parsed.message) msg = parsed.message; } catch {}
|
try { const parsed = JSON.parse(errBody); if (parsed.message) msg = parsed.message; } catch {}
|
||||||
throw new Error(msg);
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowDrawer(false);
|
setShowDrawer(false);
|
||||||
|
showSuccess(t('questionBankCreated'));
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('创建失败:', err);
|
showError(err.message || t('actionFailed'));
|
||||||
alert('创建失败: ' + (err.message || '未知错误'));
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -101,182 +99,280 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
|
|||||||
|
|
||||||
const handleDelete = async (e: React.MouseEvent, bankId: string, bankName: string) => {
|
const handleDelete = async (e: React.MouseEvent, bankId: string, bankName: string) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!confirm(`确定要删除题库"${bankName}"吗?此操作不可恢复。`)) return;
|
const ok = await confirm({ message: t('confirmDeleteBank').replace('$1', bankName), confirmLabel: t('delete'), cancelLabel: t('cancel') });
|
||||||
|
if (!ok) return;
|
||||||
setDeletingId(bankId);
|
setDeletingId(bankId);
|
||||||
try {
|
try {
|
||||||
await questionBankService.deleteBank(bankId);
|
const res = await apiClient.request(`/question-banks/${bankId}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const errBody = await res.text().catch(() => '');
|
||||||
|
let msg = res.status.toString();
|
||||||
|
try { const parsed = JSON.parse(errBody); if (parsed.message) msg = parsed.message; } catch {}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
showSuccess(t('confirm'));
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('删除失败:', err);
|
showError(err.message || t('questionBankDeleteFailed'));
|
||||||
alert('删除失败: ' + (err.message || '未知错误'));
|
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null);
|
setDeletingId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCardClick = (bank: QuestionBank) => {
|
const handleCardClick = (bank: QuestionBankItem) => {
|
||||||
navigate(`/question-banks/${bank.id}`);
|
navigate(`/question-banks/${bank.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filteredBanks = useMemo(() => {
|
||||||
|
let result = banks;
|
||||||
|
if (statusFilter !== 'ALL') result = result.filter(b => b.status === statusFilter);
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
result = result.filter(b => b.name.toLowerCase().includes(q) || (b.description || '').toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [banks, statusFilter, searchQuery]);
|
||||||
|
|
||||||
|
const STATUS_TABS: { key: StatusFilter; label: string; icon: React.ReactNode; count: (b: QuestionBankItem[]) => number }[] = [
|
||||||
|
{ key: 'ALL', label: t('all'), icon: <Layers size={14} />, count: (b) => b.length },
|
||||||
|
{ key: 'PUBLISHED', label: t('published'), icon: <Check size={14} />, count: (b) => b.filter(i => i.status === 'PUBLISHED').length },
|
||||||
|
{ key: 'DRAFT', label: t('draft'), icon: <FileText size={14} />, count: (b) => b.filter(i => i.status === 'DRAFT').length },
|
||||||
|
{ key: 'PENDING_REVIEW', label: t('pendingReview'), icon: <Clock size={14} />, count: (b) => b.filter(i => i.status === 'PENDING_REVIEW').length },
|
||||||
|
];
|
||||||
|
|
||||||
|
const stats = useMemo(() => ({
|
||||||
|
total: banks.length,
|
||||||
|
published: banks.filter(b => b.status === 'PUBLISHED').length,
|
||||||
|
draft: banks.filter(b => b.status === 'DRAFT').length,
|
||||||
|
pending: banks.filter(b => b.status === 'PENDING_REVIEW').length,
|
||||||
|
}), [banks]);
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
PUBLISHED: t('published'),
|
||||||
|
PENDING_REVIEW: t('pendingReview'),
|
||||||
|
REJECTED: t('rejected'),
|
||||||
|
DRAFT: t('draft'),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 bg-white min-h-screen">
|
<div className="space-y-6 overflow-y-auto h-full">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">题库管理</h1>
|
<div>
|
||||||
|
<h1 className="text-2xl font-black text-slate-900">{t('questionBankManagement')}</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">{t('questionBankManagementDesc')}</p>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={openDrawer}
|
onClick={openDrawer}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
className="px-5 py-3 bg-blue-600 text-white rounded-2xl text-sm font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-blue-600/20 hover:bg-blue-700 transition-all active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
<Plus size={18} />
|
<Plus size={18} />
|
||||||
<span>创建题库</span>
|
{t('createQuestionBank')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{!loading && !error && banks.length > 0 && (
|
||||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
<div className="grid grid-cols-4 gap-4">
|
||||||
) : error ? (
|
{[
|
||||||
<div className="text-center py-8 text-red-500">错误: {error}</div>
|
{ label: t('totalBanks'), value: stats.total, color: 'bg-slate-50 border-slate-200 text-slate-700', icon: <Layers size={16} className="text-slate-500" /> },
|
||||||
) : banks.length === 0 ? (
|
{ label: t('published'), value: stats.published, color: 'bg-emerald-50 border-emerald-200/50 text-emerald-700', icon: <Check size={16} className="text-emerald-500" /> },
|
||||||
<div className="text-center py-8 text-gray-500">
|
{ label: t('draft'), value: stats.draft, color: 'bg-slate-50 border-slate-200 text-slate-700', icon: <FileText size={16} className="text-slate-500" /> },
|
||||||
<BookOpen size={48} className="mx-auto mb-4 text-gray-300" />
|
{ label: t('pendingReview'), value: stats.pending, color: 'bg-amber-50 border-amber-200/50 text-amber-700', icon: <Clock size={16} className="text-amber-500" /> },
|
||||||
<p>暂无题库,点击上方按钮创建</p>
|
].map((stat, i) => (
|
||||||
</div>
|
<motion.div key={stat.label} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.05 }}
|
||||||
) : (
|
className={`${stat.color} rounded-2xl border p-4`}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="flex items-center justify-between mb-1">
|
||||||
{banks.map((bank) => (
|
<span className="text-[10px] font-black uppercase tracking-widest opacity-70">{stat.label}</span>
|
||||||
<div
|
{stat.icon}
|
||||||
key={bank.id}
|
|
||||||
className="border rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer group relative"
|
|
||||||
onClick={() => handleCardClick(bank)}
|
|
||||||
>
|
|
||||||
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleCardClick(bank); }}
|
|
||||||
className="p-1.5 text-gray-400 hover:text-blue-600 rounded-md bg-white border shadow-sm"
|
|
||||||
title="编辑"
|
|
||||||
>
|
|
||||||
<Edit2 size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleDelete(e, bank.id, bank.name)}
|
|
||||||
disabled={deletingId === bank.id}
|
|
||||||
className="p-1.5 text-gray-400 hover:text-red-600 rounded-md bg-white border shadow-sm disabled:opacity-50"
|
|
||||||
title="删除"
|
|
||||||
>
|
|
||||||
{deletingId === bank.id ? (
|
|
||||||
<span className="w-3.5 h-3.5 border-2 border-red-500 border-t-transparent rounded-full animate-spin block"></span>
|
|
||||||
) : (
|
|
||||||
<Trash2 size={14} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-semibold pr-16">{bank.name}</h3>
|
<div className="text-2xl font-black">{stat.value}</div>
|
||||||
<p className="text-sm text-gray-500 mt-1">{bank.description || '暂无描述'}</p>
|
</motion.div>
|
||||||
<div className="flex items-center justify-between mt-3 pt-3 border-t">
|
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
|
||||||
bank.status === 'PUBLISHED' ? 'bg-green-100 text-green-700' :
|
|
||||||
bank.status === 'PENDING_REVIEW' ? 'bg-yellow-100 text-yellow-700' :
|
|
||||||
bank.status === 'REJECTED' ? 'bg-red-100 text-red-700' :
|
|
||||||
'bg-gray-100 text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{bank.status === 'PUBLISHED' ? '已发布' :
|
|
||||||
bank.status === 'PENDING_REVIEW' ? '待审核' :
|
|
||||||
bank.status === 'REJECTED' ? '已否决' : '草稿'}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-400">
|
|
||||||
{new Date(bank.createdAt).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Drawer */}
|
{!loading && !error && banks.length > 0 && (
|
||||||
<>
|
<div className="flex items-center gap-4">
|
||||||
{showDrawer && (
|
<div className="relative flex-1 max-w-sm">
|
||||||
<div
|
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 transition-opacity duration-300"
|
<input
|
||||||
onClick={() => setShowDrawer(false)}
|
type="text"
|
||||||
/>
|
value={searchQuery}
|
||||||
)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
<div
|
placeholder={t('searchQuestionBanksPlaceholder')}
|
||||||
className={`fixed right-0 top-0 h-full w-full max-w-md bg-white shadow-2xl z-50 transform transition-transform duration-300 ease-out ${showDrawer ? 'translate-x-0' : 'translate-x-full'}`}
|
className="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300"
|
||||||
>
|
/>
|
||||||
<div className="flex flex-col h-full">
|
</div>
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b bg-slate-50">
|
<div className="flex gap-1 bg-slate-50 rounded-2xl p-1 border border-slate-200">
|
||||||
<h2 className="text-xl font-semibold text-slate-800 flex items-center gap-2">
|
{STATUS_TABS.map((tab) => {
|
||||||
<Plus className="w-6 h-6 text-blue-600" />
|
const active = statusFilter === tab.key;
|
||||||
创建题库
|
return (
|
||||||
</h2>
|
<button
|
||||||
<button
|
key={tab.key}
|
||||||
onClick={() => setShowDrawer(false)}
|
onClick={() => setStatusFilter(tab.key)}
|
||||||
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-200 rounded-full transition-colors"
|
className={`flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||||
>
|
active
|
||||||
<ChevronRight size={24} />
|
? 'bg-white text-slate-900 shadow-sm border border-slate-200/50'
|
||||||
</button>
|
: 'text-slate-500 hover:text-slate-700'
|
||||||
</div>
|
}`}
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
>
|
||||||
<form id="create-form" onSubmit={handleCreate} className="space-y-6">
|
{tab.icon}
|
||||||
<div>
|
{tab.label}
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
<span className={`${active ? 'bg-slate-100 text-slate-600' : 'bg-white/50 text-slate-400'} px-1.5 py-0.5 rounded-lg text-[10px] font-black`}>
|
||||||
名称 <span className="text-red-500">*</span>
|
{tab.count(banks)}
|
||||||
</label>
|
</span>
|
||||||
<input
|
</button>
|
||||||
type="text"
|
);
|
||||||
value={formData.name}
|
})}
|
||||||
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
|
||||||
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
|
|
||||||
placeholder="输入题库名称"
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
||||||
描述
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData({...formData, description: e.target.value})}
|
|
||||||
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
|
|
||||||
placeholder="输入描述"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
||||||
关联模板
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.templateId}
|
|
||||||
onChange={(e) => setFormData({...formData, templateId: e.target.value})}
|
|
||||||
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
|
|
||||||
disabled={loadingTemplates}
|
|
||||||
>
|
|
||||||
<option value="">不选择模板</option>
|
|
||||||
{templates.map(t => (
|
|
||||||
<option key={t.id} value={t.id}>{t.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{loadingTemplates && <span className="text-xs text-slate-500">加载中...</span>}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 border-t bg-slate-50">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
form="create-form"
|
|
||||||
disabled={saving || !formData.name.trim()}
|
|
||||||
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-xl hover:bg-blue-700 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-blue-600/20"
|
|
||||||
>
|
|
||||||
<Plus size={20} />
|
|
||||||
{saving ? '创建中...' : '创建'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-blue-600 opacity-20" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center gap-3 text-red-500 bg-red-50 rounded-2xl p-6 border border-red-100">
|
||||||
|
<AlertCircle size={20} />
|
||||||
|
<span className="text-sm font-bold">{t('actionFailed')}</span>
|
||||||
|
<button onClick={fetchData} className="ml-auto text-xs font-black text-red-600 hover:text-red-700 uppercase tracking-widest">{t('retry')}</button>
|
||||||
|
</div>
|
||||||
|
) : banks.length === 0 ? (
|
||||||
|
<div className="bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-20 text-center">
|
||||||
|
<div className="w-16 h-16 bg-slate-100 rounded-3xl flex items-center justify-center mx-auto mb-6">
|
||||||
|
<BookOpen size={32} className="text-slate-300" />
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400 font-black uppercase tracking-widest text-xs mb-2">{t('noQuestionBanks')}</p>
|
||||||
|
<p className="text-slate-300 text-xs mb-6">{t('createFirstBank')}</p>
|
||||||
|
<button onClick={openDrawer} className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-2xl text-sm font-black uppercase tracking-widest hover:bg-blue-700 transition-all active:scale-[0.98] shadow-lg shadow-blue-600/20">
|
||||||
|
<Plus size={18} /> {t('createQuestionBank')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : filteredBanks.length === 0 ? (
|
||||||
|
<div className="bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-20 text-center">
|
||||||
|
<Search size={32} className="text-slate-300 mx-auto mb-4" />
|
||||||
|
<p className="text-slate-400 font-bold text-xs uppercase tracking-widest">{t('noMatchingQuestionBanks')}</p>
|
||||||
|
<p className="text-slate-300 text-xs mt-2">{t('tryChangingFilter')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{filteredBanks.map((bank) => (
|
||||||
|
<motion.div
|
||||||
|
key={bank.id}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
onClick={() => handleCardClick(bank)}
|
||||||
|
className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm hover:shadow-md transition-all cursor-pointer group relative overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className={`absolute top-0 right-0 w-32 h-32 rounded-full blur-3xl -mr-16 -mt-16 ${
|
||||||
|
bank.status === 'PUBLISHED' ? 'bg-emerald-500/5' :
|
||||||
|
bank.status === 'PENDING_REVIEW' ? 'bg-amber-500/5' :
|
||||||
|
bank.status === 'REJECTED' ? 'bg-red-500/5' : 'bg-blue-500/5'
|
||||||
|
}`} />
|
||||||
|
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<h3 className="font-black text-base text-slate-900 pr-8 line-clamp-1">{bank.name}</h3>
|
||||||
|
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity absolute top-0 right-0">
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); handleCardClick(bank); }}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all" title={t('edit')}>
|
||||||
|
<Edit2 size={13} />
|
||||||
|
</button>
|
||||||
|
<button onClick={(e) => handleDelete(e, bank.id, bank.name)} disabled={deletingId === bank.id}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all disabled:opacity-50" title={t('delete')}>
|
||||||
|
{deletingId === bank.id ? <Loader2 size={13} className="animate-spin" /> : <Trash2 size={13} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mb-4 line-clamp-2 h-8">{bank.description || t('noDescription')}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-3 border-t border-slate-50">
|
||||||
|
<span className={`px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full border ${
|
||||||
|
bank.status === 'PUBLISHED' ? 'bg-emerald-50 text-emerald-600 border-emerald-200/50' :
|
||||||
|
bank.status === 'PENDING_REVIEW' ? 'bg-amber-50 text-amber-600 border-amber-200/50' :
|
||||||
|
bank.status === 'REJECTED' ? 'bg-red-50 text-red-500 border-red-200/50' :
|
||||||
|
'bg-slate-50 text-slate-500 border-slate-200/50'
|
||||||
|
}`}>
|
||||||
|
{statusLabels[bank.status] || bank.status}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400 font-medium">
|
||||||
|
{new Date(bank.createdAt).toLocaleDateString('zh-CN')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showDrawer && (
|
||||||
|
<>
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setShowDrawer(false)} className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-40" />
|
||||||
|
<motion.div initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||||
|
className="fixed right-0 top-0 h-full w-full max-w-md bg-white shadow-2xl z-50 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center"><Plus size={22} /></div>
|
||||||
|
<h2 className="text-lg font-black text-slate-900">{t('createQuestionBank')}</h2>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowDrawer(false)} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-all"><XCircle size={22} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<form id="create-form" onSubmit={handleCreate} className="space-y-5">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
|
||||||
|
<BookOpen size={12} className="text-blue-500" /> {t('name')} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
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-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300"
|
||||||
|
placeholder={t('name')} required autoFocus />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
|
||||||
|
<FileText size={12} className="text-blue-500" /> {t('description')}
|
||||||
|
</label>
|
||||||
|
<input type="text" value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
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-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300"
|
||||||
|
placeholder={t('description')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
|
||||||
|
<Layers size={12} className="text-blue-500" /> {t('linkTemplate')}
|
||||||
|
</label>
|
||||||
|
<select value={formData.templateId} onChange={(e) => setFormData({ ...formData, templateId: e.target.value })}
|
||||||
|
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-blue-500/10 focus:border-blue-500/50 outline-none transition-all cursor-pointer"
|
||||||
|
disabled={loadingTemplates}>
|
||||||
|
<option value="">{t('noTemplate')}</option>
|
||||||
|
{templates.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||||
|
</select>
|
||||||
|
{loadingTemplates && (
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1">
|
||||||
|
<Loader2 size={12} className="animate-spin text-slate-400" />
|
||||||
|
<span className="text-[10px] text-slate-400 font-medium">{t('loading')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 border-t border-slate-100">
|
||||||
|
<button type="submit" form="create-form" disabled={saving || !formData.name.trim()}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-4 bg-blue-600 text-white font-black uppercase tracking-widest text-xs rounded-[1.25rem] hover:bg-blue-700 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-xl shadow-blue-100">
|
||||||
|
{saving ? <Loader2 size={18} className="animate-spin" /> : <Plus size={18} />}
|
||||||
|
{saving ? t('creating') : t('createQuestionBank')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ class ApiClient {
|
|||||||
headers['Authorization'] = `Bearer ${token}`;
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTenantId && activeTenantId !== 'undefined' && activeTenantId !== 'null') {
|
if (activeTenantId && activeTenantId !== 'undefined' && activeTenantId !== 'null' && activeTenantId !== 'default') {
|
||||||
headers['x-tenant-id'] = activeTenantId;
|
headers['x-tenant-id'] = activeTenantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export interface AssessmentState {
|
|||||||
status?: 'IN_PROGRESS' | 'COMPLETED';
|
status?: 'IN_PROGRESS' | 'COMPLETED';
|
||||||
report?: string;
|
report?: string;
|
||||||
finalScore?: number;
|
finalScore?: number;
|
||||||
|
passed?: boolean;
|
||||||
|
dimensionScores?: Record<string, number>;
|
||||||
|
radarData?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Certificate {
|
export interface Certificate {
|
||||||
@@ -139,8 +142,13 @@ export class AssessmentService {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportPdf(sessionId: string): Promise<{ filename: string; content: string }> {
|
async exportPdf(sessionId: string): Promise<{ filename: string; buffer: string }> {
|
||||||
const { data } = await apiClient.get<{ filename: string; content: string }>(`/assessment/${sessionId}/export/pdf`);
|
const { data } = await apiClient.get<{ filename: string; buffer: string }>(`/assessment/${sessionId}/export/pdf`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async nextQuestion(sessionId: string): Promise<{ success: boolean }> {
|
||||||
|
const { data } = await apiClient.post<{ success: boolean }>(`/assessment/${sessionId}/next-question`, {});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface QuestionBankItem {
|
|||||||
questionType: 'SHORT_ANSWER' | 'MULTIPLE_CHOICE' | 'TRUE_FALSE';
|
questionType: 'SHORT_ANSWER' | 'MULTIPLE_CHOICE' | 'TRUE_FALSE';
|
||||||
options?: string[] | null;
|
options?: string[] | null;
|
||||||
correctAnswer?: string | null;
|
correctAnswer?: string | null;
|
||||||
|
judgment?: string | null;
|
||||||
|
followupHints?: string[] | null;
|
||||||
keyPoints: string[];
|
keyPoints: string[];
|
||||||
difficulty: 'STANDARD' | 'ADVANCED' | 'SPECIALIST';
|
difficulty: 'STANDARD' | 'ADVANCED' | 'SPECIALIST';
|
||||||
dimension: 'PROMPT' | 'LLM' | 'IDE' | 'DEV_PATTERN' | 'WORK_CAPABILITY';
|
dimension: 'PROMPT' | 'LLM' | 'IDE' | 'DEV_PATTERN' | 'WORK_CAPABILITY';
|
||||||
|
|||||||
@@ -332,6 +332,12 @@ export interface TenantMember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Assessment Template Types
|
// Assessment Template Types
|
||||||
|
export interface AssessmentDimension {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AssessmentTemplate {
|
export interface AssessmentTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -343,6 +349,10 @@ export interface AssessmentTemplate {
|
|||||||
knowledgeBaseId?: string;
|
knowledgeBaseId?: string;
|
||||||
knowledgeGroupId?: string;
|
knowledgeGroupId?: string;
|
||||||
knowledgeGroup?: KnowledgeGroup;
|
knowledgeGroup?: KnowledgeGroup;
|
||||||
|
dimensions?: AssessmentDimension[];
|
||||||
|
passingScore?: number;
|
||||||
|
totalTimeLimit?: number;
|
||||||
|
perQuestionTimeLimit?: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
version: number;
|
version: number;
|
||||||
creatorId: string;
|
creatorId: string;
|
||||||
@@ -359,6 +369,10 @@ export interface CreateTemplateData {
|
|||||||
style?: string;
|
style?: string;
|
||||||
knowledgeBaseId?: string;
|
knowledgeBaseId?: string;
|
||||||
knowledgeGroupId?: string;
|
knowledgeGroupId?: string;
|
||||||
|
dimensions?: AssessmentDimension[];
|
||||||
|
passingScore?: number;
|
||||||
|
totalTimeLimit?: number;
|
||||||
|
perQuestionTimeLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateTemplateData extends Partial<CreateTemplateData> {
|
export interface UpdateTemplateData extends Partial<CreateTemplateData> {
|
||||||
|
|||||||
+232
-3
@@ -636,6 +636,12 @@ export const translations = {
|
|||||||
style: "风格要求",
|
style: "风格要求",
|
||||||
createTemplate: "创建模板",
|
createTemplate: "创建模板",
|
||||||
editTemplate: "编辑模板",
|
editTemplate: "编辑模板",
|
||||||
|
templateDimensions: "评估维度",
|
||||||
|
dimensionName: "维度名称",
|
||||||
|
dimensionLabel: "维度标签",
|
||||||
|
dimensionWeight: "权重",
|
||||||
|
addDimension: "添加维度",
|
||||||
|
removeDimension: "删除",
|
||||||
|
|
||||||
allNotes: "所有笔记",
|
allNotes: "所有笔记",
|
||||||
filterNotesPlaceholder: "筛选笔记...",
|
filterNotesPlaceholder: "筛选笔记...",
|
||||||
@@ -813,7 +819,7 @@ export const translations = {
|
|||||||
questionBasis: "出题依据",
|
questionBasis: "出题依据",
|
||||||
viewBasis: "查看依据",
|
viewBasis: "查看依据",
|
||||||
hideBasis: "隐藏依据",
|
hideBasis: "隐藏依据",
|
||||||
verified: "已验证",
|
verified: "合格",
|
||||||
fail: "失败",
|
fail: "失败",
|
||||||
comprehensiveMasteryReport: "综合能力报告",
|
comprehensiveMasteryReport: "综合能力报告",
|
||||||
newAssessmentSession: "新评测会话",
|
newAssessmentSession: "新评测会话",
|
||||||
@@ -828,6 +834,8 @@ export const translations = {
|
|||||||
deleteAssessmentSuccess: "评测记录已成功删除",
|
deleteAssessmentSuccess: "评测记录已成功删除",
|
||||||
deleteAssessmentFailed: '删除评估记录失败',
|
deleteAssessmentFailed: '删除评估记录失败',
|
||||||
view: '查看',
|
view: '查看',
|
||||||
|
exportAssessmentFailed: '导出评估报告失败',
|
||||||
|
cannotResumeInProgress: '此评估进行中,无法恢复查看',
|
||||||
|
|
||||||
// Plugins
|
// Plugins
|
||||||
pluginTitle: "插件中心",
|
pluginTitle: "插件中心",
|
||||||
@@ -933,6 +941,74 @@ export const translations = {
|
|||||||
allFormats: "所有格式支持",
|
allFormats: "所有格式支持",
|
||||||
visualVision: "视觉识别",
|
visualVision: "视觉识别",
|
||||||
releaseToIngest: "释放以注入",
|
releaseToIngest: "释放以注入",
|
||||||
|
|
||||||
|
// Question Bank Management
|
||||||
|
questionBankManagement: "题库管理",
|
||||||
|
questionBankManagementDesc: "管理和创建评测题库",
|
||||||
|
createQuestionBank: "创建题库",
|
||||||
|
searchQuestionBanksPlaceholder: "搜索题库名称或描述...",
|
||||||
|
noQuestionBanks: "暂无题库",
|
||||||
|
noMatchingQuestionBanks: "未找到匹配的题库",
|
||||||
|
createFirstBank: "点击上方按钮创建第一个题库",
|
||||||
|
totalBanks: "总题库",
|
||||||
|
pendingReview: "待审核",
|
||||||
|
rejected: "已否决",
|
||||||
|
draft: "草稿",
|
||||||
|
published: "已发布",
|
||||||
|
description: "描述",
|
||||||
|
linkTemplate: "关联模板",
|
||||||
|
noTemplate: "不选择模板",
|
||||||
|
tryChangingFilter: "尝试修改筛选条件",
|
||||||
|
|
||||||
|
// Question Bank Detail
|
||||||
|
backToBankList: "返回题库列表",
|
||||||
|
invalidBankId: "无效的题库ID",
|
||||||
|
questionList: "题目列表",
|
||||||
|
addQuestion: "添加题目",
|
||||||
|
noQuestions: "暂无题目",
|
||||||
|
noQuestionsDesc: "点击上方按钮添加或使用 AI 生成",
|
||||||
|
editQuestion: "编辑题目",
|
||||||
|
addQuestionTitle: "添加题目",
|
||||||
|
gradingPoints: "评分要点",
|
||||||
|
questionContent: "题目内容",
|
||||||
|
questionType: "题型",
|
||||||
|
shortAnswer: "简答题",
|
||||||
|
multipleChoice: "选择题",
|
||||||
|
trueFalse: "判断题",
|
||||||
|
advanced: "进阶",
|
||||||
|
specialist: "专家",
|
||||||
|
standard: "标准",
|
||||||
|
dimension: "维度",
|
||||||
|
basis: "依据:",
|
||||||
|
submitForReview: "提交审核",
|
||||||
|
approve: "审核通过",
|
||||||
|
republish: "重新发布",
|
||||||
|
aiGenerate: "AI生成",
|
||||||
|
aiGenerateTitle: "AI 生成题目",
|
||||||
|
generateCount: "生成数量",
|
||||||
|
knowledgeBaseContentOptional: "知识库内容(可选)",
|
||||||
|
generate: "生成",
|
||||||
|
generating: "生成中...",
|
||||||
|
|
||||||
|
// Question Bank Toasts
|
||||||
|
questionBankCreated: "题库已创建",
|
||||||
|
questionBankDeleteFailed: "删除失败",
|
||||||
|
questionAdded: "题目已添加",
|
||||||
|
questionUpdated: "题目已更新",
|
||||||
|
questionDeleted: "题目已删除",
|
||||||
|
bankSubmittedForReview: "题库已提交审核",
|
||||||
|
bankApproved: "题库已审核通过",
|
||||||
|
bankRepublished: "题库已重新发布",
|
||||||
|
questionApproved: "题目已通过审核",
|
||||||
|
questionReturned: "题目已退回",
|
||||||
|
generatedQuestions: "成功生成 $1 道题目",
|
||||||
|
|
||||||
|
// Question Bank Confirm
|
||||||
|
confirmDeleteBank: "确定要删除题库「$1」吗?此操作不可恢复。",
|
||||||
|
confirmDeleteQuestion: "确定要删除这道题目吗?",
|
||||||
|
confirmSubmitReview: "确定要提交审核吗?提交后将进入待审核状态。",
|
||||||
|
confirmApproveBank: "确定要审核通过此题库吗?",
|
||||||
|
confirmRepublishBank: "确定要重新发布此题库吗?",
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
aiCommandsError: "An error occurred",
|
aiCommandsError: "An error occurred",
|
||||||
@@ -1573,6 +1649,12 @@ export const translations = {
|
|||||||
style: "Style Requirements",
|
style: "Style Requirements",
|
||||||
createTemplate: "Create Template",
|
createTemplate: "Create Template",
|
||||||
editTemplate: "Edit Template",
|
editTemplate: "Edit Template",
|
||||||
|
templateDimensions: "Evaluation Dimensions",
|
||||||
|
dimensionName: "Dimension Name",
|
||||||
|
dimensionLabel: "Label",
|
||||||
|
dimensionWeight: "Weight",
|
||||||
|
addDimension: "Add Dimension",
|
||||||
|
removeDimension: "Remove",
|
||||||
|
|
||||||
allNotes: "All Notes",
|
allNotes: "All Notes",
|
||||||
filterNotesPlaceholder: "Filter notes...",
|
filterNotesPlaceholder: "Filter notes...",
|
||||||
@@ -1750,7 +1832,7 @@ export const translations = {
|
|||||||
questionBasis: "Question Basis",
|
questionBasis: "Question Basis",
|
||||||
viewBasis: "View Basis",
|
viewBasis: "View Basis",
|
||||||
hideBasis: "Hide Basis",
|
hideBasis: "Hide Basis",
|
||||||
verified: "Verified",
|
verified: "Qualified",
|
||||||
fail: "Fail",
|
fail: "Fail",
|
||||||
comprehensiveMasteryReport: "Comprehensive Mastery Report",
|
comprehensiveMasteryReport: "Comprehensive Mastery Report",
|
||||||
newAssessmentSession: "New Assessment Session",
|
newAssessmentSession: "New Assessment Session",
|
||||||
@@ -1765,6 +1847,8 @@ export const translations = {
|
|||||||
deleteAssessmentSuccess: "Assessment record deleted successfully",
|
deleteAssessmentSuccess: "Assessment record deleted successfully",
|
||||||
deleteAssessmentFailed: 'Failed to delete assessment record',
|
deleteAssessmentFailed: 'Failed to delete assessment record',
|
||||||
view: 'View',
|
view: 'View',
|
||||||
|
exportAssessmentFailed: 'Failed to export assessment report',
|
||||||
|
cannotResumeInProgress: 'Assessment in progress, cannot view',
|
||||||
|
|
||||||
// Plugins
|
// Plugins
|
||||||
pluginTitle: "Plugin Store",
|
pluginTitle: "Plugin Store",
|
||||||
@@ -1877,6 +1961,74 @@ export const translations = {
|
|||||||
allFormats: "All Formats Supported",
|
allFormats: "All Formats Supported",
|
||||||
visualVision: "Visual Recognition",
|
visualVision: "Visual Recognition",
|
||||||
releaseToIngest: "Release to Ingest",
|
releaseToIngest: "Release to Ingest",
|
||||||
|
|
||||||
|
// Question Bank Management
|
||||||
|
questionBankManagement: "Question Bank Management",
|
||||||
|
questionBankManagementDesc: "Manage and create assessment question banks",
|
||||||
|
createQuestionBank: "Create Question Bank",
|
||||||
|
searchQuestionBanksPlaceholder: "Search bank name or description...",
|
||||||
|
noQuestionBanks: "No question banks",
|
||||||
|
noMatchingQuestionBanks: "No matching question banks found",
|
||||||
|
createFirstBank: "Click the button above to create your first bank",
|
||||||
|
totalBanks: "Total Banks",
|
||||||
|
pendingReview: "Pending Review",
|
||||||
|
rejected: "Rejected",
|
||||||
|
draft: "Draft",
|
||||||
|
published: "Published",
|
||||||
|
description: "Description",
|
||||||
|
linkTemplate: "Linked Template",
|
||||||
|
noTemplate: "No template",
|
||||||
|
tryChangingFilter: "Try changing the filter criteria",
|
||||||
|
|
||||||
|
// Question Bank Detail
|
||||||
|
backToBankList: "Back to Bank List",
|
||||||
|
invalidBankId: "Invalid question bank ID",
|
||||||
|
questionList: "Question List",
|
||||||
|
addQuestion: "Add Question",
|
||||||
|
noQuestions: "No questions",
|
||||||
|
noQuestionsDesc: "Click the button above to add or use AI to generate",
|
||||||
|
editQuestion: "Edit Question",
|
||||||
|
addQuestionTitle: "Add Question",
|
||||||
|
gradingPoints: "Scoring Points",
|
||||||
|
questionContent: "Question Content",
|
||||||
|
questionType: "Question Type",
|
||||||
|
shortAnswer: "Short Answer",
|
||||||
|
multipleChoice: "Multiple Choice",
|
||||||
|
trueFalse: "True/False",
|
||||||
|
advanced: "Advanced",
|
||||||
|
specialist: "Specialist",
|
||||||
|
standard: "Standard",
|
||||||
|
dimension: "Dimension",
|
||||||
|
basis: "Basis:",
|
||||||
|
submitForReview: "Submit for Review",
|
||||||
|
approve: "Approve",
|
||||||
|
republish: "Republish",
|
||||||
|
aiGenerate: "AI Generate",
|
||||||
|
aiGenerateTitle: "AI Generate Questions",
|
||||||
|
generateCount: "Generation Count",
|
||||||
|
knowledgeBaseContentOptional: "Knowledge Base Content (optional)",
|
||||||
|
generate: "Generate",
|
||||||
|
generating: "Generating...",
|
||||||
|
|
||||||
|
// Question Bank Toasts
|
||||||
|
questionBankCreated: "Question bank created",
|
||||||
|
questionBankDeleteFailed: "Delete failed",
|
||||||
|
questionAdded: "Question added",
|
||||||
|
questionUpdated: "Question updated",
|
||||||
|
questionDeleted: "Question deleted",
|
||||||
|
bankSubmittedForReview: "Bank submitted for review",
|
||||||
|
bankApproved: "Bank approved",
|
||||||
|
bankRepublished: "Bank republished",
|
||||||
|
questionApproved: "Question approved",
|
||||||
|
questionReturned: "Question returned",
|
||||||
|
generatedQuestions: "Successfully generated $1 questions",
|
||||||
|
|
||||||
|
// Question Bank Confirm
|
||||||
|
confirmDeleteBank: "Are you sure you want to delete \"$1\"? This cannot be undone.",
|
||||||
|
confirmDeleteQuestion: "Are you sure you want to delete this question?",
|
||||||
|
confirmSubmitReview: "Submit this bank for review? It will enter pending review status.",
|
||||||
|
confirmApproveBank: "Approve this question bank?",
|
||||||
|
confirmRepublishBank: "Republish this question bank?",
|
||||||
},
|
},
|
||||||
ja: {
|
ja: {
|
||||||
aiCommandsError: "エラーが発生しました",
|
aiCommandsError: "エラーが発生しました",
|
||||||
@@ -2610,6 +2762,13 @@ export const translations = {
|
|||||||
style: "スタイル要件",
|
style: "スタイル要件",
|
||||||
createTemplate: "テンプレートを作成",
|
createTemplate: "テンプレートを作成",
|
||||||
editTemplate: "テンプレートを編集",
|
editTemplate: "テンプレートを編集",
|
||||||
|
templateDimensions: "評価ディメンション",
|
||||||
|
dimensionName: "ディメンション名",
|
||||||
|
dimensionLabel: "ラベル",
|
||||||
|
dimensionWeight: "重み",
|
||||||
|
addDimension: "ディメンションを追加",
|
||||||
|
removeDimension: "削除",
|
||||||
|
|
||||||
allNotes: "すべてのノート",
|
allNotes: "すべてのノート",
|
||||||
filterNotesPlaceholder: "ノートをフィルタリング...",
|
filterNotesPlaceholder: "ノートをフィルタリング...",
|
||||||
startWritingPlaceholder: "書き始める...",
|
startWritingPlaceholder: "書き始める...",
|
||||||
@@ -2688,7 +2847,7 @@ export const translations = {
|
|||||||
questionBasis: "出題の根拠",
|
questionBasis: "出題の根拠",
|
||||||
viewBasis: "根拠を表示",
|
viewBasis: "根拠を表示",
|
||||||
hideBasis: "根拠を非表示",
|
hideBasis: "根拠を非表示",
|
||||||
verified: "検証済み",
|
verified: "合格",
|
||||||
fail: "失敗",
|
fail: "失敗",
|
||||||
comprehensiveMasteryReport: "包括的習熟度レポート",
|
comprehensiveMasteryReport: "包括的習熟度レポート",
|
||||||
newAssessmentSession: "新しいアセスメントセッション",
|
newAssessmentSession: "新しいアセスメントセッション",
|
||||||
@@ -2703,6 +2862,8 @@ export const translations = {
|
|||||||
deleteAssessmentSuccess: "評価記録が正常に削除されました",
|
deleteAssessmentSuccess: "評価記録が正常に削除されました",
|
||||||
deleteAssessmentFailed: 'アセスメント記録の削除に失敗しました',
|
deleteAssessmentFailed: 'アセスメント記録の削除に失敗しました',
|
||||||
view: '表示',
|
view: '表示',
|
||||||
|
exportAssessmentFailed: '評価レポートのエクスポートに失敗しました',
|
||||||
|
cannotResumeInProgress: '評価進行中、表示できません',
|
||||||
|
|
||||||
// Plugins
|
// Plugins
|
||||||
pluginTitle: "プラグインストア",
|
pluginTitle: "プラグインストア",
|
||||||
@@ -2817,5 +2978,73 @@ export const translations = {
|
|||||||
allFormats: "すべてのフォーマット対応",
|
allFormats: "すべてのフォーマット対応",
|
||||||
visualVision: "視覚認識",
|
visualVision: "視覚認識",
|
||||||
releaseToIngest: "離して取り込む",
|
releaseToIngest: "離して取り込む",
|
||||||
|
|
||||||
|
// Question Bank Management
|
||||||
|
questionBankManagement: "問題バンク管理",
|
||||||
|
questionBankManagementDesc: "評価問題バンクの管理と作成",
|
||||||
|
createQuestionBank: "問題バンクを作成",
|
||||||
|
searchQuestionBanksPlaceholder: "バンク名または説明を検索...",
|
||||||
|
noQuestionBanks: "問題バンクがありません",
|
||||||
|
noMatchingQuestionBanks: "一致する問題バンクが見つかりません",
|
||||||
|
createFirstBank: "上のボタンをクリックして最初の問題バンクを作成",
|
||||||
|
totalBanks: "総バンク数",
|
||||||
|
pendingReview: "審査待ち",
|
||||||
|
rejected: "却下",
|
||||||
|
draft: "下書き",
|
||||||
|
published: "公開済み",
|
||||||
|
description: "説明",
|
||||||
|
linkTemplate: "関連テンプレート",
|
||||||
|
noTemplate: "テンプレートなし",
|
||||||
|
tryChangingFilter: "フィルター条件を変更してみてください",
|
||||||
|
|
||||||
|
// Question Bank Detail
|
||||||
|
backToBankList: "問題バンクリストに戻る",
|
||||||
|
invalidBankId: "無効な問題バンクID",
|
||||||
|
questionList: "問題リスト",
|
||||||
|
addQuestion: "問題を追加",
|
||||||
|
noQuestions: "問題がありません",
|
||||||
|
noQuestionsDesc: "上のボタンをクリックして追加するか、AIで生成してください",
|
||||||
|
editQuestion: "問題を編集",
|
||||||
|
addQuestionTitle: "問題を追加",
|
||||||
|
gradingPoints: "採点ポイント",
|
||||||
|
questionContent: "問題内容",
|
||||||
|
questionType: "問題タイプ",
|
||||||
|
shortAnswer: "記述式",
|
||||||
|
multipleChoice: "選択式",
|
||||||
|
trueFalse: "正誤式",
|
||||||
|
advanced: "上級",
|
||||||
|
specialist: "専門家",
|
||||||
|
standard: "標準",
|
||||||
|
dimension: "ディメンション",
|
||||||
|
basis: "根拠:",
|
||||||
|
submitForReview: "審査を依頼",
|
||||||
|
approve: "承認",
|
||||||
|
republish: "再公開",
|
||||||
|
aiGenerate: "AI生成",
|
||||||
|
aiGenerateTitle: "AI問題生成",
|
||||||
|
generateCount: "生成数",
|
||||||
|
knowledgeBaseContentOptional: "ナレッジベース内容(任意)",
|
||||||
|
generate: "生成",
|
||||||
|
generating: "生成中...",
|
||||||
|
|
||||||
|
// Question Bank Toasts
|
||||||
|
questionBankCreated: "問題バンクが作成されました",
|
||||||
|
questionBankDeleteFailed: "削除に失敗しました",
|
||||||
|
questionAdded: "問題が追加されました",
|
||||||
|
questionUpdated: "問題が更新されました",
|
||||||
|
questionDeleted: "問題が削除されました",
|
||||||
|
bankSubmittedForReview: "バンクが審査に提出されました",
|
||||||
|
bankApproved: "バンクが承認されました",
|
||||||
|
bankRepublished: "バンクが再公開されました",
|
||||||
|
questionApproved: "問題が承認されました",
|
||||||
|
questionReturned: "問題が差し戻されました",
|
||||||
|
generatedQuestions: "$1問の問題を生成しました",
|
||||||
|
|
||||||
|
// Question Bank Confirm
|
||||||
|
confirmDeleteBank: "「$1」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||||
|
confirmDeleteQuestion: "この問題を削除してもよろしいですか?",
|
||||||
|
confirmSubmitReview: "審査に提出しますか?審査待ち状態になります。",
|
||||||
|
confirmApproveBank: "この問題バンクを承認しますか?",
|
||||||
|
confirmRepublishBank: "この問題バンクを再公開しますか?",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user