Compare commits

..

39 Commits

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:55:38 +08:00
Developer 6e569ff478 fix: skip content check when bank questions available, early generator return 2026-05-23 22:32:51 +08:00
Developer a83de861dd fix: replace PDF with HTML report (fontkit unavailable) 2026-05-21 16:30:36 +08:00
Developer 0b0da09d4b fix: use pdf-lib embedFont with proper pagination for CJK PDF 2026-05-21 16:12:38 +08:00
Developer d7cd5641d7 fix: rewrite PDF generator using pdf-lib native embedFont for CJK support 2026-05-21 15:59:01 +08:00
Developer c53f26a07e perf: trim grader prompts ~40% to reduce LLM latency 2026-05-21 15:52:33 +08:00
Developer b15e821252 feat: enriched certificate with template name, dimension scores, question details + Modal UI
- generateCertificate: return templateName, questionDetails, dimensionScores
- Frontend: replace alert() with certificate Modal showing level, scores, dimensions, questions
- Status label: change from '已验证' to '合格'
2026-05-21 15:42:59 +08:00
Developer 990b8c7b83 fix: forward passed flag in SSE final events 2026-05-21 15:24:36 +08:00
Developer f8df92c36b fix: forward finalScore in submitAnswerStream final event 2026-05-21 15:19:10 +08:00
Developer 51f2a41cc3 fix: determineLevel uses 0-10 scale thresholds instead of 0-100 2026-05-21 15:07:23 +08:00
Developer 0a3a8a2e32 fix: send accumulated answers to LLM grader for follow-up context
- Grader now passes all rounds of user answers to LLM (tagged 第N轮回答)
- LLM can see what was already answered and avoid redundant follow-ups
- Updated all three language prompts with multi-round guidance
2026-05-21 14:41:57 +08:00
Developer 9303d7ac64 fix: auto-submit answer on timeout instead of blocking
- Timeout triggers forced submission of current answer (or empty)
- Prevents assessment from hanging when time expires
- autoSubmitted flag prevents duplicate submissions
2026-05-21 14:32:01 +08:00
Developer 02f4ab23f7 feat: LLM-generated adaptive follow-up questions
- Grader: LLM outputs follow_up_question targeting uncovered keyPoints
- Remove static followupHints usage in grading flow
- maxFollowUps sourced from question.maxFollowUps (hints.length)
- Clean answerKey: remove followupHints field
- Three-language prompt update with examples and bad examples
- Grader spec: add follow_up_question to mock responses
2026-05-21 14:18:14 +08:00
Developer 7fd2a4cda2 fix: option display + partial credit grading
- Option display: use slice(1) instead of regex to strip letter prefix
- Grader prompts: add explicit partial credit guidance (5-7 for partial, 0-2 only for off-target)
2026-05-21 13:13:21 +08:00
Developer 7b1103903f fix: remove prefix from followup hint, use raw text 2026-05-21 13:00:03 +08:00
Developer 3cc3b28471 fix: broader regex to strip conditional prefix from followup hints 2026-05-21 12:57:04 +08:00
Developer 5c82c75a09 fix: strip option letter prefix in QuestionBankDetailView
Consistent with AssessmentView, now strips A./B./C./D. prefix
from option text before displaying alongside letter badge.
2026-05-21 12:48:35 +08:00
Developer 24ffc028e2 fix: shuffle choice options per session + clean followup hints
- Options shuffled with correctAnswer remapped at session creation
- Followup hints strip conditional prefix (如果只说了XX,追问:)
2026-05-21 12:42:52 +08:00
Developer 734c0129d8 chore: remove test artifact 2026-05-21 11:53:47 +08:00
Developer 1224a74e63 fix: natural follow-up conversation flow
- Grader: separate followup hint from scoring feedback
- Interviewer: use followup hint directly without prefix/suffix
- Restored standard and choice question presentation paths
2026-05-21 11:53:24 +08:00
Developer c015ea3697 fix: shuffle bank questions + grader LLM error resilience
- selectQuestions: shuffle final result for random question order
- grader: wrap LLM invoke in try-catch, default score 5 on failure
- grader: inner try-catch for JSON parse errors, graceful fallback
2026-05-21 11:33:17 +08:00
Developer 240aea24aa fix: linkedGroupIds null check in validateRequiredFields
null !== undefined was true, causing false validation failure on templates
without linked groups. Changed to != null check.
2026-05-21 11:17:45 +08:00
Developer 54762ca299 fix: passingScore scaling and dimensions propagation
- Frontend: divide by 10 on load, multiply by 10 on send (UI:0-10, DB:0-100)
- Backend: include template dimensions in session templateData snapshot
2026-05-21 11:07:07 +08:00
Developer eba30517a6 fix: remove bank PUBLISHED guard from selectQuestions
selectQuestions now only checks item-level PUBLISHED status.
startSession already handles bank detection by counting published items.
This fixes assessment always falling back to LLM generation.
2026-05-21 10:26:19 +08:00
Developer 35b1c6c37d feat: judgment-anchored grading and per-question results
- Grader: inject judgment as pass criteria anchor in LLM prompt
- Grader: use followupHints for follow-up direction (not generic text)
- Grader: follow-up limit from followupHints.length instead of hardcoded 2
- Session: correctAnswer/judgment stored in questions, stripped during assessment
- Frontend: per-question results panel with choice / + judgment display
2026-05-21 10:18:15 +08:00
Developer 3993099907 feat: end-to-end choice question support in assessment pipeline
- Data pathway: flow options through questions, answerKey in graph state
- Interviewer: format MULTIPLE_CHOICE with A/B/C/D options
- Grader: instant choice scoring (zero LLM), compare correctAnswer
- AssessmentView: render choice buttons vs textarea based on questionType
- Security: sanitizeStateForClient strips correctAnswer/judgment/answerKey
- Bank detection: check PUBLISHED items (not PUBLISHED bank status)
- Batch UI: select all / batch approve / batch reject on detail view
2026-05-21 10:06:33 +08:00
Developer 57898f939c fix: add status guards to prevent data loss
- create: auto-delete REJECTED→throw error; add tenantId filter
- remove: forbid PUBLISHED bank deletion
- removeItem: forbid PUBLISHED item deletion
- generateQuestions: restrict to DRAFT status only
- frontend: render MULTIPLE_CHOICE options/judgment/followupHints
- frontend: add judgment and followupHints to QuestionBankItem type
- add 12 service guard tests (109 total)
2026-05-21 08:55:35 +08:00
Developer e782d180d7 feat: support choice+open dual question generation with judgment anchors
- Add judgment and followupHints fields to QuestionBankItem entity
- Rewrite generateQuestions prompt for 3:7 choice:open ratio
- Extract parseGeneratedQuestion function with type-aware parsing
- Add 29 unit tests: 14 prompt content + 15 parse logic
- Total: 97 tests passing (59 baseline + 38 new)
2026-05-21 01:04:08 +08:00
Developer 17ddfa83bf Question generation: scenario-based 3-step prompt with technique labeling, key_points constrained to KB source, temperature 0.1. Generator node: two-step extraction prompt for assessment flow. 2026-05-20 17:33:28 +08:00
Developer 83483d8117 F1-F10: audit fixes (dimension normalize, passingScore scale, DB defaults, onDelete, item status filter, timeout event type, userId privacy) + generator.node.ts strict prompt rules (anti-hallucination) 2026-05-20 11:13:37 +08:00
Developer 29bac74b58 M3: console.log -> Logger + UI redesign (QuestionBank) + S7/A9/A10/A11/U11 bug fixes + #1/#2/#3/#4 enhancements + i18n for QuestionBank pages 2026-05-19 16:57:45 +08:00
Developer 5b5f14674d fix: minor issues from code review
(M1) DTO: @IsObject({ each: true }) on dimensions array
(M2) audit log: add missing tenantId in submitAnswer
(M3) console.log -> this.logger in controller + service
2026-05-19 10:22:18 +08:00
Developer 82a9e75842 fix: code review — 7 issues resolved
(C1) Add dimensionScores/radarData/passed columns to AssessmentSession
(C2) Mock DataSource in service.spec.ts + app.e2e-spec.ts
(C3) Mock AuditLogService in controller.spec.ts
(C4) Rewrite deleteSession tests for dataSource.transaction
(I1) batchDeleteSessions uses transaction with certificate cleanup
(I2) extractDimensionScores reads from session property
(I3/I5) PDF generator supports multi-page + newline splitting
(I4) findOne inside transaction uses deleteCondition
2026-05-19 10:06:30 +08:00
Developer 7f8e7214b3 P3-02-03-04: audit log, batch ops, transactions
P3-02: audit-log.entity + service, manual logging in controller
  (startSession, submitAnswer, deleteSession, review, forceEnd)
P3-03: POST batch-delete, POST batch-export endpoints + service methods
P3-04: DataSource.transaction for deleteSession + reviewAssessment,
  graph state cleanup on session delete
2026-05-19 09:52:31 +08:00
Developer eb0798de5b P2-1: remove dead cost-control module (3 files)
P2-2: switch TypeORM to autoLoadEntities: true

Remove unused vision-pipeline-cost-aware.service.ts,
cost-control.service.ts and its orphan module.
Switch explicit entities[] list to autoLoadEntities.
2026-05-19 09:39:41 +08:00
Developer 33e48f6d4e P1-3: grader/interviewer node unit tests (24 passing)
grader.node.spec.ts — 13 tests: LLM mock validation, breakout logic
(shorts/IDontKnow), error handling, scoring/indexing, zh/ja language support

interviewer.node.spec.ts — 11 tests: empty questions, index bounds,
standard presentation, follow-up mode, zh/ja/en localization
2026-05-19 09:30:19 +08:00
Developer b139ae18b7 P1-2: certificate E2E integration tests + API verification
- Certificate lifecycle tests: create/verify/idempotency/level
- Public endpoint integration tests for verifyCertificate and getPublicCertificateInfo
- API verified: /public returns 200, /verify returns 200, auth endpoint returns 404 for missing
2026-05-19 09:26:34 +08:00
Developer 68371922ca P0-1/P0-2/P1-1: dimensions form + E2E tests + PDF export
P0-1 Backend: dimensions column on template entity + validation
P0-1 Frontend: dimensions edit UI in TemplateManager
P0-2: routeAfterGrading unit tests (10 cases), service spec fix + certificate tests, jest-e2e.json
P1-1: proper PDF generation with embedded CJK font via pdf-lib low-level API
2026-05-19 08:42:03 +08:00
59 changed files with 4593 additions and 1738 deletions
+476
View File
@@ -0,0 +1,476 @@
/**
* IDE 协作开发题库 — 种子脚本
* 运行方式:cd D:/AuraK/server && node scripts/seed-ide-questions.js
*/
const { DatabaseSync } = require('node:sqlite');
const crypto = require('crypto');
const path = require('path');
const DB_PATH = path.join(__dirname, '..', 'data', 'metadata.db');
const BANK_ID = crypto.randomUUID();
const TENANT_ID = 'a140a68e-f70a-44d3-b753-fa33d48cf234'; // 现有tenant
const ADMIN_USER_ID = '1cf8ba6d-d184-4055-ab58-99c6f38bbf93'; // admin用户
const db = new DatabaseSync(DB_PATH);
function uuid() {
return crypto.randomUUID();
}
function now() {
return new Date().toISOString().replace('T', ' ').substring(0, 22) + '000';
}
const ts = now();
// ===== 创建题库 =====
console.log('Creating IDE question bank...');
db.prepare(`
INSERT INTO question_banks (id, tenant_id, template_id, name, description, status, created_by, reviewed_by, reviewed_at, review_comment, created_at, updated_at)
VALUES (?, ?, NULL, ?, ?, 'PUBLISHED', ?, ?, ?, ?, ?, ?)
`).run(BANK_ID, TENANT_ID, 'IDE协作开发题库', 'L1课程四:IDE协作开发考核题库,包含Copilot、Claude Code、OpenCode三种工具的50道题目', ADMIN_USER_ID, ADMIN_USER_ID, ts, 'approve', ts, ts);
// ===== 生成题目 =====
const items = [];
function addItem(questionText, questionType, options, correctAnswer, keyPoints, difficulty, dimension, basis, judgment) {
items.push({
questionText,
questionType,
options: options ? JSON.stringify(options) : null,
correctAnswer,
keyPoints: JSON.stringify(keyPoints),
difficulty: difficulty || 'STANDARD',
dimension: dimension || 'IDE',
basis: basis || null,
judgment: judgment || null,
});
}
// ================================================
// 一、GitHub Copilot — 智能代码补全(4题)
// ================================================
addItem(
'以下说法是否正确?(✓ / ✗)\n\n看到 Copilot 给出的灰色补全建议后,按 Tab 键可以接受建议。',
'TRUE_FALSE', null, '✓',
['Tab键接受补全建议', 'Copilot基础操作'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n看到 Copilot 给出的补全建议后,按 Esc 键可以拒绝这个建议。',
'TRUE_FALSE', null, '✓',
['Esc键拒绝补全建议', 'Copilot基础操作'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n写函数开头后,Copilot 会自动逐行补全后续逻辑。',
'TRUE_FALSE', null, '✓',
['Copilot智能补全功能', '注释驱动代码生成'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章'
);
addItem(
'小张用智能补全生成了一段代码,看起来功能正常。\n\n问:在正式使用这段代码前,他应该先做什么?',
'SHORT_ANSWER', null, '审查代码逻辑,确认没有语法错误或逻辑漏洞后再使用。AI 生成的代码需要人工审核。',
['人工审核AI生成代码', '代码审查', '不直接使用AI生成的代码'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 红线警告'
);
// ================================================
// 二、GitHub Copilot — Chat 三种模式(5题)
// ================================================
addItem(
'小张接手了一个老项目,打开 OrderService.java 发现有段逻辑看不太懂。他打开 Copilot Chat,想先问问这段代码是干什么的。\n\nCopilot Chat 有以下三种模式:Ask / Plan / Agent\n\n问:小张应该选择哪种模式?',
'SHORT_ANSWER', null, 'Ask(问答模式)。Ask 模式只回答问题,不修改代码。',
['Ask模式适用场景', '不修改代码的对话方式'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章 三种对话模式'
);
addItem(
'小李需要在三个文件中新增一个「批量删除用户」的功能,希望 AI 直接帮他完成代码修改。\n\nCopilot Chat 有以下三种模式:Ask / Plan / Agent\n\n问:小李应该选择哪种模式?',
'SHORT_ANSWER', null, 'Agent(智能代理模式)。Agent 模式可以自动跨文件修改代码。',
['Agent模式适用场景', '跨文件修改任务'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章 三种对话模式'
);
addItem(
'小赵想在项目中新增一个功能,但不知道涉及哪些文件、影响范围多大,想让 AI 先扫描整个项目给出方案。\n\nCopilot Chat 有以下三种模式:Ask / Plan / Agent\n\n问:小赵应该选择哪种模式?',
'SHORT_ANSWER', null, 'Plan(计划模式)。Plan 模式只出方案不动代码,适合先评估再动手。',
['Plan模式适用场景', '先规划再执行'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章 三种对话模式'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\nAsk 模式下,Copilot 只会回答问题,不会修改用户的代码。',
'TRUE_FALSE', null, '✓',
['Ask模式只回答不修改'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\nAgent 模式下,Copilot 可以跨多个文件修改代码。',
'TRUE_FALSE', null, '✓',
['Agent模式跨文件修改'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章'
);
// ================================================
// 三、GitHub Copilot — CLI 使用(3题)
// ================================================
addItem(
'小刘想用 Copilot CLI 重构一个 Python 脚本,过程中要多次对话、逐步调优。\n\nCopilot CLI 有以下两种使用方式:交互模式(copilot)/ 非交互模式(copilot -p "指令"\n\n问:小刘应该选择哪种方式?',
'SHORT_ANSWER', null, '交互模式(copilot)。交互模式支持多轮对话,适合需要迭代的复杂任务。',
['CLI交互模式适用场景', '多轮对话重构'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - CLI 使用'
);
addItem(
'小钱想用 Copilot CLI 快速解释一下 git diff 的结果,不想进入交互式对话。\n\nCopilot CLI 有以下两种使用方式:交互模式(copilot)/ 非交互模式(copilot -p "指令"\n\n问:小钱应该选择哪种方式?',
'SHORT_ANSWER', null, '非交互模式(copilot -p "指令")。非交互模式适合一次性任务,快速获得结果后退出。',
['CLI非交互模式适用场景', '一次性任务'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - CLI使用'
);
addItem(
'小赵在 Copilot CLI 交互模式中,想清空当前对话上下文重新开始。\n\nCopilot CLI 中常用的斜杠命令有:/clear / /model / /session / /exit\n\n问:小赵应该使用哪个命令?',
'SHORT_ANSWER', null, '/clear',
['CLI斜杠命令', '/clear清空对话'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - CLI斜杠命令'
);
// ================================================
// 四、Claude Code — 四种交互方式(6题)
// ================================================
addItem(
'以下说法是否正确?(✓ / ✗)\n\n在 Claude Code 中输入 @src/utils.js 可以让 AI 读取该文件。',
'TRUE_FALSE', null, '✓',
['Claude Code文件引用', '@语法'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 文件引用'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n在 Claude Code 中输入 /clear 可以清空当前对话。',
'TRUE_FALSE', null, '✓',
['Claude Code斜杠命令', '/clear清屏'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 斜杠命令'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n在 Claude Code 中输入 !git status 可以查看 Git 状态。',
'TRUE_FALSE', null, '✓',
['Claude Code Bash模式', '!语法执行系统命令'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 Bash模式'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n在 Claude Code 中所有操作都必须用特殊符号,自然语言输入不能完成任何功能。',
'TRUE_FALSE', null, '✗。自然语言也可以完成大部分功能,特殊符号用于特定场景。',
['自然语言可用', '特殊符号的定位'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 交互方式'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n在 Claude Code 中输入 !npm run dev 可以启动开发服务器。',
'TRUE_FALSE', null, '✓',
['Claude Code Bash模式', '!语法启动开发服务器'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 Bash模式'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n在 Claude Code 中输入 /help 可以查看所有可用命令。',
'TRUE_FALSE', null, '✓',
['Claude Code斜杠命令', '/help帮助'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 斜杠命令'
);
// ================================================
// 五、Claude Code — 模型选择与切换(3题)
// ================================================
addItem(
'Claude Code 的三个模型特点如下:\n- Sonnet:主力工程师,日常编码首选\n- Haiku:响应极快、成本低,适合简单任务\n- Opus:处理超级复杂的难题,智商最高\n\n小陈需要修复一个非常复杂的系统架构 Bug。\n\n问:他应该选择哪个模型?',
'SHORT_ANSWER', null, 'Opus',
['Opus模型适用场景', '复杂难题'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 模型选择'
);
addItem(
'Claude Code 的三个模型特点如下:\n- Sonnet:主力工程师,日常编码首选\n- Haiku:响应极快、成本低,适合简单任务\n- Opus:处理超级复杂的难题,智商最高\n\n小陈在做日常的 CRUD 接口开发。\n\n问:他应该选择哪个模型?',
'SHORT_ANSWER', null, 'Sonnet',
['Sonnet模型适用场景', '日常编码'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 模型选择'
);
addItem(
'Claude Code 的三个模型特点如下:\n- Sonnet:主力工程师,日常编码首选\n- Haiku:响应极快、成本低,适合简单任务\n- Opus:处理超级复杂的难题,智商最高\n\n小陈想快速查一下某个 JavaScript 数组方法的语法。\n\n问:他应该选择哪个模型?',
'SHORT_ANSWER', null, 'Haiku',
['Haiku模型适用场景', '快速简单任务'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 模型选择'
);
// ================================================
// 六、Claude Code — CLI 命令(3题)
// ================================================
addItem(
'小赵的 Claude Code 会话因为终端意外关闭了,想接着刚才的对话继续。\n\nClaude CLI 有以下命令:claude / claude --continue / claude --resume\n\n问:小赵应该用哪个命令?',
'SHORT_ANSWER', null, 'claude --continue(或 claude -c)。该命令用于恢复上次意外关闭的会话。',
['恢复会话', '--continue参数'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 CLI命令'
);
addItem(
'小钱想用 Claude Code 快速解释一下 git diff 的结果,不想进入交互式对话。\n\nClaude CLI 有以下命令:claude / claude -p "指令" / claude --resume\n\n问:小钱应该用哪个命令?',
'SHORT_ANSWER', null, 'claude -p "指令"。-p 参数用于一次性任务,适合快速执行。',
['一次性任务', '-p参数'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 CLI命令'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\nclaude --resume 可以从历史会话列表中选择恢复。',
'TRUE_FALSE', null, '✓',
['恢复历史会话', '--resume参数'],
'STANDARD', 'IDE', 'Claude Code 使用指南 - 第3章 CLI命令'
);
// ================================================
// 七、OpenCode — 整体认知(3题)
// ================================================
addItem(
'以下说法是否正确?(✓ / ✗)\n\nOpenCode 像一位身边的搭档,可以直接读取项目文件、修改代码、执行命令。',
'TRUE_FALSE', null, '✓',
['OpenCode核心定位', 'AI编码代理'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第1章 核心定位'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n传统 AI 像远程顾问,给你建议但需要你自己动手;OpenCode 可以直接帮你操作。',
'TRUE_FALSE', null, '✓',
['OpenCode与传统AI区别', '直接操作能力'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第1章'
);
addItem(
'小周想用 OpenCode 读取包含客户个人信息的代码文件,让 AI 帮忙优化。\n\n问:这种做法是否合适?为什么?',
'SHORT_ANSWER', null, '不合适。客户个人信息属于敏感数据,严禁输入任何公共 AI 工具。应该先对数据进行脱敏处理,用虚构数据或占位符替代后再使用。',
['安全合规', '敏感数据保护', '数据脱敏'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 红线警告'
);
// ================================================
// 八、OpenCode — 安装与使用方式(4题)
// ================================================
addItem(
'OpenCode 有以下四种使用方式:\n- 终端版 — 轻量启动快,适合有基础的用户\n- 桌面应用 — 界面直观,适合新手\n- IDE 扩展 — 深度绑定编辑器\n- Web 版 — 浏览器访问,可远程部署\n\n小周是新手,不喜欢操作命令行,想找一个界面直观的方式。\n\n问:他应该选择哪种方式?',
'SHORT_ANSWER', null, '桌面应用',
['OpenCode使用方式选择', '桌面应用适合新手'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第2章 使用方式'
);
addItem(
'OpenCode 有以下四种使用方式:终端版 / 桌面应用 / IDE 扩展 / Web 版\n\n小刘平时用 VS Code 写代码,希望不离开编辑器就能用 OpenCode。\n\n问:他应该选择哪种方式?',
'SHORT_ANSWER', null, 'IDE 扩展',
['OpenCode IDE扩展', '编辑器集成'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第2章 使用方式'
);
addItem(
'OpenCode 有以下四种使用方式:终端版 / 桌面应用 / IDE 扩展 / Web 版\n\n小马需要在远程服务器上开发,只能通过命令行操作。\n\n问:他应该选择哪种方式?',
'SHORT_ANSWER', null, '终端版',
['OpenCode终端版', '远程服务器开发'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第2章 使用方式'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n在终端中输入 opencode 可以启动 OpenCode。',
'TRUE_FALSE', null, '✓',
['OpenCode启动命令'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第2章 安装'
);
// ================================================
// 九、OpenCode — Plan / Build 工作模式(5题)
// ================================================
addItem(
'小周接手了一个新项目,想先让 OpenCode 分析项目结构,还不想修改任何文件。\n\nOpenCode 有以下两种工作模式:Plan / Build\n\n问:小周应该选择哪种模式?',
'SHORT_ANSWER', null, 'Plan(计划模式)。Plan 模式下 AI 只能读取文件,不会修改代码。',
['Plan模式', '只读分析'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章 Plan模式'
);
addItem(
'小周已经确认了修改方案,想让 OpenCode 开始实际修改代码。\n\nOpenCode 有以下两种工作模式:Plan / Build\n\n问:小周应该选择哪种模式?',
'SHORT_ANSWER', null, 'Build(构建模式)。Build 模式下 AI 可以编辑文件和执行命令。',
['Build模式', '实际代码修改'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章 Build模式'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\nPlan 模式下 AI 只能读取文件,不会修改任何代码。',
'TRUE_FALSE', null, '✓',
['Plan模式只读特性'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\nBuild 模式下 AI 可以编辑文件和执行命令。',
'TRUE_FALSE', null, '✓',
['Build模式可写特性'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章'
);
addItem(
'小周让 OpenCode 在 Build 模式下修改了多个文件。\n\n问:修改完成后,他应该先做什么?',
'SHORT_ANSWER', null, '审查 AI 修改的代码,确认逻辑正确后再使用。AI 生成的代码不能直接部署到生产环境。',
['代码审查', 'AI生成代码需人工审核'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 安全原则'
);
// ================================================
// 十、OpenCode — 常用命令(5题)
// ================================================
addItem(
'小周用 OpenCode 修改了代码,但发现改错了,想撤销刚才的修改。\n\nOpenCode 中有以下命令:/undo / /redo / /clear / /init\n\n问:他应该使用哪个命令?',
'SHORT_ANSWER', null, '/undo',
['OpenCode撤销命令', '/undo'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章 撤销更改'
);
addItem(
'小周撤销了修改后又觉得还是刚才改得好,想恢复回来。\n\nOpenCode 中有以下命令:/undo / /redo / /clear / /init\n\n问:他应该使用哪个命令?',
'SHORT_ANSWER', null, '/redo',
['OpenCode重做命令', '/redo'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章'
);
addItem(
'小周想在新项目目录中创建 AGENTS.md 文件,让 OpenCode 了解项目结构。\n\nOpenCode 中有以下命令:/undo / /redo / /clear / /init\n\n问:他应该使用哪个命令?',
'SHORT_ANSWER', null, '/init',
['OpenCode初始化', '/init命令'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第4章 项目初始化'
);
addItem(
'小周想看看 OpenCode 当前有哪些可用的斜杠命令和快捷键。\n\nOpenCode 中有以下命令:/help / /models / /connect / /exit\n\n问:他应该使用哪个命令?',
'SHORT_ANSWER', null, '/help',
['OpenCode帮助', '/help命令'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章 斜杠命令'
);
addItem(
'小周想切换 OpenCode 正在使用的 AI 模型。\n\nOpenCode 中有以下命令:/help / /models / /connect / /exit\n\n问:他应该使用哪个命令?',
'SHORT_ANSWER', null, '/models',
['OpenCode模型切换', '/models命令'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章 模型选择'
);
// ================================================
// 十一、OpenCode — 模型选择(2题)
// ================================================
addItem(
'以下说法是否正确?(✓ / ✗)\n\nOpenCode 内置多款免费模型,启动后可以直接选择使用,无需配置 API 密钥。',
'TRUE_FALSE', null, '✓',
['OpenCode免费模型', '无需API密钥'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章 模型'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n使用第三方 LLM 提供商(如 OpenAI、Anthropic)需要自行承担 API 费用。',
'TRUE_FALSE', null, '✓',
['第三方LLM费用', 'API成本'],
'STANDARD', 'IDE', 'OpenCode 使用指南 - 第3章 模型'
);
// ================================================
// 十二、Debug — 调试助手(7题)
// ================================================
addItem(
'小吴的代码运行时报错了,不知道问题出在哪,想让 Copilot Chat 帮他定位和解决 Bug。\n\nCopilot Chat 中有以下命令:/fix / /tests / /explain / /debug\n\n问:他应该使用哪个命令?',
'SHORT_ANSWER', null, '/debug。该命令专用于帮助定位和解决 Bug。',
['/debug命令', '定位解决Bug'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章 内置命令'
);
addItem(
'小吴已经知道问题在哪了,想让 Copilot 直接修复选中的代码。\n\nCopilot Chat 中有以下命令:/fix / /tests / /explain / /debug\n\n问:他应该使用哪个命令?',
'SHORT_ANSWER', null, '/fix。该命令用于自动修复代码问题。',
['/fix命令', '自动修复代码'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章 内置命令'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n在 Copilot Chat 中输入 /tests 可以生成选中代码的单元测试。',
'TRUE_FALSE', null, '✓',
['/tests命令', '生成单元测试'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章 内置命令'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n在 Copilot Chat 中输入 /explain 可以让 AI 解释选中代码的逻辑。',
'TRUE_FALSE', null, '✓',
['/explain命令', '解释代码逻辑'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章 内置命令'
);
addItem(
'以下说法是否正确?(✓ / ✗)\n\n/debug 命令可以帮助定位和解决 Bug。',
'TRUE_FALSE', null, '✓',
['/debug命令功能'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 第3章 内置命令'
);
addItem(
'小吴用 /fix 命令让 Copilot 自动修复了代码。\n\n问:修复完成后,他应该先做什么?',
'SHORT_ANSWER', null, '审查修复后的代码,确认修改正确、逻辑无误后再使用。',
['代码审查', '修复后验证'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 安全原则'
);
addItem(
'小吴遇到一个 Bug,想把包含数据库连接串的配置文件贴到 Copilot Chat 中用 /debug 分析。\n\n问:这种做法是否合适?为什么?',
'SHORT_ANSWER', null, '不合适。数据库连接串属于敏感信息,严禁输入公共 AI 工具。应该用脱敏数据或占位符替代后再进行分析。',
['安全合规', '敏感数据保护', '脱敏处理'],
'STANDARD', 'IDE', 'GitHub Copilot 使用指南 - 红线警告'
);
// ===== 批量插入题目 =====
console.log(`Inserting ${items.length} questions...`);
const insertItem = db.prepare(`
INSERT INTO question_bank_items (id, bank_id, question_text, questionType, options, correctAnswer, key_points, difficulty, dimension, basis, judgment, status, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PUBLISHED', ?, ?, ?)
`);
for (const item of items) {
insertItem.run(
uuid(),
BANK_ID,
item.questionText,
item.questionType,
item.options,
item.correctAnswer,
item.keyPoints,
item.difficulty,
item.dimension,
item.basis,
item.judgment,
ADMIN_USER_ID,
ts,
ts
);
}
console.log(`Successfully inserted ${items.length} IDE questions into bank ${BANK_ID}`);
db.close();
+4 -2
View File
@@ -1,10 +1,12 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { ChatOpenAI } from '@langchain/openai';
import { ModelConfig } from '../types';
import { I18nService } from '../i18n/i18n.service';
@Injectable()
export class ApiService {
private readonly logger = new Logger(ApiService.name);
constructor(private i18nService: I18nService) {}
// Simple health check method
@@ -23,7 +25,7 @@ export class ApiService {
const response = await llm.invoke(prompt);
return response.content.toString();
} catch (error) {
console.error('LangChain call failed:', error);
this.logger.error('LangChain call failed:', error);
if (error.message?.includes('401')) {
throw new Error(this.i18nService.getMessage('invalidApiKey'));
}
+3 -51
View File
@@ -31,34 +31,11 @@ import { ImportTaskModule } from './import-task/import-task.module';
import { AssessmentModule } from './assessment/assessment.module';
import { I18nMiddleware } from './i18n/i18n.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 { SuperAdminModule } from './super-admin/super-admin.module';
import { AdminModule } from './admin/admin.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({
imports: [
@@ -77,33 +54,8 @@ import { AssessmentCertificate } from './assessment/entities/assessment-certific
useFactory: (configService: ConfigService) => ({
type: 'better-sqlite3',
database: configService.get<string>('DATABASE_PATH'),
entities: [
User,
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.
autoLoadEntities: true,
synchronize: true,
}),
}),
AuthModule,
@@ -5,6 +5,8 @@ import { AssessmentService } from './assessment.service';
import { TenantService } from '../tenant/tenant.service';
import { UserService } from '../user/user.service';
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
import { ExportService } from './services/export.service';
import { AuditLogService } from './services/audit-log.service';
describe('AssessmentController', () => {
let controller: AssessmentController;
@@ -23,8 +25,10 @@ describe('AssessmentController', () => {
controllers: [AssessmentController],
providers: [
{ provide: AssessmentService, useFactory: mockService },
{ provide: 'UserService', useFactory: mockService },
{ provide: UserService, useFactory: mockService },
{ provide: TenantService, useFactory: mockService },
{ provide: ExportService, useFactory: mockService },
{ provide: AuditLogService, useFactory: () => ({ log: jest.fn() }) },
{ provide: Reflector, useFactory: mockReflector },
{ provide: CombinedAuthGuard, useFactory: mockGuard },
],
+62 -27
View File
@@ -13,10 +13,12 @@ import {
Delete,
Put,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { map } from 'rxjs/operators';
import { AssessmentService } from './assessment.service';
import { ExportService } from './services/export.service';
import { AuditLogService } from './services/audit-log.service';
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
import { Public } from '../auth/public.decorator';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
@@ -25,9 +27,12 @@ import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
@Controller('assessment')
@UseGuards(CombinedAuthGuard)
export class AssessmentController {
private readonly logger = new Logger(AssessmentController.name);
constructor(
private readonly assessmentService: AssessmentService,
private readonly exportService: ExportService,
private readonly auditLog: AuditLogService,
) {}
@Post('start')
@@ -38,16 +43,18 @@ export class AssessmentController {
body: { knowledgeBaseId?: string; language?: string; templateId?: string },
) {
const { id: userId, tenantId } = req.user;
console.log(
`[AssessmentController] startSession: user=${userId}, tenant=${tenantId}, templateId=${body.templateId}, kbId=${body.knowledgeBaseId}`,
this.logger.log(
`startSession: user=${userId}, tenant=${tenantId}, templateId=${body.templateId}, kbId=${body.knowledgeBaseId}`,
);
return this.assessmentService.startSession(
const session = await this.assessmentService.startSession(
userId,
body.knowledgeBaseId,
tenantId,
body.language,
body.templateId,
);
this.auditLog.log({ userId, tenantId, action: 'session.start', resourceType: 'assessment_session', resourceId: session.id });
return session;
}
@Post(':id/answer')
@@ -57,24 +64,26 @@ export class AssessmentController {
@Param('id') sessionId: string,
@Body() body: { answer: string; language?: string },
) {
const { id: userId } = req.user;
console.log(
`[AssessmentController] >>> submitAnswer CALLED: user=${userId}, session=${sessionId}, answerLen=${body.answer?.length}`,
const { id: userId, tenantId } = req.user;
this.logger.log(
`submitAnswer: user=${userId}, session=${sessionId}, answerLen=${body.answer?.length}`,
);
return this.assessmentService.submitAnswer(
const result = await this.assessmentService.submitAnswer(
sessionId,
userId,
body.answer,
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')
@ApiOperation({ summary: 'Stream initial session generation' })
startSessionStream(@Request() req: any, @Param('id') sessionId: string) {
const { id: userId } = req.user;
console.log(
`[AssessmentController] startSessionStream: user=${userId}, session=${sessionId}`,
this.logger.log(
`startSessionStream: user=${userId}, session=${sessionId}`,
);
return this.assessmentService
.startSessionStream(sessionId, userId)
@@ -92,8 +101,8 @@ export class AssessmentController {
@Query('language') language?: string,
) {
const { id: userId } = req.user;
console.log(
`[AssessmentController] >>> submitAnswerStream CALLED: user=${userId}, session=${sessionId}, answerLen=${answer?.length}, lang=${language}`,
this.logger.log(
`submitAnswerStream: user=${userId}, session=${sessionId}, answerLen=${answer?.length}, lang=${language}`,
);
return this.assessmentService
.submitAnswerStream(sessionId, userId, answer, language)
@@ -104,8 +113,8 @@ export class AssessmentController {
@ApiOperation({ summary: 'Get the current state of an assessment session' })
async getSessionState(@Request() req: any, @Param('id') sessionId: string) {
const { id: userId } = req.user;
console.log(
`[AssessmentController] getSessionState: user=${userId}, session=${sessionId}`,
this.logger.log(
`getSessionState: user=${userId}, session=${sessionId}`,
);
return this.assessmentService.getSessionState(sessionId, userId);
}
@@ -114,10 +123,12 @@ export class AssessmentController {
@ApiOperation({ summary: 'Delete an assessment session' })
async deleteSession(@Request() req: any, @Param('id') sessionId: string) {
const user = req.user;
console.log(
`[AssessmentController] deleteSession: user=${user.id}, role=${user.role}, session=${sessionId}`,
this.logger.log(
`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')
@@ -127,8 +138,8 @@ export class AssessmentController {
@Param('id') sessionId: string,
) {
const { id: userId, tenantId } = req.user;
console.log(
`[AssessmentController] getCertificate: user=${userId}, session=${sessionId}`,
this.logger.log(
`getCertificate: user=${userId}, session=${sessionId}`,
);
return this.assessmentService.generateCertificate(sessionId, userId, tenantId);
}
@@ -170,8 +181,8 @@ export class AssessmentController {
@Query('knowledgeGroupId') knowledgeGroupId?: string,
) {
const { id: userId, tenantId, role } = req.user;
console.log(
`[AssessmentController] getStats: user=${userId}, role=${role}, tenant=${tenantId}`,
this.logger.log(
`getStats: user=${userId}, role=${role}, tenant=${tenantId}`,
);
return this.assessmentService.getStats(
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')
@ApiOperation({ summary: 'Review assessment - adjust final score' })
async review(
@@ -224,13 +255,15 @@ export class AssessmentController {
@Req() req: any,
) {
const { id: userId, tenantId } = req.user;
return this.assessmentService.reviewAssessment(
const result = await this.assessmentService.reviewAssessment(
sessionId,
body.newScore,
body.comment,
userId,
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')
@@ -252,12 +285,14 @@ export class AssessmentController {
@Param('id') sessionId: string,
@Request() req: any,
) {
const { role } = req.user;
const isAdmin = role === 'super_admin' || role === 'admin';
const { id: userId, tenantId, role } = req.user;
const isAdmin = role?.toLowerCase() === 'super_admin' || role?.toLowerCase() === 'admin';
if (!isAdmin) {
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')
@@ -271,12 +306,12 @@ export class AssessmentController {
}
@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) {
const buffer = await this.exportService.exportToPdf(sessionId);
return {
filename: `assessment-${sessionId}.txt`,
content: buffer.toString('utf-8'),
filename: `assessment-${sessionId}.html`,
buffer: buffer.toString('base64'),
};
}
}
@@ -23,6 +23,8 @@ import { ContentFilterService } from './services/content-filter.service';
import { QuestionOutlineService } from './services/question-outline.service';
import { QuestionBankService } from './services/question-bank.service';
import { ExportService } from './services/export.service';
import { AuditLog } from './entities/audit-log.entity';
import { AuditLogService } from './services/audit-log.service';
@Module({
imports: [
@@ -34,6 +36,7 @@ import { ExportService } from './services/export.service';
AssessmentCertificate,
QuestionBank,
QuestionBankItem,
AuditLog,
]),
forwardRef(() => KnowledgeBaseModule),
forwardRef(() => KnowledgeGroupModule),
@@ -51,6 +54,7 @@ import { ExportService } from './services/export.service';
QuestionOutlineService,
QuestionBankService,
ExportService,
AuditLogService,
],
exports: [AssessmentService, TemplateService, QuestionOutlineService, QuestionBankService, ExportService],
})
@@ -1,10 +1,13 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
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 { AssessmentAnswer } from './entities/assessment-answer.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 { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
import { ModelConfigService } from '../model-config/model-config.service';
@@ -22,16 +25,35 @@ import { NotFoundException } from '@nestjs/common';
describe('AssessmentService', () => {
let service: AssessmentService;
let sessionRepository: any;
let certificateRepository: any;
let dataSource: any;
const mockRepository = () => ({
delete: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
create: jest.fn(),
});
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 () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -40,6 +62,8 @@ describe('AssessmentService', () => {
{ provide: getRepositoryToken(AssessmentQuestion), useFactory: mockRepository },
{ provide: getRepositoryToken(AssessmentAnswer), useFactory: mockRepository },
{ provide: getRepositoryToken(AssessmentCertificate), useFactory: mockRepository },
{ provide: getRepositoryToken(QuestionBank), useFactory: mockRepository },
{ provide: getRepositoryToken(QuestionBankItem), useFactory: mockRepository },
{ provide: KnowledgeBaseService, useFactory: mockService },
{ provide: KnowledgeGroupService, useFactory: mockService },
{ provide: ModelConfigService, useFactory: mockService },
@@ -52,11 +76,14 @@ describe('AssessmentService', () => {
{ provide: ChatService, useFactory: mockService },
{ provide: I18nService, useFactory: mockService },
{ provide: TenantService, useFactory: mockService },
{ provide: DataSource, useFactory: () => mockDataSource(mockManager()) },
],
}).compile();
service = module.get<AssessmentService>(AssessmentService);
sessionRepository = module.get(getRepositoryToken(AssessmentSession));
certificateRepository = module.get(getRepositoryToken(AssessmentCertificate));
dataSource = module.get(DataSource);
});
it('should be defined', () => {
@@ -64,15 +91,110 @@ describe('AssessmentService', () => {
});
describe('deleteSession', () => {
it('should delete a session if it exists and belongs to the user', async () => {
sessionRepository.delete.mockResolvedValue({ affected: 1 });
await expect(service.deleteSession('session-id', 'user-id')).resolves.not.toThrow();
expect(sessionRepository.delete).toHaveBeenCalledWith({ id: 'session-id', userId: 'user-id' });
it('should delete a session when non-admin user owns it', async () => {
const manager = mockManager({
findOne: jest.fn().mockResolvedValue({ id: 'session-id', userId: 'user-1' }),
});
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 () => {
sessionRepository.delete.mockResolvedValue({ affected: 0 });
await expect(service.deleteSession('non-existent', 'user-id')).rejects.toThrow(NotFoundException);
it('should delete any session when admin user', async () => {
const manager = mockManager({
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 }),
);
});
});
});
+437 -201
View File
@@ -8,7 +8,7 @@ import {
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DeepPartial, In } from 'typeorm';
import { Repository, DeepPartial, In, DataSource } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { ChatOpenAI } from '@langchain/openai';
import {
@@ -27,7 +27,7 @@ import { AssessmentAnswer } from './entities/assessment-answer.entity';
import { AssessmentTemplate } from './entities/assessment-template.entity';
import { AssessmentCertificate } from './entities/assessment-certificate.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 { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
import { ModelConfigService } from '../model-config/model-config.service';
@@ -78,6 +78,7 @@ export class AssessmentService {
private chatService: ChatService,
private i18nService: I18nService,
private tenantService: TenantService,
private dataSource: DataSource,
) {}
private async getModel(tenantId: string): Promise<ChatOpenAI> {
@@ -136,12 +137,19 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
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(
questions: any[],
scores: Record<string, number>,
weightConfig: { prompt: number; other: number },
): { finalScore: number; dimensionScores: Record<string, number>; radarData: Record<string, number> } {
console.log('[calculateScores] Input:', {
this.logger.debug('[calculateScores] Input:', {
questionsCount: questions.length,
scores,
weightConfig,
@@ -156,7 +164,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
};
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;
if (dimensionScoresMap[dimension]) {
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
: 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> = {};
Object.keys(dimensionAverages).forEach(dim => {
radarData[dim] = Math.round(dimensionAverages[dim] * 10) / 10;
});
console.log('[calculateScores] Result:', {
this.logger.debug('[calculateScores] Result:', {
finalScore: Math.round(finalScore * 10) / 10,
dimensionScores: dimensionAverages,
promptAvg,
@@ -414,38 +431,59 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
// Use kbId if provided, otherwise fall back to template's group ID
const activeKbId = kbId || template?.knowledgeGroupId;
this.logger.log(`[startSession] activeKbId resolved to: ${activeKbId}`);
if (!activeKbId) {
// If no knowledge source, check if template has a question bank first
let hasBankQuestions = false;
if (!activeKbId && templateId && template) {
try {
const targetCount = template.questionCount || 5;
const linkedBanks = await this.questionBankRepository.find({
where: { templateId },
});
if (linkedBanks.length > 0) {
const bankIds = linkedBanks.map(b => b.id);
const count = await this.questionBankItemRepository.count({
where: { bankId: In(bankIds), status: QuestionBankItemStatus.PUBLISHED },
});
if (count >= targetCount) {
hasBankQuestions = true;
this.logger.log(`[startSession] Template has ${count} published questions, skipping KB check`);
}
}
} catch (e) {
this.logger.warn(`[startSession] Bank pre-check failed: ${e.message}`);
}
}
if (!activeKbId && !hasBankQuestions) {
this.logger.error(`[startSession] No knowledge source resolved`);
throw new BadRequestException('Knowledge source (ID or Template) must be provided.');
}
// Try to determine if it's a KB or Group and check permissions
// Determine if it's a KB or Group (only when activeKbId exists)
let isKb = false;
try {
await this.kbService.findOne(activeKbId, userId, tenantId);
isKb = true;
} catch (kbError) {
if (kbError instanceof NotFoundException) {
// Try finding it as a Group
try {
await this.groupService.findOne(activeKbId, userId, tenantId);
} catch (groupError) {
this.logger.error(
`[startSession] Knowledge source ${activeKbId} not found as KB or Group`,
);
throw new NotFoundException(
this.i18nService.getMessage('knowledgeSourceNotFound') ||
'Knowledge source not found',
);
if (activeKbId) {
try {
await this.kbService.findOne(activeKbId, userId, tenantId);
isKb = true;
} catch (kbError) {
if (kbError instanceof NotFoundException) {
try {
await this.groupService.findOne(activeKbId, userId, tenantId);
} catch (groupError) {
this.logger.error(`[startSession] Knowledge source ${activeKbId} not found`);
throw new NotFoundException(
this.i18nService.getMessage('knowledgeSourceNotFound') || 'Knowledge source not found',
);
}
} else {
throw kbError;
}
} else {
throw kbError; // e.g. ForbiddenException
}
}
this.logger.debug(`[startSession] isKb: ${isKb}`);
const templateData = template
const templateData: any = template
? {
name: template.name,
keywords: template.keywords,
@@ -457,6 +495,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
weightConfig: template.weightConfig,
passingScore: template.passingScore,
style: template.style,
dimensions: template.dimensions,
linkedGroupIds: template.linkedGroupIds,
}
: undefined;
@@ -467,36 +506,71 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
if (templateId) {
try {
const targetCount = template?.questionCount || 5;
const publishedBanks = await this.questionBankRepository.find({
where: { templateId, status: QuestionBankStatus.PUBLISHED },
const linkedBanks = await this.questionBankRepository.find({
where: { templateId },
});
if (publishedBanks.length > 0) {
const bankIds = publishedBanks.map(b => b.id);
if (linkedBanks.length > 0) {
const bankIds = linkedBanks.map(b => b.id);
const questionCount = await this.questionBankItemRepository.count({
where: { bankId: In(bankIds) },
where: { bankId: In(bankIds), status: QuestionBankItemStatus.PUBLISHED },
});
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) {
const bankId = publishedBanks[0].id;
const bankId = linkedBanks[0].id;
const selectedItems = await this.questionBankService.selectQuestions(
bankId,
targetCount,
template?.dimensions,
);
questionsFromBank = selectedItems.map(item => ({
id: item.id,
questionText: item.questionText,
questionType: item.questionType,
keyPoints: item.keyPoints,
difficulty: item.difficulty,
dimension: item.dimension,
basis: item.basis,
}));
questionsFromBank = selectedItems.map(item => {
let options = item.options;
let correctAnswer = item.correctAnswer;
if (item.questionType === 'MULTIPLE_CHOICE' && options && options.length > 0 && correctAnswer) {
const labels = ['A', 'B', 'C', 'D'];
const optTexts = options.map((o: string) => o.replace(/^[A-D][.)、]\s*/, ''));
const correctIdx = correctAnswer.charCodeAt(0) - 65;
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';
this.logger.log(
@@ -534,15 +608,20 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
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) {
this.logger.error(
`[startSession] Insufficient content length: ${content?.length || 0}`,
);
throw new BadRequestException(
'Selected knowledge source has no sufficient content for evaluation.',
);
if (!hasBankContent) {
const content = await this.getSessionContent(sessionData);
if (!content || content.trim().length < 10) {
this.logger.error(
`[startSession] Insufficient content length: ${content?.length || 0}`,
);
throw new BadRequestException(
'Selected knowledge source has no sufficient content for evaluation.',
);
}
}
const session = this.sessionRepository.create(
@@ -560,7 +639,9 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
`[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;
}
@@ -581,12 +662,14 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
}
const model = await this.getModel(session.tenantId);
const content = await this.getSessionContent(session);
// Check if questions already exist in session (from question bank)
const existingQuestions = session.questions_json || [];
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
const existingState = await this.graph.getState({
configurable: { thread_id: sessionId },
@@ -599,7 +682,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
this.logger.log(
`Session ${sessionId} already has state, skipping generation.`,
);
const mappedData = { ...existingState.values };
const mappedData = this.sanitizeStateForClient({ ...existingState.values });
mappedData.messages = this.mapMessages(mappedData.messages || []);
mappedData.feedbackHistory = this.mapMessages(
mappedData.feedbackHistory || [],
@@ -621,6 +704,7 @@ const initialState: Partial<EvaluationState> = {
style: session.templateJson?.style,
keywords: session.templateJson?.keywords,
questionAnswerKey: session.templateJson?.questionAnswerKey,
currentQuestionIndex: 0,
};
@@ -708,7 +792,7 @@ const initialState: Partial<EvaluationState> = {
const finalData = fullState.values as EvaluationState;
if (finalData && finalData.messages) {
console.log(
this.logger.debug(
`[AssessmentService] startSessionStream Final Authoritative State messages:`,
finalData.messages.length,
);
@@ -726,7 +810,7 @@ const initialState: Partial<EvaluationState> = {
const scores = finalData.scores;
const questions = finalData.questions || [];
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
const passingScore = session.templateJson?.passingScore || 90;
const passingScore = (session.templateJson?.passingScore ?? 60) / 10;
if (questions.length > 0 && Object.keys(scores).length > 0) {
const { finalScore, dimensionScores, radarData } = this.calculateScores(
@@ -742,7 +826,10 @@ const initialState: Partial<EvaluationState> = {
}
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.feedbackHistory = this.mapMessages(
finalData.feedbackHistory || [],
@@ -750,6 +837,7 @@ const initialState: Partial<EvaluationState> = {
mappedData.status = session.status;
mappedData.report = session.finalReport;
mappedData.finalScore = session.finalScore;
mappedData.passed = (session as any).passed;
observer.next({ type: 'final', data: mappedData });
}
@@ -776,6 +864,33 @@ const initialState: Partial<EvaluationState> = {
});
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);
await this.ensureGraphState(sessionId, session);
const content = await this.getSessionContent(session);
@@ -790,7 +905,7 @@ const initialState: Partial<EvaluationState> = {
let finalResult: any = null;
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
const passingScore = session.templateJson?.passingScore || 90;
const passingScore = (session.templateJson?.passingScore ?? 60) / 10;
// Resume from the last interrupt (typically after interviewer)
const stream = await this.graph.stream(null, {
@@ -843,18 +958,18 @@ const initialState: Partial<EvaluationState> = {
const scores = finalResult.scores as Record<string, number>;
const questions = finalResult.questions || [];
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
const passingScore = session.templateJson?.passingScore || 90;
const passingScore = (session.templateJson?.passingScore ?? 60) / 10;
if (questions.length > 0 && Object.keys(scores).length > 0) {
const { finalScore, dimensionScores, radarData } = this.calculateScores(
questions,
scores,
weightConfig,
);
session.finalScore = finalScore;
(session as any).dimensionScores = dimensionScores;
(session as any).radarData = radarData;
(session as any).passed = finalScore >= passingScore;
if (questions.length > 0 && Object.keys(scores).length > 0) {
const { finalScore, dimensionScores, radarData } = this.calculateScores(
questions,
scores,
weightConfig,
);
session.finalScore = finalScore;
(session as any).dimensionScores = dimensionScores;
(session as any).radarData = radarData;
(session as any).passed = finalScore >= passingScore;
}
}
@@ -902,13 +1017,13 @@ const initialState: Partial<EvaluationState> = {
answer: string,
language: string = 'en',
): 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 hasEmittedNodes = false;
return new Observable((observer) => {
(async () => {
try {
console.log('[submitAnswerStream] After Observable - sessionId:', sessionId);
this.logger.debug('[submitAnswerStream] After Observable - sessionId:', sessionId);
const session = await this.sessionRepository.findOne({
where: { id: sessionId, userId },
});
@@ -917,6 +1032,36 @@ const initialState: Partial<EvaluationState> = {
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 content = await this.getSessionContent(session);
await this.ensureGraphState(sessionId, session);
@@ -927,7 +1072,7 @@ const initialState: Partial<EvaluationState> = {
graphState &&
graphState.values &&
Object.keys(graphState.values).length > 0;
console.log(
this.logger.debug(
`[AssessmentService] submitAnswerStream: sessionId=${sessionId}, hasState=${hasState}, nextNodes=[${graphState.next || ''}]`,
);
@@ -953,8 +1098,8 @@ const initialState: Partial<EvaluationState> = {
let hasEmittedNodes = false;
for await (const [mode, data] of stream) {
streamCount++;
console.log('[submitAnswerStream] Stream event:', streamCount, mode, Object.keys(data || {}));
console.log('[submitAnswerStream] Data detail:', JSON.stringify(data).substring(0, 500));
this.logger.debug('[submitAnswerStream] Stream event:', streamCount, mode, Object.keys(data || {}));
this.logger.debug('[submitAnswerStream] Data detail:', JSON.stringify(data).substring(0, 500));
if (mode === 'updates') {
hasEmittedNodes = true;
const node = Object.keys(data)[0];
@@ -962,17 +1107,17 @@ const initialState: Partial<EvaluationState> = {
// Skip interrupt nodes - they have no useful data
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;
}
console.log('[submitAnswerStream] Node update:', node, {
this.logger.debug('[submitAnswerStream] Node update:', node, {
hasMessages: !!updateData.messages,
messageCount: updateData.messages?.length,
currentIndex: updateData.currentQuestionIndex,
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) {
updateData.messages = this.mapMessages(updateData.messages);
}
@@ -983,7 +1128,7 @@ const initialState: Partial<EvaluationState> = {
}
observer.next({ type: 'node', node, data: updateData });
} 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;
// 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) {
const currentIndex = finalData.currentQuestionIndex || 0;
const nextQuestion = finalData.questions[currentIndex];
if (nextQuestion) {
const questionText = nextQuestion.questionText || '';
console.log('[submitAnswerStream] Forcing emit next question:', {
this.logger.debug('[submitAnswerStream] Forcing emit next question:', {
currentIndex,
questionPreview: questionText.substring(0, 50)
});
@@ -1020,7 +1165,7 @@ const initialState: Partial<EvaluationState> = {
}
if (finalData && finalData.messages) {
console.log(
this.logger.debug(
`[AssessmentService] submitAnswerStream Final Authoritative State messages:`,
finalData.messages.length,
);
@@ -1036,7 +1181,7 @@ const initialState: Partial<EvaluationState> = {
const scores = finalData.scores;
const questions = finalData.questions || [];
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
const passingScore = session.templateJson?.passingScore || 90;
const passingScore = (session.templateJson?.passingScore ?? 60) / 10;
if (questions.length > 0 && Object.keys(scores).length > 0) {
const { finalScore, dimensionScores, radarData } = this.calculateScores(
@@ -1048,6 +1193,7 @@ const initialState: Partial<EvaluationState> = {
(session as any).dimensionScores = dimensionScores;
(session as any).radarData = radarData;
(session as any).passed = finalScore >= passingScore;
this.logger.log(
`[DimensionScoring] Session ${sessionId} Final Score: ${finalScore}, Passed: ${finalScore >= passingScore}`,
);
@@ -1055,13 +1201,18 @@ const initialState: Partial<EvaluationState> = {
}
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.feedbackHistory = this.mapMessages(
finalData.feedbackHistory || [],
);
mappedData.status = session.status;
mappedData.report = session.finalReport;
mappedData.finalScore = session.finalScore;
mappedData.passed = (session as any).passed;
observer.next({ type: 'final', data: mappedData });
}
@@ -1101,7 +1252,10 @@ const initialState: Partial<EvaluationState> = {
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 isAdmin = user.role === 'super_admin' || user.role === 'admin';
const deleteCondition: any = { id: sessionId };
if (!isAdmin) {
deleteCondition.userId = userId;
}
await this.dataSource.transaction(async (manager) => {
const deleteCondition: any = { id: sessionId };
if (!isAdmin) {
deleteCondition.userId = userId;
}
const result = await this.sessionRepository.delete(deleteCondition);
if (result.affected === 0) {
throw new NotFoundException(
'Session not found or you do not have permission to delete it',
);
const session = await manager.findOne(AssessmentSession, { where: deleteCondition });
if (!session) {
throw new NotFoundException('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 existingQuestions = session.questions_json || [];
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) {
this.logger.log(
`[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 {
this.logger.log(`Initializing new state for session ${sessionId}`);
const content = await this.getSessionContent(session);
const model = await this.getModel(session.tenantId);
const existingQuestions = session.questions_json || [];
const hasQuestionsFromBank = existingQuestions.length > 0;
const isZh = (session.language || 'en') === 'zh';
const isJa = session.language === 'ja';
const initialState: Partial<EvaluationState> = {
assessmentSessionId: sessionId,
knowledgeBaseId:
session.knowledgeBaseId || session.knowledgeGroupId || '',
messages: [],
messages: hasQuestionsFromBank
? [new HumanMessage(
isZh ? '我已准备好回答问题。' : isJa ? '質問への回答準備ができています。' : 'I am ready to answer the questions.',
)]
: [],
questionCount: session.templateJson?.questionCount,
difficultyDistribution: session.templateJson?.difficultyDistribution,
style: session.templateJson?.style,
keywords: session.templateJson?.keywords,
questionAnswerKey: session.templateJson?.questionAnswerKey,
language: session.language || 'en',
questions: hasQuestionsFromBank ? existingQuestions : undefined,
};
this.logger.log(
@@ -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.
*/
@@ -1340,7 +1527,7 @@ const initialState: Partial<EvaluationState> = {
}
if (session.status !== AssessmentStatus.COMPLETED) {
throw new Error('Session not completed');
throw new BadRequestException('Session not completed yet');
}
const existing = await this.certificateRepository.findOne({
@@ -1350,9 +1537,17 @@ const initialState: Partial<EvaluationState> = {
return existing;
}
const level = this.determineLevel(session.finalScore || 0);
const passingThreshold = (session.templateJson?.passingScore ?? 60) / 10;
const level = this.determineLevel(session.finalScore || 0, !!(session as any).passed, passingThreshold);
const qrCode = `cert://${sessionId}-${Date.now()}`;
const questionDetails = (session.questions_json || []).map((q: any, i: number) => ({
index: i + 1,
questionText: q.questionText?.substring(0, 100) || '',
questionType: q.questionType || 'SHORT_ANSWER',
dimension: q.dimension || '',
}));
const certificate = this.certificateRepository.create({
userId,
sessionId,
@@ -1365,14 +1560,20 @@ const initialState: Partial<EvaluationState> = {
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 {
if (score >= 90) return 'Expert';
if (score >= 75) return 'Advanced';
if (score >= 60) return 'Proficient';
return 'Novice';
private determineLevel(score: number, passed: boolean, passingThreshold: number): string {
if (!passed) return 'Novice';
if (score >= 9) return 'Expert';
if (score >= 7) return 'Advanced';
return 'Proficient';
}
async getStats(
@@ -1464,19 +1665,15 @@ const initialState: Partial<EvaluationState> = {
const sessions = await qb.take(100).getMany();
const dimensionScores: Record<string, number[]> = {
PROMPT: [],
LLM: [],
IDE: [],
DEV_PATTERN: [],
WORK_CAPABILITY: [],
};
const dimensionScores: Record<string, number[]> = {};
for (const session of sessions) {
const messages = session.messages || [];
for (const msg of messages) {
if (msg.dimension && msg.score !== undefined) {
dimensionScores[msg.dimension]?.push(msg.score);
const scores = (session as any).dimensionScores || {};
for (const [dim, score] of Object.entries(scores)) {
if (dimensionScores[dim]) {
dimensionScores[dim].push(score as number);
} else {
dimensionScores[dim] = [score as number];
}
}
}
@@ -1531,48 +1728,50 @@ const initialState: Partial<EvaluationState> = {
reviewerId: string,
tenantId: string,
): Promise<AssessmentSession> {
const session = await this.sessionRepository.findOne({
where: { id: sessionId },
return this.dataSource.transaction(async (manager) => {
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[]> {
@@ -1655,7 +1854,6 @@ const initialState: Partial<EvaluationState> = {
totalScore: number;
passed: boolean;
issuedAt: Date;
userId: string;
};
message?: string;
}> {
@@ -1676,7 +1874,6 @@ const initialState: Partial<EvaluationState> = {
totalScore: certificate.totalScore,
passed: certificate.passed,
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> {
const session = await this.sessionRepository.findOne({
where: { id: sessionId },
@@ -22,6 +22,7 @@ import {
ReviewDto,
} from '../services/question-bank.service';
import { CombinedAuthGuard } from '../../auth/combined-auth.guard';
import { KnowledgeGroupService } from '../../knowledge-group/knowledge-group.service';
@Controller('question-banks')
@UseGuards(CombinedAuthGuard)
@@ -29,12 +30,20 @@ import { CombinedAuthGuard } from '../../auth/combined-auth.guard';
export class QuestionBankController {
private readonly logger = new Logger(QuestionBankController.name);
constructor(private readonly questionBankService: QuestionBankService) {}
constructor(
private readonly questionBankService: QuestionBankService,
private readonly groupService: KnowledgeGroupService,
) {}
@Post()
create(@Body() createDto: CreateQuestionBankDto, @Req() req: any) {
this.logger.log(`Creating question bank: ${createDto.name}`);
return this.questionBankService.create(createDto, req.user.id, req.user.tenantId);
async create(@Body() createDto: CreateQuestionBankDto, @Req() req: any) {
try {
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()
@@ -125,11 +134,32 @@ export class QuestionBankController {
@Body() body: { count: number; knowledgeBaseContent?: string },
@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(
bankId,
body.count,
body.knowledgeBaseContent || '',
content,
req.user.tenantId,
);
}
@@ -8,6 +8,7 @@ import {
Max,
IsObject,
IsBoolean,
IsNumber,
} from 'class-validator';
export class CreateTemplateDto {
@@ -59,6 +60,11 @@ export class CreateTemplateDto {
@IsOptional()
linkedGroupIds?: string[];
@IsArray()
@IsObject({ each: true })
@IsOptional()
dimensions?: Array<{ name: string; label: string; weight: number }>;
@IsObject()
@IsOptional()
weightConfig?: {
@@ -91,4 +97,14 @@ export class CreateTemplateDto {
@Max(100)
@IsOptional()
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 })
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 })
finalReport: string;
@@ -63,6 +63,9 @@ export class AssessmentTemplate {
@JoinColumn({ name: 'knowledge_group_id' })
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 })
isActive: boolean;
@@ -94,7 +97,7 @@ export class AssessmentTemplate {
@Column({ type: 'int', name: 'question_count_max', default: 10 })
questionCountMax: number;
@Column({ type: 'int', name: 'passing_score', default: 90 })
@Column({ type: 'int', name: 'passing_score', default: 60 })
passingScore: number;
@Column({ type: 'int', name: 'total_time_limit', default: 1800 })
@@ -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({
type: 'simple-enum',
enum: QuestionType,
default: QuestionType.SHORT_ANSWER,
})
questionType: QuestionType;
@@ -71,24 +72,33 @@ export class QuestionBankItem {
@Column({
type: 'simple-enum',
enum: QuestionDifficulty,
default: QuestionDifficulty.STANDARD,
})
difficulty: QuestionDifficulty;
@Column({
type: 'simple-enum',
enum: QuestionDimension,
default: QuestionDimension.PROMPT,
})
dimension: QuestionDimension;
@Column({ type: 'text', nullable: true })
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' })
createdBy: string | null;
@Column({
type: 'simple-enum',
enum: QuestionBankItemStatus,
default: QuestionBankItemStatus.PENDING_REVIEW,
})
status: QuestionBankItemStatus;
@@ -37,7 +37,7 @@ export class QuestionBank {
@Column({ name: 'template_id', nullable: true })
templateId: string | null;
@OneToOne(() => AssessmentTemplate, { nullable: true })
@OneToOne(() => AssessmentTemplate, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'template_id' })
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');
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ import { reportAnalyzerNode } from './nodes/analyzer.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 questionsLen = state.questions?.length || 0;
const currentIndex = Math.max(0, state.currentQuestionIndex || 0);
@@ -56,7 +56,12 @@ const scoreSummary = Object.entries(scores)
1. **你必须使用以下语言生成报告:中文 (Simplified Chinese)**。
2. **严禁夹杂日文**。即使对话记录中包含日文,报告内容也必须全中文。
3. 报告的第一行必须严格遵守此格式:"LEVEL: [Novice/Proficient/Advanced/Expert]"。
4. 必须保持客观。如果用户没有提供有效的回答或得分为 0,你必须将其识别为 'Novice',并明确指出他们尚未证明其掌握程度。
4. **等级判定必须遵循以下分数阈值**
- 总体平均分 >= 9 → Expert(专家)
- 总体平均分 >= 7 → Advanced(高级)
- 已通过(有有效回答且得分 > 0)→ Proficient(熟练)
- 未通过(无有效回答或得分为 0)→ Novice(新手)
即使得分很高,也要确保等级与上述阈值匹配。不要随意提高或降低等级。
5. 不要虚构或幻想优点(如"潜力"或"好奇心"),如果用户明确表示"不知道"或未提供实质内容。
6. 专注于对话记录中已证明的事实。
@@ -87,8 +92,13 @@ ${messages
2. **中国語を混ぜないでください**。会話ログに中国語が含まれていても、レポートの内容はすべて日本語で記述してください。
3. レポートの最初の行は, 必ず次の形式に従ってください:"LEVEL: [Novice/Proficient/Advanced/Expert]"。
4. 客観的であること。ユーザーが有効な回答を提供しなかった場合、またはスコアが 0 の場合、'Novice' と判定し、習熟度が証明されていないことを明示してください。
5. ユーザーが「わからない」と言ったり、内容を提供しなかった場合に、長所(「ポテンシャル」や「好奇心」など)を捏造しないでください。
6. 会話ログで証明された事実に集中してください。
5. **レベル判定は以下のスコアしきい値に従うこと**:
- 平均スコア >= 9 → Expert
- 平均スコア >= 7 → Advanced
- 合格(有効な回答がありスコア > 0)→ Proficient
- 不合格(有効な回答なし、またはスコア 0)→ Novice
6. ユーザーが「わからない」と言ったり、内容を提供しなかった場合に、長所(「ポテンシャル」や「好奇心」など)を捏造しないでください。
7. 会話ログで証明された事実に集中してください。
各ディメンションスコア:
${dimensionAvg}
@@ -115,8 +125,13 @@ IMPORTANT:
1. **You MUST generate the report strictly in English.**
2. START the report with exactly this format: "LEVEL: [Novice/Proficient/Advanced/Expert]" on the first line.
3. Be OBJECTIVE. If the user provided no valid answers or scores are 0, you MUST identify them as 'Novice' and explicitly state they have NOT demonstrated mastery.
4. DO NOT invent or hallucinate strengths (like 'potential' or 'curiosity') if the user explicitly said "I don't know" or provided no content.
5. Focus on what was PROVEN in the conversation logs.
4. **Level assignment MUST follow these score thresholds**:
- Average score >= 9 → Expert
- Average score >= 7 → Advanced
- Passed (has valid answers with score > 0) → Proficient
- Not passed (no valid answers or score is 0) → Novice
5. DO NOT invent or hallucinate strengths (like 'potential' or 'curiosity') if the user explicitly said "I don't know" or provided no content.
6. Focus on what was PROVEN in the conversation logs.
DIMENSION SCORES:
${dimensionAvg}
@@ -22,6 +22,14 @@ export const questionGeneratorNode = async (
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) {
console.error('[GeneratorNode] Missing model or knowledgeBaseContent');
throw new Error(
@@ -78,91 +86,165 @@ export const questionGeneratorNode = async (
.map((r, i) => `${i + 1}. ${r}`)
.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
.map((q, i) => `Q${i + 1}: ${q.questionText}`)
.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 个问题:
[
{
"question_text": "...",
"key_points": ["点1", "点2"],
"difficulty": "...",
"dimension": "prompt/llm/ide/devPattern/workCapability",
"basis": "[n] 引用原文..."
}
]`;
// dimension取值:prompt=提示词, llm=LLM原理, ide=IDE协作, devPattern=开发范式, workCapability=工作能力
### 选择题出题标准
- 必须是场景驱动:描述一个真实工作场景,让用户判断最佳做法
- 四个选项(A/B/C/D),只有一个正确,另外三个要有迷惑性
- 难度:不是考概念背诵,是考实际应用判断
- 正确答案必须附带解析,说明为什么对、错在哪
- 出题依据必须引用第一步提取的知识点编号
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}
難易度:${difficultyText}
### 输出要求
- 只输出 JSON 数组,不要其他文字
- question_type 必须为 MULTIPLE_CHOICE 或 SHORT_ANSWER
- dimension 只能取以下值之一:prompt、llm、ide、devPattern、workCapability
- 每次生成 1 道题,以 JSON 数组格式输出
- 选择题必须包含全部8个字段:question_text、options、correct_answer、judgment、key_points、difficulty、dimension、basis
- 对话简答题必须包含全部6个字段:question_text、key_points、difficulty、dimension、basis
- 每个字段的值不能为空`;
以下のJSON配列形式で問題を1つ返してください
[
{
"question_text": "...",
"key_points": ["ポイント1", "ポイント2"],
"difficulty": "...",
"dimension": "prompt/llm/ide/devPattern/workCapability",
"basis": "[n] 引用箇所..."
}
]`;
const systemPromptJa = `あなたは問題作成ツールです。以下の手順に厳密に従ってください
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:
[
{
"question_text": "...",
"key_points": ["point1", "point2"],
"difficulty": "...",
"dimension": "prompt/llm/ide/devPattern/workCapability",
"basis": "[n] citation..."
}
]`;
### 選択問題の基準
- シナリオ駆動:実務シーンを想定
- 4択(A/B/C/D)、正解は1つ
- 正解には必ず解説を含める
### 対話式記述問題の基準
- オープンクエスチョン、正解なし
- 理解の深さと表現力を評価
### 絶対禁止:
- 暗記問題の禁止
- 知識ベースにない概念の使用禁止
${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
@@ -172,10 +254,10 @@ Return 1 question as a JSON array with format:
? systemPromptJa
: systemPromptEn;
const humanMsg = isZh
? `请使用中文基于以下内容生成题目:\n\n${knowledgeBaseContent}`
? `【知识库内容 - 以下是你出题的唯一依据】\n\n--- 知识库开始 ---\n${knowledgeBaseContent}\n--- 知识库结束 ---\n\n请严格基于以上内容生成题目。`
: isJa
? `以下の内容に基づいて、必ず日本語でアセスメント問題を作成してください:\n\n${knowledgeBaseContent}`
: `Generate evaluation question in English based on:\n\n${knowledgeBaseContent}`;
? `【ナレッジベース内容 - 以下は出題の唯一の根拠です】\n\n--- ナレッジベース開始 ---\n${knowledgeBaseContent}\n--- ナレッジベース終了 ---\n\n上記の内容のみに基づいて問題を作成してください。`
: `【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 {
const response = await model.invoke([
@@ -196,6 +278,42 @@ Return 1 question as a JSON array with format:
newQuestions = [newQuestions];
}
// === 代码级校验:确保 LLM 输出符合规范 ===
const VALID_DIMENSIONS = ['prompt', 'llm', 'ide', 'devPattern', 'workCapability'];
const VALID_TYPES = ['MULTIPLE_CHOICE', 'SHORT_ANSWER'];
const validatedQuestions = newQuestions.filter((q: any) => {
const qType = q.question_type;
const dim = q.dimension?.toString().toLowerCase().trim();
const errors: string[] = [];
if (!VALID_TYPES.includes(qType)) errors.push(`invalid question_type: ${qType}`);
if (!dim || !VALID_DIMENSIONS.includes(dim)) errors.push(`invalid dimension: ${q.dimension}`);
if (!q.question_text || q.question_text.length < 5) errors.push('question_text missing or too short');
if (qType === 'MULTIPLE_CHOICE') {
if (!Array.isArray(q.options) || q.options.length < 2) errors.push('options missing or insufficient');
if (!q.correct_answer) errors.push('correct_answer missing');
if (!q.judgment) errors.push('judgment missing');
} else if (qType === 'SHORT_ANSWER') {
if (!Array.isArray(q.key_points) || q.key_points.length === 0) errors.push('key_points missing');
}
if (errors.length > 0) {
console.warn('[GeneratorNode] Validation failed for question:', errors.join('; '));
return false;
}
return true;
});
if (validatedQuestions.length === 0) {
console.warn('[GeneratorNode] All generated questions failed validation, using existing questions only');
return { questions: existingQuestions };
}
// 只取验证通过的题目
newQuestions = validatedQuestions;
const dimensionMap: Record<string, string> = {
// 中文
'技术能力-提示词': 'prompt',
@@ -223,14 +341,27 @@ Return 1 question as a JSON array with format:
inferredDimension = dimensionMap[dimValue] || 'workCapability';
console.log('[GeneratorNode] Dimension mapping:', { original: q.dimension, mapped: inferredDimension });
}
return {
const qType = q.question_type === 'MULTIPLE_CHOICE' ? 'MULTIPLE_CHOICE' : 'SHORT_ANSWER';
const base = {
id: (existingQuestions.length + 1).toString(),
questionText: q.question_text,
keyPoints: q.key_points,
difficulty: q.difficulty,
basis: q.basis,
questionType: qType,
keyPoints: q.key_points || [],
difficulty: q.difficulty || 'STANDARD',
basis: q.basis || '',
dimension: inferredDimension,
};
if (qType === 'MULTIPLE_CHOICE') {
return {
...base,
options: q.options || [],
correctAnswer: q.correct_answer || '',
judgment: q.judgment || '',
};
}
return base;
});
const questionsToGenerate = Math.max(1, limitCount - existingQuestions.length);
@@ -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();
});
});
});
+213 -77
View File
@@ -67,106 +67,219 @@ export const graderNode = async (
return { currentQuestionIndex: currentQuestionIndex + 1 };
}
const systemPromptZh = `你是一位专业的考官。
请根据以下问题和关键点对用户的回答进行评分。
const isChoice = currentQuestion.questionType === 'MULTIPLE_CHOICE';
const expectedAnswer = currentQuestion.correctAnswer;
重要提示:
1. **你必须使用以下语言提供反馈:中文 (Simplified Chinese)**。
2. 即使用户的回答或知识库内容涉及其他语言,请确保你的反馈和解释依然严格使用中文。不要夹杂日文。
if (isChoice && expectedAnswer) {
const userAnswer = (lastUserMessage.content as string).trim();
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.keyPoints.join(', ')}
关键点:${currentQuestion.keyPoints.join(', ')}
评估标准:
1. 准确性:他们是否正确覆盖了关键点?
2. 完整性:他们是否遗漏了任何重要内容?
3. 深度:解释是否充分?
评分标准:不要求深度,不要求使用特定术语,只看用户是否理解了概念。
用户理解核心概念就给分。即使没有使用关键点中的原词,只要意思到位就算覆盖。
例如关键点是"上下文窗口有限",用户说"信息太多超过AI处理长度"也是覆盖。
评分原则:往宽了给分,不确定时就给高分。明显正确就给8-10分,部分正确5-7分,完全不沾边才0-2分。
请提供
1. 0 到 10 的评分。
2. 建设性的反馈。
3. 如果回答不完整或不清晰,需要进一步解释,请将 'should_follow_up' 标志设为 true。
返回JSON
- score: 0-10
- feedback: 评语
- should_follow_up: true/false
- follow_up_question: 追问(仅true时需要,针对未覆盖的关键点,false时null)
请以 JSON 格式返回响应:
{
"score": 8,
"feedback": "...",
"should_follow_up": false
}`;
{"score":0到10,"feedback":"评语","should_follow_up":true或false,"follow_up_question":"追问或null"}
const systemPromptJa = `あなたは専門的な試験官です。
以下の質問とキーポイントに基づいて、ユーザーの回答を採点してください。
示例(需要追问):
{"score":6,"feedback":"提到了安全性和性能,未说明依赖关系。","should_follow_up":true,"follow_up_question":"你如何让AI在计划中明确任务依赖关系?"}
重要事項
1. **フィードバックは必ず次の言語で提供してください:日本語**。
2. ユーザーの回答やナレッジベースの内容に他の言語(中国語や英語など)が含まれている場合でも、フィードバックと説明は必ず日本語のみで行ってください。中国語が混ざらないよう厳格に注意してください。
示例(不需追问)
{"score":8,"feedback":"回答完整。","should_follow_up":false,"follow_up_question":null}`;
const systemPromptJa = `あなたは試験官です。採点とフィードバックを提供してください。
ルール:
1. 日本語のみ使用。
2. 複数ラウンドの回答は「第N輪回答:」でマークされ、全ラウンドを総合判断。
質問:${currentQuestion.questionText}
期待されるキーポイント:${currentQuestion.keyPoints.join(', ')}
キーポイント:${currentQuestion.keyPoints.join(', ')}
評価基準:
1. 正確性:キーポイントを正確に網羅していますか?
2. 網羅性:重要な内容が欠落していませんか?
3. 深さ:説明は十分ですか?
評価基準:正確性、網羅性、深さ。
部分点可(5〜7点)、見当違いのみ0〜2点。
以下を提供してください
1. 0 から 10 までのスコア。
2. 建設的なフィードバック。
3. 回答が不完全または不明確で、さらなる説明が必要な場合は、'should_follow_up' フラグを true に設定してください。
JSON形式
- score: 0〜10
- feedback: 評価
- should_follow_up: true/false
- follow_up_question: 追質問(true時のみ、未カバーのポイントに焦点、false時null)
JSON 形式で回答してください:
{
"score": 8,
"feedback": "...",
"should_follow_up": false
}`;
{"score":0から10,"feedback":"評価","should_follow_up":trueかfalse,"follow_up_question":"追質問かnull"}
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.**
2. If the user's answer or knowledge base content references other languages, ensure your feedback and explanation remain strictly in English.
例(不要):
{"score":8,"feedback":"回答は完全。","should_follow_up":false,"follow_up_question":null}`;
QUESTION: ${currentQuestion.questionText}
EXPECTED KEY POINTS: ${currentQuestion.keyPoints.join(', ')}
const systemPromptEn = `You are an examiner. Grade and give feedback.
Evaluate:
1. Accuracy: Did they cover the key points correctly?
2. Completeness: Did they miss anything important?
3. Depth: Is the explanation sufficient?
Rules:
1. English only.
2. Multi-round answers are tagged "第N轮回答:". Consider all rounds.
Provide:
1. A score from 0 to 10.
2. Constructive feedback.
3. A boolean flag 'should_follow_up' if the answer is incomplete or unclear and needs further clarification.
Question: ${currentQuestion.questionText}
Key points: ${currentQuestion.keyPoints.join(', ')}
Format your response as JSON:
{
"score": 8,
"feedback": "...",
"should_follow_up": false
}`;
Criteria: accuracy, completeness, depth.
Give partial credit (5-7 for partial), 0-2 only for off-target.
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
: isJa
? systemPromptJa
: 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 =
typeof lastUserMessage.content === 'string'
? 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] User answer length:', userContentText.length);
console.log('[GraderNode] Question:', currentQuestion?.questionText?.substring(0, 100));
console.log('[GraderNode] Target dimension:', currentQuestion?.dimension);
try {
const response = await model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(userContentText),
new HumanMessage(allAnswers),
]);
console.log('[GraderNode] LLM invoke completed');
@@ -187,10 +300,7 @@ Format your response as JSON:
const scoreLabel = isZh ? '得分' : isJa ? 'スコア' : 'Score';
const feedbackLabel = isZh ? '反馈' : isJa ? 'フィードバック' : 'Feedback';
const feedbackMessage = new AIMessage(
`${scoreLabel}: ${result.score}/10\n\n${feedbackLabel}: ${result.feedback}`,
);
let enhancedFeedback: string = result.feedback;
const newScores = {
...state.scores,
@@ -199,10 +309,6 @@ Format your response as JSON:
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 saysIDontKnow =
normalizedContent.length < 10 &&
@@ -217,10 +323,21 @@ Format your response as JSON:
normalizedContent.includes('不明') ||
normalizedContent.includes('わからない'));
if (currentFollowUpCount >= 2 || result.score >= 8 || saysIDontKnow) {
if (currentFollowUpCount >= maxFollowUps || result.score >= 8 || saysIDontKnow) {
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:', {
shouldFollowUp,
nextIndex: shouldFollowUp
@@ -230,8 +347,12 @@ Format your response as JSON:
saysIDontKnow,
});
const feedbackHistoryMessages = followupHintMsg
? [feedbackMessage, followupHintMsg]
: [feedbackMessage];
return {
feedbackHistory: [feedbackMessage],
feedbackHistory: feedbackHistoryMessages,
scores: newScores,
shouldFollowUp: shouldFollowUp,
followUpCount: shouldFollowUp ? currentFollowUpCount + 1 : 0,
@@ -239,14 +360,29 @@ Format your response as JSON:
? currentQuestionIndex
: currentQuestionIndex + 1,
} as any;
} catch (error) {
console.error('Failed to parse grade from AI response:', error);
} catch (parseError) {
console.error('[GraderNode] Failed to parse grade:', parseError);
const scoreLabel = isZh ? '得分' : isJa ? 'スコア' : 'Score';
const fallbackMsg = new AIMessage(`${scoreLabel}: 5/10\n\n评分解析失败,默认给5分。`);
return {
feedbackHistory: [
new AIMessage("I had some trouble grading that, but let's move on."),
],
currentQuestionIndex: currentQuestionIndex + 1,
feedbackHistory: [fallbackMsg],
scores: { [currentQuestion.id || currentQuestionIndex.toString()]: 5 },
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;
}
};
@@ -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];
// 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) {
return { shouldFollowUp: false };
}
@@ -49,33 +47,24 @@ export const interviewerNode = async (
state.feedbackHistory &&
state.feedbackHistory.length > 0
) {
// Construct a follow-up prompt based on last feedback
const lastFeedbackMsg =
state.feedbackHistory[state.feedbackHistory.length - 1];
const feedbackText = lastFeedbackMsg.content.toString();
// Extract the "Feedback: ..." part if possible, otherwise use whole text
const feedbackMatch = feedbackText.match(
/(?:Feedback|反馈|フィードバック): ([\s\S]*)/i,
);
const specificFeedback = feedbackMatch
? feedbackMatch[1].trim()
: feedbackText;
const followUpLabel = isZh
? '补充追问'
prompt = lastFeedbackMsg.content.toString();
} else if (currentQuestion.questionType === 'MULTIPLE_CHOICE' && currentQuestion.options?.length > 0) {
const label = isZh
? `问题 ${currentQuestionIndex + 1}`
: isJa
? '追加の質問'
: 'Follow-up Clarification';
const followUpInstruction = isZh
? '根据以上反馈,请补充更具体的信息:'
: isJa
? '上記のフィードバックに基づき、より具体的な情報を追加してください:'
: 'Based on the feedback above, please provide more specific details:';
? `質問 ${currentQuestionIndex + 1}`
: `Question ${currentQuestionIndex + 1}`;
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 {
// Standard question presentation
const label = isZh
? `问题 ${currentQuestionIndex + 1}`
: isJa
+9
View File
@@ -119,6 +119,15 @@ export const EvaluationAnnotation = Annotation.Root({
keywords: Annotation<string[] | undefined>({
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;
@@ -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 { AssessmentAnswer } from '../entities/assessment-answer.entity';
import { AssessmentCertificate } from '../entities/assessment-certificate.entity';
import { generateAssessmentPdf } from './pdf-generator';
@Injectable()
export class ExportService {
@@ -95,7 +96,7 @@ export class ExportService {
}
private extractDimensionScores(session: AssessmentSession): any[][] {
const scores = session.templateJson?.dimensionScores || session.finalReport;
const scores = (session as any).dimensionScores;
if (!scores) return [['未找到维度分数']];
if (typeof scores === 'string') {
@@ -142,86 +143,47 @@ export class ExportService {
throw new Error('Session not found');
}
const certificate = await this.certificateRepository.findOne({
const cert = await this.certificateRepository.findOne({
where: { sessionId },
});
const questions = await this.questionRepository.find({
where: { sessionId },
order: { createdAt: 'ASC' },
const questions = (session.questions_json || []) as any[];
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({
where: { questionId: In(questions.map((q) => q.id)) },
});
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Assessment Report</title>
<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(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');
return Buffer.from(html, 'utf-8');
}
}
@@ -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,
];
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(提示词工程)
- llmLLM理解)
- ideIDE协作开发)
- devPattern(开发范式)
- workCapability(工作能力)
## 七、难度说明
默认 STANDARD。如果场景特别复杂或涉及多步推理,可标记 ADVANCED 或 SPECIALIST。
## 八、参考示例
### 选择题示例
【场景】你在编写一段复杂的业务逻辑代码,让 AI 帮忙生成。AI 第一次生成的代码功能没问题,但代码风格和你项目现有的不太一样(缩进方式、命名规范不同)。为了提高后续生成的代码一致性,以下哪种做法最有效?
A. 每次生成后手动调整格式,下次再让 AI 生成时重新说明一遍风格要求。
B. 将项目的代码规范写入 AGENTS.md 或项目配置文件中,让 AI 在生成时自动参考。
C. 给 AI 发送一条"请遵循团队规范"的通用指令,下一条代码就会自动匹配风格。
D. 等全部代码生成完后,统一用 Prettier 或 ESLint 格式化工具修正所有风格问题。
**正确答案:B**
**解析:** B正确,将规范文档化并注入上下文,能从源头统一AI的输出风格。A效率低且容易遗漏。C"团队规范"是模糊描述,AI无法知道具体指什么。D格式化工具只能解决缩进等表面问题,无法修复命名规范等逻辑性规范。
### 简答题示例
【场景】你正在同一个 AI 对话窗口里和 AI 反复修改一份技术方案文档。改了大概30轮之后,你发现 AI 开始"忘记"一开始定下的某些关键约束条件。比如你最早说过"目标读者是业务部门,不要写太多技术细节",但 AI 新生成的内容又开始出现大量技术术语。
【问题】这种情况是怎么造成的?你应该怎么做才能让 AI 重新聚焦?
**判定依据:**
- 关键考点:会话管理——长对话导致上下文窗口膨胀,AI注意力分散
- 通过标准:说出"让AI总结之前内容+开新窗口"即通过
**追问方向:**
- 如果只回答"开新窗口"没说怎么带上前情:追问"开新窗口后之前讨论的结论不就丢了吗?怎么把有用信息带过去?"
- 如果内容不完整:追问"还有没有更好的办法?"
## 九、输出格式(仅输出纯JSON,不要带Markdown标记)
选择题输出:
{
"type": "choice",
"scenario": "场景描述(100-200字实际工作场景)",
"questionText": "【场景】... 【问题】以下哪种做法最有效?",
"options": ["A. 选项A描述", "B. 选项B描述", "C. 选项C描述", "D. 选项D描述"],
"correctAnswer": "B",
"judgment": "B正确,因为... A错误在于... C错误在于... D错误在于...",
"keyPoints": ["知识库中的评分要素1", "知识库中的评分要素2"],
"difficulty": "STANDARD",
"dimension": "prompt",
"basis": "知识库原文依据"
}
简答题输出:
{
"type": "open",
"scenario": "场景描述(100-200字实际工作场景)",
"questionText": "【场景】... 【问题】请描述你会如何处理",
"judgment": "关键考点:XXX 通过标准:说出XXX即通过",
"followupHints": ["追问方向1", "追问方向2"],
"keyPoints": ["知识库中的评分要素1"],
"difficulty": "STANDARD",
"dimension": "prompt",
"basis": "知识库原文依据"
}
输出为JSON数组:`;
const DIMENSION_MAP: Record<string, QuestionDimension> = {
'prompt': QuestionDimension.PROMPT,
'llm': QuestionDimension.LLM,
'ide': QuestionDimension.IDE,
'devpattern': QuestionDimension.DEV_PATTERN,
'workcapability': QuestionDimension.WORK_CAPABILITY,
};
const DIFFICULTY_MAP: Record<string, QuestionDifficulty> = {
'STANDARD': QuestionDifficulty.STANDARD,
'ADVANCED': QuestionDifficulty.ADVANCED,
'SPECIALIST': QuestionDifficulty.SPECIALIST,
};
export function parseGeneratedQuestion(
q: any,
bankId: string,
): QuestionBankItem {
const isChoice = q.type === 'choice';
const dimension = DIMENSION_MAP[q.dimension?.toLowerCase()] ?? QuestionDimension.WORK_CAPABILITY;
const difficulty = DIFFICULTY_MAP[q.difficulty?.toUpperCase()] ?? QuestionDifficulty.STANDARD;
const techniqueTag = q.technique ? `【考查技巧】${q.technique}` : null;
const keyPoints = techniqueTag
? [techniqueTag, ...(q.keyPoints ?? q.key_points ?? [])]
: (q.keyPoints ?? q.key_points ?? []);
return {
bankId,
questionText: q.questionText ?? q.question_text ?? '',
questionType: isChoice ? QuestionType.MULTIPLE_CHOICE : QuestionType.SHORT_ANSWER,
options: isChoice ? (q.options ?? null) : null,
correctAnswer: isChoice ? (q.correctAnswer ?? null) : null,
judgment: q.judgment ?? null,
followupHints: isChoice ? null : (q.followupHints ?? null),
keyPoints,
difficulty,
dimension,
basis: q.basis ?? null,
status: QuestionBankItemStatus.PENDING_REVIEW,
} as QuestionBankItem;
}
@Injectable()
export class QuestionBankService {
private readonly logger = new Logger(QuestionBankService.name);
@@ -92,13 +266,11 @@ export class QuestionBankService {
}
if (createDto.templateId) {
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.status === QuestionBankStatus.DRAFT || existing.status === QuestionBankStatus.REJECTED) {
await this.bankRepository.remove(existing);
} else {
throw new BadRequestException('该模板已关联有效题库,请编辑已有题库');
if (existing.status === QuestionBankStatus.DRAFT || existing.status === QuestionBankStatus.REJECTED || existing.status === QuestionBankStatus.PUBLISHED) {
throw new BadRequestException('该模板已关联题库,请编辑已有题库或删除后重建');
}
}
}
@@ -122,7 +294,7 @@ export class QuestionBankService {
page?: number,
limit?: number,
): 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
.createQueryBuilder('bank')
.leftJoinAndSelect('bank.template', 'template');
@@ -175,6 +347,9 @@ export class QuestionBankService {
async remove(id: string): Promise<void> {
const bank = await this.findOne(id);
if (bank.status === QuestionBankStatus.PUBLISHED) {
throw new ForbiddenException('已发布的题库不可删除');
}
await this.bankRepository.remove(bank);
}
@@ -267,6 +442,9 @@ export class QuestionBankService {
if (!item) {
throw new NotFoundException(`QuestionBankItem with ID "${itemId}" not found`);
}
if (item.status === QuestionBankItemStatus.PUBLISHED) {
throw new ForbiddenException('已发布的题目不可删除');
}
await this.itemRepository.remove(item);
}
@@ -295,35 +473,14 @@ export class QuestionBankService {
const model = new ChatOpenAI({
apiKey: modelConfig.apiKey || 'ollama',
modelName: modelConfig.modelId,
temperature: 0.7,
temperature: 0.1,
configuration: {
baseURL: modelConfig.baseUrl || 'https://api.deepseek.com/v1',
},
});
const systemPrompt = `你是一位专业的知识评估专家。请根据提供的知识库片段生成 ${count} 个唯一的测试题目。
### 强制性语言规则:
**必须使用中文 (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}`;
const systemPrompt = GENERATE_QUESTIONS_SYSTEM_PROMPT;
const humanMsg = `【知识库内容 - 唯一来源】\n\n--- 开始 ---\n${knowledgeBaseContent}\n--- 结束 ---\n\n请按上述规则生成 ${count} 道题,choice:open 比例约 3:7。难度以 STANDARD 为主。`;
try {
const response = await model.invoke([
@@ -341,35 +498,11 @@ export class QuestionBankService {
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[] = [];
for (const q of parsedQuestions) {
const dimension = dimensionMap[q.dimension?.toLowerCase()] || 'WORK_CAPABILITY';
const difficulty = difficultyMap[q.difficulty?.toUpperCase()] || 'STANDARD';
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,
});
const item = this.itemRepository.create(
parseGeneratedQuestion(q, bankId),
);
items.push(item);
}
@@ -386,16 +519,12 @@ export class QuestionBankService {
async selectQuestions(
bankId: string,
count: number,
dimensionWeights?: Array<{ name: string; weight: number }>,
): Promise<QuestionBankItem[]> {
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({
where: { bankId },
where: { bankId, status: QuestionBankItemStatus.PUBLISHED },
});
if (allItems.length === 0) {
@@ -405,40 +534,51 @@ export class QuestionBankService {
const usedIds = new Set<string>();
const selected: QuestionBankItem[] = [];
const availableItems = [...allItems];
let availableItems = [...allItems];
let dimIdx = 0;
while (selected.length < count && availableItems.length > 0) {
const dim = DIMENSIONS[dimIdx % DIMENSIONS.length];
dimIdx++;
const available = availableItems.filter(
(i) => i.dimension === dim && !usedIds.has(i.id),
);
if (available.length > 0) {
const idx = Math.floor(Math.random() * available.length);
const item = available[idx];
selected.push(item);
usedIds.add(item.id);
const actualIdx = availableItems.findIndex(i => i.id === item.id);
if (actualIdx > -1) {
availableItems.splice(actualIdx, 1);
if (dimensionWeights && dimensionWeights.length > 0) {
const totalWeight = dimensionWeights.reduce((s, d) => s + d.weight, 0);
for (const dw of dimensionWeights) {
const dimName = dw.name as QuestionDimension;
const targetForDim = Math.round(count * dw.weight / totalWeight);
const pool = availableItems.filter(i => i.dimension === dimName && !usedIds.has(i.id));
this.shuffleArray(pool);
const take = Math.min(targetForDim, pool.length);
for (let i = 0; i < take; i++) {
selected.push(pool[i]);
usedIds.add(pool[i].id);
}
}
if (dimIdx >= DIMENSIONS.length * 3) {
break;
availableItems = availableItems.filter(i => !usedIds.has(i.id));
this.shuffleArray(availableItems);
while (selected.length < count && availableItems.length > 0) {
const item = availableItems.pop()!;
selected.push(item);
usedIds.add(item.id);
}
}
if (selected.length < count && availableItems.length > 0) {
const shuffled = this.shuffleArray([...availableItems]);
for (const item of shuffled) {
if (selected.length >= count) break;
if (!usedIds.has(item.id)) {
} else {
let dimIdx = 0;
while (selected.length < count && availableItems.length > 0) {
const dim = DIMENSIONS[dimIdx % DIMENSIONS.length];
dimIdx++;
const pool = availableItems.filter(i => i.dimension === dim && !usedIds.has(i.id));
if (pool.length > 0) {
const idx = Math.floor(Math.random() * pool.length);
const item = pool[idx];
selected.push(item);
usedIds.add(item.id);
availableItems = availableItems.filter(i => i.id !== item.id);
}
if (dimIdx >= DIMENSIONS.length * 3) break;
}
if (selected.length < count && availableItems.length > 0) {
this.shuffleArray(availableItems);
for (const item of availableItems) {
if (selected.length >= count) break;
if (!usedIds.has(item.id)) {
selected.push(item);
usedIds.add(item.id);
}
}
}
}
@@ -456,7 +596,7 @@ export class QuestionBankService {
this.logger.log(
`[selectQuestions] Selected ${selected.length} questions from bank ${bankId}`,
);
return selected;
return this.shuffleArray(selected);
}
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,
NotFoundException,
ForbiddenException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@@ -18,14 +19,37 @@ export class TemplateService {
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(
createDto: CreateTemplateDto,
userId: string,
tenantId: string,
): Promise<AssessmentTemplate> {
this.validateRequiredFields(createDto);
const { ...data } = createDto;
const template = this.templateRepository.create({
...data,
isActive: data.isActive !== undefined ? data.isActive : true,
createdBy: userId,
tenantId,
});
@@ -76,6 +100,8 @@ export class TemplateService {
tenantId: string,
): Promise<AssessmentTemplate> {
const template = await this.findOne(id, userId, tenantId);
const merged = { ...template, ...updateDto };
this.validateRequiredFields(merged);
Object.assign(template, updateDto);
return this.templateRepository.save(template);
}
+5 -2
View File
@@ -3,6 +3,7 @@ import {
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@@ -25,6 +26,8 @@ import * as path from 'path';
*/
@Injectable()
export class CombinedAuthGuard implements CanActivate {
private readonly logger = new Logger(CombinedAuthGuard.name);
// We extend AuthGuard('jwt') functionality by composition
private jwtGuard: ReturnType<typeof AuthGuard>;
@@ -55,7 +58,7 @@ export class CombinedAuthGuard implements CanActivate {
return true;
}
console.log(
this.logger.log(
`[CombinedAuthGuard] Checking auth for route: ${request.method} ${request.url}`,
);
@@ -160,7 +163,7 @@ export class CombinedAuthGuard implements CanActivate {
}
return false;
} catch (e) {
console.error(`[CombinedAuthGuard] JWT Auth Error:`, e);
this.logger.error('[CombinedAuthGuard] JWT Auth Error: ' + e);
throw e instanceof UnauthorizedException
? e
: new UnauthorizedException('Authentication required');
+25 -22
View File
@@ -1,6 +1,7 @@
import {
Body,
Controller,
Logger,
Post,
Request,
Res,
@@ -36,6 +37,8 @@ class StreamChatDto {
@Controller('chat')
@UseGuards(CombinedAuthGuard)
export class ChatController {
private readonly logger = new Logger(ChatController.name);
constructor(
private chatService: ChatService,
private modelConfigService: ModelConfigService,
@@ -49,7 +52,7 @@ export class ChatController {
@Res() res: Response,
) {
try {
console.log('Full Request Body:', JSON.stringify(body, null, 2));
this.logger.log('Full Request Body:', JSON.stringify(body, null, 2));
const {
message,
history = [],
@@ -71,22 +74,22 @@ export class ChatController {
} = body;
const userId = req.user.id;
console.log('=== Chat Debug Info ===');
console.log('User ID:', userId);
console.log('Message:', message);
console.log('User Language:', userLanguage);
console.log('Selected Embedding ID:', selectedEmbeddingId);
console.log('Selected LLM ID:', selectedLLMId);
console.log('Selected Groups:', selectedGroups);
console.log('Selected Files:', selectedFiles);
console.log('History ID:', historyId);
console.log('Temperature:', temperature);
console.log('Max Tokens:', maxTokens);
console.log('Top K:', topK);
console.log('Similarity Threshold:', similarityThreshold);
console.log('Rerank Similarity Threshold:', rerankSimilarityThreshold);
console.log('Query Expansion:', enableQueryExpansion);
console.log('HyDE:', enableHyDE);
this.logger.log('=== Chat Debug Info ===');
this.logger.log('User ID:', userId);
this.logger.log('Message:', message);
this.logger.log('User Language:', userLanguage);
this.logger.log('Selected Embedding ID:', selectedEmbeddingId);
this.logger.log('Selected LLM ID:', selectedLLMId);
this.logger.log('Selected Groups:', selectedGroups);
this.logger.log('Selected Files:', selectedFiles);
this.logger.log('History ID:', historyId);
this.logger.log('Temperature:', temperature);
this.logger.log('Max Tokens:', maxTokens);
this.logger.log('Top K:', topK);
this.logger.log('Similarity Threshold:', similarityThreshold);
this.logger.log('Rerank Similarity Threshold:', rerankSimilarityThreshold);
this.logger.log('Query Expansion:', enableQueryExpansion);
this.logger.log('HyDE:', enableHyDE);
const role = req.user.role;
const tenantId = req.user.tenantId;
@@ -105,14 +108,14 @@ export class ChatController {
if (selectedLLMId) {
// Find specifically selected model
llmModel = await this.modelConfigService.findOne(selectedLLMId);
console.log('使用选中的LLM模型:', llmModel.name);
this.logger.log('使用选中的LLM模型:', llmModel.name);
} else {
// Use organization's default LLM from Index Chat Config (strict)
llmModel = await this.modelConfigService.findDefaultByType(
tenantId,
ModelType.LLM,
);
console.log(
this.logger.log(
'最终使用的LLM模型 (默认):',
llmModel ? llmModel.name : '无',
);
@@ -162,7 +165,7 @@ export class ChatController {
res.write('data: [DONE]\n\n');
res.end();
} catch (error) {
console.error('Stream chat error:', error);
this.logger.error('Stream chat error:', error);
try {
res.write(
`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.end();
} 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.end();
} catch (error) {
console.error('Stream assist error:', error);
this.logger.error('Stream assist error:', error);
res.write(
`data: ${JSON.stringify({ type: 'error', data: error.message || 'Server Error' })}\n\n`,
);
+29 -29
View File
@@ -71,30 +71,30 @@ export class ChatService {
enableHyDE?: boolean, // New
tenantId?: string, // New: tenant isolation
): AsyncGenerator<{ type: 'content' | 'sources' | 'historyId'; data: any }> {
console.log('=== ChatService.streamChat ===');
console.log('User ID:', userId);
console.log('User language:', userLanguage);
console.log('Selected embedding model ID:', selectedEmbeddingId);
console.log('Selected groups:', selectedGroups);
console.log('Selected files:', selectedFiles);
console.log('History ID:', historyId);
console.log('Temperature:', temperature);
console.log('Max Tokens:', maxTokens);
console.log('Top K:', topK);
console.log('Similarity threshold:', similarityThreshold);
console.log('Rerank threshold:', rerankSimilarityThreshold);
console.log('Query expansion:', enableQueryExpansion);
console.log('HyDE:', enableHyDE);
console.log('Model configuration:', {
this.logger.log('=== ChatService.streamChat ===');
this.logger.log('User ID:', userId);
this.logger.log('User language:', userLanguage);
this.logger.log('Selected embedding model ID:', selectedEmbeddingId);
this.logger.log('Selected groups:', selectedGroups);
this.logger.log('Selected files:', selectedFiles);
this.logger.log('History ID:', historyId);
this.logger.log('Temperature:', temperature);
this.logger.log('Max Tokens:', maxTokens);
this.logger.log('Top K:', topK);
this.logger.log('Similarity threshold:', similarityThreshold);
this.logger.log('Rerank threshold:', rerankSimilarityThreshold);
this.logger.log('Query expansion:', enableQueryExpansion);
this.logger.log('HyDE:', enableHyDE);
this.logger.log('Model configuration:', {
name: modelConfig.name,
modelId: modelConfig.modelId,
baseUrl: modelConfig.baseUrl,
});
console.log(
this.logger.log(
'API Key prefix:',
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)
// Use actual language based on user settings
@@ -113,7 +113,7 @@ export class ChatService {
selectedGroups,
);
currentHistoryId = searchHistory.id;
console.log(
this.logger.log(
this.i18nService.getMessage(
'creatingHistory',
effectiveUserLanguage,
@@ -143,7 +143,7 @@ export class ChatService {
);
}
console.log(
this.logger.log(
this.i18nService.getMessage(
'usingEmbeddingModel',
effectiveUserLanguage,
@@ -156,7 +156,7 @@ export class ChatService {
);
// 2. Search using user's query directly
console.log(
this.logger.log(
this.i18nService.getMessage('startingSearch', effectiveUserLanguage),
);
yield {
@@ -204,7 +204,7 @@ export class ChatService {
// HybridSearch returns ES hit structure, but RagSearchResult is normalized
// BuildContext expects {fileName, content}. RagSearchResult has these
searchResults = ragResults;
console.log(
this.logger.log(
this.i18nService.getMessage(
'searchResultsCount',
effectiveUserLanguage,
@@ -274,7 +274,7 @@ export class ChatService {
};
}
} catch (searchError) {
console.error(
this.logger.error(
this.i18nService.getMessage(
'searchFailedLog',
effectiveUserLanguage,
@@ -461,14 +461,14 @@ ${instruction}`;
try {
// Join keywords into search string
const combinedQuery = keywords.join(' ');
console.log(
this.logger.log(
this.i18nService.getMessage('searchString', userLanguage) +
combinedQuery,
);
// Check if embedding model ID is provided
if (!embeddingModelId) {
console.log(
this.logger.log(
this.i18nService.getMessage(
'embeddingModelIdNotProvided',
userLanguage,
@@ -478,7 +478,7 @@ ${instruction}`;
}
// Use actual embedding vector
console.log(
this.logger.log(
this.i18nService.getMessage('generatingEmbeddings', userLanguage),
);
const queryEmbedding = await this.embeddingService.getEmbeddings(
@@ -486,7 +486,7 @@ ${instruction}`;
embeddingModelId,
);
const queryVector = queryEmbedding[0];
console.log(
this.logger.log(
this.i18nService.getMessage('embeddingsGenerated', userLanguage) +
this.i18nService.getMessage('dimensions', userLanguage) +
':',
@@ -494,7 +494,7 @@ ${instruction}`;
);
// Hybrid search
console.log(
this.logger.log(
this.i18nService.getMessage('performingHybridSearch', userLanguage),
);
const results = await this.elasticsearchService.hybridSearch(
@@ -507,7 +507,7 @@ ${instruction}`;
explicitFileIds, // Pass explicit file IDs
tenantId, // Pass tenant ID
);
console.log(
this.logger.log(
this.i18nService.getMessage('esSearchCompleted', userLanguage) +
this.i18nService.getMessage('resultsCount', userLanguage) +
':',
@@ -516,7 +516,7 @@ ${instruction}`;
return results.slice(0, 10);
} catch (error) {
console.error(
this.logger.error(
this.i18nService.getMessage('hybridSearchFailed', userLanguage) + ':',
error,
);
+7 -3
View File
@@ -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.
*/
@@ -40,9 +44,9 @@ export function safeParseJson<T = any>(text: string): T | null {
try {
return JSON.parse(jsonStr) as T;
} catch (error) {
console.error('[safeParseJson] Failed to parse JSON:', error);
console.error('[safeParseJson] Original text:', text);
console.error('[safeParseJson] Extracted string:', jsonStr);
logger.error('[safeParseJson] Failed to parse JSON:', error);
logger.error('[safeParseJson] Original text:', text);
logger.error('[safeParseJson] Extracted string:', jsonStr);
return null;
}
}
@@ -9,6 +9,7 @@ import {
UseGuards,
Request,
Query,
Logger,
} from '@nestjs/common';
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
@@ -24,6 +25,8 @@ import { I18nService } from '../i18n/i18n.service';
@Controller('knowledge-groups')
@UseGuards(CombinedAuthGuard, RolesGuard)
export class KnowledgeGroupController {
private readonly logger = new Logger(KnowledgeGroupController.name);
constructor(
private readonly groupService: KnowledgeGroupService,
private readonly i18nService: I18nService,
@@ -43,7 +46,7 @@ export class KnowledgeGroupController {
@Post()
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
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(
req.user.id,
req.user.tenantId,
@@ -1,5 +1,6 @@
import {
Injectable,
Logger,
NotFoundException,
ForbiddenException,
Inject,
@@ -47,6 +48,8 @@ export interface PaginatedGroups {
@Injectable()
export class KnowledgeGroupService {
private readonly logger = new Logger(KnowledgeGroupService.name);
constructor(
@InjectRepository(KnowledgeGroup)
private groupRepository: Repository<KnowledgeGroup>,
@@ -62,7 +65,7 @@ export class KnowledgeGroupService {
userId: string,
tenantId: string,
): 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
const queryBuilder = this.groupRepository
.createQueryBuilder('group')
@@ -147,7 +150,7 @@ export class KnowledgeGroupService {
tenantId: string,
createGroupDto: CreateGroupDto,
): Promise<KnowledgeGroup> {
console.log('[KnowledgeGroup create] userId:', userId, 'tenantId:', tenantId);
this.logger.log('[KnowledgeGroup create] userId: ' + userId + ', tenantId: ' + tenantId);
const group = this.groupRepository.create({
...createGroupDto,
parentId: createGroupDto.parentId ?? null,
@@ -155,7 +158,7 @@ export class KnowledgeGroupService {
});
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;
}
@@ -229,7 +232,7 @@ export class KnowledgeGroupService {
);
}
} catch (error) {
console.error(
this.logger.error(
`Failed to delete file ${file.id} when deleting group ${id}`,
error,
);
@@ -257,7 +260,6 @@ export class KnowledgeGroupService {
throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
}
// Check permission using TenantService
const hasAccess = await this.tenantService.canAccessTenant(
userId,
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(
+5 -3
View File
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Note } from './note.entity';
@@ -11,6 +11,8 @@ import { I18nService } from '../i18n/i18n.service';
@Injectable()
export class NoteService {
private readonly logger = new Logger(NoteService.name);
// Directory will be created dynamically per user
private getScreenshotsDir(userId: string) {
return path.join(process.cwd(), 'uploads', 'notes-screenshots', userId);
@@ -153,7 +155,7 @@ export class NoteService {
}
// 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 === '') {
@@ -176,7 +178,7 @@ export class NoteService {
screenshot.buffer,
);
} catch (error) {
console.error('OCR extraction failed:', error);
this.logger.error('OCR extraction failed:', error);
// Continue without OCR text if extraction fails
}
+7 -4
View File
@@ -1,5 +1,6 @@
import {
Controller,
Logger,
Post,
UseGuards,
UseInterceptors,
@@ -14,6 +15,8 @@ import { I18nService } from '../i18n/i18n.service';
@UseGuards(CombinedAuthGuard)
@UseGuards(CombinedAuthGuard)
export class OcrController {
private readonly logger = new Logger(OcrController.name);
constructor(
private readonly ocrService: OcrService,
private readonly i18n: I18nService,
@@ -22,14 +25,14 @@ export class OcrController {
@Post('recognize')
@UseInterceptors(FileInterceptor('image'))
async recognizeText(@UploadedFile() image: Express.Multer.File) {
console.log('OCR recognition endpoint called');
this.logger.log('OCR recognition endpoint called');
if (!image) {
console.error('No image uploaded');
this.logger.error('No image uploaded');
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);
console.log(`OCR extraction completed. Text length: ${text.length}`);
this.logger.log('OCR extraction completed. Text length: ' + text.length);
return { text };
}
}
+2 -5
View File
@@ -171,7 +171,7 @@ export class UserService implements OnModuleInit {
}
const hashedPassword = await bcrypt.hash(password, 10);
console.log(
this.logger.log(
`[UserService] Creating user: ${username}, isAdmin: ${isAdmin}`,
);
const user = await this.usersRepository.save({
@@ -403,10 +403,7 @@ export class UserService implements OnModuleInit {
role: UserRole.SUPER_ADMIN,
});
console.log('\n=== Admin account created ===');
console.log('Username: admin');
console.log('Password:', randomPassword);
console.log('========================================\n');
this.logger.log('Admin account created (username: admin, password: ' + randomPassword + ')');
}
}
}
@@ -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 countbased 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}`);
}
}
+214
View File
@@ -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);
}
});
});
});
+12
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
cd /d D:\AuraK\server
node --enable-source-maps dist/main
pause
+3
View File
@@ -0,0 +1,3 @@
cd /d D:\AuraK\web
npx vite --port 13001
pause
+1 -1
View File
@@ -34,7 +34,7 @@ export const WorkspaceLayout: React.FC<WorkspaceLayoutProps> = ({
appMode={appMode}
onSwitchMode={onSwitchMode}
/>
<div className="flex-1 overflow-hidden relative">
<div className="flex-1 overflow-auto relative">
{children}
</div>
</div>
@@ -7,7 +7,7 @@ import { useToast } from '../../contexts/ToastContext';
import { useConfirm } from '../../contexts/ConfirmContext';
import { templateService } from '../../services/templateService';
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 = () => {
const { t } = useLanguage();
@@ -29,8 +29,12 @@ export const AssessmentTemplateManager: React.FC = () => {
difficultyDistribution: 'Basic: 30%, Intermediate: 40%, Advanced: 30%',
style: 'Professional',
knowledgeGroupId: '',
passingScore: 6,
totalTimeLimit: 1800,
perQuestionTimeLimit: 300,
});
const [copiedId, setCopiedId] = useState<string | null>(null);
const [dimensions, setDimensions] = useState<AssessmentDimension[]>([]);
const fetchTemplates = async () => {
setIsLoading(true);
@@ -72,7 +76,11 @@ export const AssessmentTemplateManager: React.FC = () => {
: (template.difficultyDistribution || ''),
style: template.style || 'Professional',
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 {
setEditingTemplate(null);
setFormData({
@@ -83,7 +91,11 @@ export const AssessmentTemplateManager: React.FC = () => {
difficultyDistribution: '{"Basic": 3, "Intermediate": 4, "Advanced": 3}',
style: 'Professional',
knowledgeGroupId: '',
passingScore: 6,
totalTimeLimit: 1800,
perQuestionTimeLimit: 300,
});
setDimensions([]);
}
setShowModal(true);
};
@@ -95,13 +107,10 @@ export const AssessmentTemplateManager: React.FC = () => {
// Convert UI strings back to required types
const keywordsArray = formData.keywords.split(',').map(k => k.trim()).filter(k => k !== '');
let diffDist: any = formData.difficultyDistribution;
try {
if (formData.difficultyDistribution.startsWith('{')) {
diffDist = JSON.parse(formData.difficultyDistribution);
}
} catch (e) {
// Keep as string if parsing fails
if (typeof diffDist === 'string' && diffDist.trim().startsWith('{')) {
try { diffDist = JSON.parse(diffDist); } catch (e) { diffDist = undefined; }
}
if (typeof diffDist !== 'object' || diffDist === null) diffDist = undefined;
const payload: CreateTemplateData = {
name: formData.name,
@@ -111,6 +120,10 @@ export const AssessmentTemplateManager: React.FC = () => {
difficultyDistribution: diffDist,
style: formData.style,
knowledgeGroupId: formData.knowledgeGroupId || undefined,
dimensions: dimensions.length > 0 ? dimensions : undefined,
passingScore: formData.passingScore * 10,
totalTimeLimit: formData.totalTimeLimit,
perQuestionTimeLimit: formData.perQuestionTimeLimit,
};
if (editingTemplate) {
@@ -122,9 +135,10 @@ export const AssessmentTemplateManager: React.FC = () => {
}
setShowModal(false);
fetchTemplates();
} catch (error) {
} catch (error: any) {
console.error('Save failed:', error);
showError(t('actionFailed'));
const msg = error?.message;
showError(msg && msg !== 'Request failed' ? msg : t('actionFailed'));
} finally {
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) => {
if (!(await confirm(t('confirmTitle')))) return;
try {
@@ -255,6 +283,16 @@ export const AssessmentTemplateManager: React.FC = () => {
</span>
</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">
{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">
@@ -372,16 +410,99 @@ export const AssessmentTemplateManager: React.FC = () => {
</select>
</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('style')}
</label>
<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"
value={formData.style}
onChange={e => setFormData({ ...formData, style: e.target.value })}
/>
<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('style')}
</label>
<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"
value={formData.style}
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>
+217 -25
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import {
Brain,
Send,
@@ -13,7 +14,8 @@ import {
Star,
Award,
Trophy,
Trash2
Trash2,
XCircle
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useLanguage } from '../../contexts/LanguageContext';
@@ -51,6 +53,11 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<string | 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);
@@ -103,6 +110,10 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
setTimeCheck(data);
if (data.isTotalTimeout || data.isQuestionTimeout) {
setError(t('timeLimitExceeded'));
if (!autoSubmitted && !isLoading) {
setAutoSubmitted(true);
await handleSubmitAnswer(true);
}
}
} catch (err) {
console.error('Failed to check time:', err);
@@ -137,7 +148,11 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
setState(histState);
setSession(histSession);
} 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 {
setIsLoading(false);
setLoadingHistoryId(null);
@@ -184,7 +199,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
...prev,
...event.data,
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,
feedbackHistory: event.data.feedbackHistory
? [...(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 () => {
if (!session || !inputValue.trim() || isLoading) return;
const handleSubmitAnswer = async (forced = false) => {
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('');
setSelectedChoice(null);
setIsLoading(true);
setError(null);
setProcessStep(isZh ? '正在准备发送...' : isJa ? '送信準備中...' : 'Preparing to send...');
@@ -252,7 +277,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
if (!prev) return event.data;
const prevMessages = prev.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;
return {
@@ -271,6 +296,8 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
if (event.data.status === 'COMPLETED') {
setSession(prev => prev ? { ...prev, status: 'COMPLETED' } : null);
fetchHistory();
} else if (event.data.currentQuestionIndex !== undefined) {
assessmentService.nextQuestion(session.id).catch(() => {});
}
}
}
@@ -428,7 +455,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
{/* Assessment History Sidebar */}
{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">
<History size={18} className="text-indigo-600" />
{t('recentAssessments')}
@@ -502,6 +529,10 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
!(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 lastFeedbackMessage = feedbackHistory[feedbackHistory.length - 1];
@@ -576,26 +607,75 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
</div>
<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">
<textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !isTimedOut) {
e.preventDefault();
handleSubmitAnswer();
}
}}
placeholder={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"
placeholder={isTimedOut ? t('timeLimitExceeded') : t('typeAnswerPlaceholder')}
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}
/>
<button
onClick={handleSubmitAnswer}
disabled={!inputValue.trim() || isLoading}
disabled={!inputValue.trim() || isLoading || isTimedOut}
className={cn(
"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-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" : ""} />
</button>
</div>
)}
</div>
</div>
{/* 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">
<ClipboardCheck size={18} className="text-indigo-600" />
{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={cn(
"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>
</div>
</div>
<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>
<h4 className="flex items-center gap-2.5 text-lg font-black text-slate-900 mb-4">
<FileText size={20} className="text-indigo-600" />
@@ -777,15 +917,14 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
if (!session) return;
try {
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 a = document.createElement('a');
a.href = url;
a.download = result.filename;
a.click();
URL.revokeObjectURL(url);
window.open(url, '_blank');
} 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]"
@@ -810,7 +949,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
a.click();
URL.revokeObjectURL(url);
} 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]"
@@ -822,7 +961,8 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
if (!session) return;
try {
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) {
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">
{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">
{error && (
<motion.div
+306 -174
View File
@@ -3,35 +3,31 @@ import { createPortal } from 'react-dom';
import { useParams, useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
ChevronLeft, Plus, Sparkles, Send, Check, X,
ChevronLeft, Plus, Sparkles, Send, Check, X, XCircle, Clock,
Trash2, Edit2, FileText, Loader2, BookOpen, Brain,
AlertCircle, Hash, Layers
} from 'lucide-react';
import { questionBankService, QuestionBank, QuestionBankItem, CreateQuestionBankItemDto } from '../../services/questionBankService';
import { templateService } from '../../services/templateService';
import { AssessmentTemplate } from '../../types';
import { useToast } from '../../contexts/ToastContext';
import { useConfirm } from '../../contexts/ConfirmContext';
import { useLanguage } from '../../contexts/LanguageContext';
const QUESTION_TYPES = [
{ value: 'SHORT_ANSWER', label: '简答题' },
{ value: 'MULTIPLE_CHOICE', label: '选择题' },
{ value: 'TRUE_FALSE', label: '判断题' },
{ value: 'SHORT_ANSWER', labelKey: 'shortAnswer' as const },
{ value: 'MULTIPLE_CHOICE', labelKey: 'multipleChoice' as const },
{ value: 'TRUE_FALSE', labelKey: 'trueFalse' as const },
];
const DIFFICULTIES = [
{ value: 'STANDARD', label: '标准' },
{ value: 'ADVANCED', label: '高级' },
{ value: 'SPECIALIST', label: '专家' },
{ value: 'STANDARD', labelKey: 'standard' as const },
{ value: 'ADVANCED', labelKey: 'advanced' as const },
{ value: 'SPECIALIST', labelKey: 'specialist' as const },
];
const DIMENSIONS = [
{ value: 'PROMPT', label: 'Prompt' },
{ value: 'LLM', label: 'LLM' },
{ value: 'IDE', label: 'IDE' },
{ value: 'DEV_PATTERN', label: '开发模式' },
{ value: 'WORK_CAPABILITY', label: '工作能力' },
];
const typeIcons: Record<string, React.ReactNode> = {
type TypeIcon = { [key: string]: React.ReactNode };
const typeIcons: TypeIcon = {
SHORT_ANSWER: <FileText size={12} />,
MULTIPLE_CHOICE: <Layers size={12} />,
TRUE_FALSE: <Check size={12} />,
@@ -40,14 +36,19 @@ const typeIcons: Record<string, React.ReactNode> = {
export default function QuestionBankDetailView() {
const { id: bankId } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useLanguage();
const { showSuccess, showError } = useToast();
const { confirm } = useConfirm();
if (!bankId) {
return (
<div className="p-6">
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-4">
<ChevronLeft size={20} />
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors mb-4">
<ChevronLeft size={18} /><span className="text-xs font-black uppercase tracking-widest">{t('backToBankList')}</span>
</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>
);
}
@@ -55,6 +56,7 @@ export default function QuestionBankDetailView() {
const [bank, setBank] = useState<QuestionBank | null>(null);
const [items, setItems] = useState<QuestionBankItem[]>([]);
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
const [template, setTemplate] = useState<AssessmentTemplate | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
@@ -72,30 +74,96 @@ export default function QuestionBankDetailView() {
});
const [keyPointsInput, setKeyPointsInput] = useState('');
const [generateForm, setGenerateForm] = useState({
count: 5,
knowledgeBaseContent: '',
});
const [generateForm, setGenerateForm] = useState({ count: 5, knowledgeBaseContent: '' });
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]);
const fetchData = async () => {
try { setLoading(true);
try {
setLoading(true);
const bankData = await questionBankService.getBank(bankId);
setBank(bankData);
const itemsData = await questionBankService.getBankItems(bankId);
setItems(itemsData);
} catch (err: any) { setError(err.message || '加载失败');
} finally { setLoading(false); }
} catch (err: any) {
setError(err.message || t('actionFailed'));
showError(err.message || t('actionFailed'));
} finally {
setLoading(false);
}
};
const fetchTemplates = async () => {
try { const data = await templateService.getAll(); setTemplates(data);
} catch (err) { console.error('加载模板失败:', err); }
try {
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) => {
e.preventDefault();
if (!itemForm.questionText.trim()) return;
@@ -103,8 +171,9 @@ export default function QuestionBankDetailView() {
try {
await questionBankService.createItem(bankId, { ...itemForm, keyPoints: keyPointsInput.split('\n').filter(k => k.trim()) });
closeItemForm();
showSuccess(t('questionAdded'));
fetchData();
} catch (err: any) { alert('创建失败: ' + (err.message || '未知错误'));
} catch (err: any) { showError(err.message || t('actionFailed'));
} finally { setSaving(false); }
};
@@ -115,15 +184,17 @@ export default function QuestionBankDetailView() {
try {
await questionBankService.updateItem(bankId, editingItem.id, { ...itemForm, keyPoints: keyPointsInput.split('\n').filter(k => k.trim()) });
closeItemForm();
showSuccess(t('questionUpdated'));
fetchData();
} catch (err: any) { alert('更新失败: ' + (err.message || '未知错误'));
} catch (err: any) { showError(err.message || t('actionFailed'));
} finally { setSaving(false); }
};
const handleDeleteItem = async (itemId: string) => {
if (!confirm('确定要删除这道题目吗?')) return;
try { await questionBankService.deleteItem(bankId, itemId); fetchData();
} catch (err: any) { alert('删除失败: ' + (err.message || '未知错误')); }
const ok = await confirm({ message: t('confirmDeleteQuestion'), confirmLabel: t('delete'), cancelLabel: t('cancel') });
if (!ok) return;
try { await questionBankService.deleteItem(bankId, itemId); showSuccess(t('questionDeleted')); fetchData();
} catch (err: any) { showError(err.message || t('actionFailed')); }
};
const handleGenerate = async () => {
@@ -132,26 +203,41 @@ export default function QuestionBankDetailView() {
await questionBankService.generateQuestions(bankId, generateForm.count, generateForm.knowledgeBaseContent);
setShowGenerate(false);
setGenerateForm({ count: 5, knowledgeBaseContent: '' });
showSuccess(t('generatedQuestions').replace('$1', String(generateForm.count)));
fetchData();
} catch (err: any) { alert('生成失败: ' + (err.message || '未知错误'));
} catch (err: any) { showError(err.message || t('actionFailed'));
} finally { setGenerating(false); }
};
const handleSubmitForReview = async () => {
if (!confirm('确定要提交审核吗?')) return;
try { await questionBankService.submitForReview(bankId); fetchData();
} catch (err: any) { alert('提交失败: ' + (err.message || '未知错误')); }
const ok = await confirm({ message: t('confirmSubmitReview'), confirmLabel: t('submitForReview'), cancelLabel: t('cancel') });
if (!ok) return;
try { await questionBankService.submitForReview(bankId); showSuccess(t('bankSubmittedForReview')); fetchData();
} catch (err: any) { showError(err.message || t('actionFailed')); }
};
const handlePublish = async () => {
if (!confirm('确定要发布题库吗?')) return;
try { await questionBankService.publishBank(bankId); fetchData();
} catch (err: any) { alert('发布失败: ' + (err.message || '未知错误')); }
const isPendingReview = bank?.status === 'PENDING_REVIEW';
const label = isPendingReview ? t('approve') : t('republish');
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) => {
try { await questionBankService.updateItem(bankId, itemId, { status: 'PUBLISHED' } as any); fetchData();
} catch (err: any) { alert('操作失败: ' + (err.message || '未知错误')); }
try { await questionBankService.updateItem(bankId, itemId, { status: 'PUBLISHED' } as any); showSuccess(t('questionApproved')); fetchData();
} 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) => {
@@ -163,27 +249,24 @@ export default function QuestionBankDetailView() {
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) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-blue-600 opacity-30" />
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-blue-600 opacity-20" />
</div>
);
}
if (error) {
return (
<div className="p-6">
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-4"><ChevronLeft size={20} /> </button>
<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>
<div className="space-y-4">
<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">{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>
);
}
@@ -191,100 +274,182 @@ export default function QuestionBankDetailView() {
const pendingItems = items.filter(i => i.status === 'PENDING_REVIEW');
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 (
<div className="space-y-6">
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors mb-2">
<ChevronLeft size={18} /><span className="text-xs font-black uppercase tracking-widest"></span>
<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">
<ChevronLeft size={18} /><span className="text-xs font-black uppercase tracking-widest">{t('backToBankList')}</span>
</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="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>
<h1 className="text-2xl font-black text-slate-900">{bank?.name}</h1>
<p className="text-sm text-slate-500 mt-1">{bank?.description || '暂无描述'}</p>
<div className="flex items-center gap-3 mt-2">
<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>
{getStatusBadge(bank?.status || 'DRAFT')}
<p className="text-sm text-slate-500 mt-1">{bank?.description || t('noDescription')}</p>
<div className="flex items-center gap-3 mt-2 flex-wrap">
{template && (
<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 className="flex gap-2">
<div className="flex gap-2 shrink-0">
{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">
<Send size={16} />
<Send size={16} /> {t('submitForReview')}
</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">
<Check size={16} />
<Check size={16} /> {bank?.status === 'PENDING_REVIEW' ? t('approve') : t('republish')}
</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">
<Sparkles size={16} /> AI生成
<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} /> {t('aiGenerate')}
</button>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
{[
{ label: '总题目数', value: items.length, color: 'blue', icon: <FileText size={16} /> },
{ label: '待审核', value: pendingItems.length, color: 'amber', icon: <Send size={16} /> },
{ 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`}>
{statCards.map((stat, i) => (
<motion.div key={stat.label} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.08 }}
className={`rounded-2xl border p-4 ${stat.classes}`}>
<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-${stat.color}-500`}>{stat.icon}</span>
<span className="text-[10px] font-black uppercase tracking-widest opacity-70">{stat.label}</span>
{stat.icon}
</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>
))}
</div>
<div className="flex items-center justify-between">
<h2 className="text-lg font-black text-slate-900"></h2>
<button onClick={() => { setShowAddItem(true); setEditingItem(null); setKeyPointsInput(''); setItemForm({ questionText: '', questionType: 'SHORT_ANSWER', keyPoints: [], difficulty: 'STANDARD', dimension: '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} />
</button>
<h2 className="text-lg font-black text-slate-900">{t('questionList')}</h2>
<div className="flex items-center gap-2">
{selectedItemIds.size > 0 && (
<>
<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>
{items.length === 0 ? (
<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" />
<p className="text-slate-400 font-bold uppercase tracking-widest text-xs"></p>
<p className="text-slate-300 text-xs mt-2">使AI生成</p>
<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-black uppercase tracking-widest text-xs mb-1">{t('noQuestions')}</p>
<p className="text-slate-300 text-xs">{t('noQuestionsDesc')}</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4">
<div className="space-y-4">
<AnimatePresence mode="popLayout">
{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 }}
className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/5 rounded-full blur-3xl -mr-16 -mt-16" />
<div className="flex items-start justify-between relative z-10">
<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]}{QUESTION_TYPES.find(t => t.value === item.questionType)?.label}</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} />{DIFFICULTIES.find(d => d.value === item.difficulty)?.label}</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} />{DIMENSIONS.find(d => d.value === item.dimension)?.label}</span>
{getStatusBadge(item.status)}
</div>
<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>
{items.map((item, idx) => {
const itemStat = item.status === 'PUBLISHED' ? statusColors.PUBLISHED : statusColors.PENDING_REVIEW;
return (
<motion.div key={item.id} layout initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95 }}
transition={{ delay: Math.min(idx * 0.03, 0.3) }}
className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden">
<div className={`absolute top-0 right-0 w-40 h-40 rounded-full blur-3xl -mr-20 -mt-20 ${itemStat.blur}`} />
<div className="relative z-10 flex items-start justify-between">
{item.status === 'PENDING_REVIEW' && (
<input type="checkbox" checked={selectedItemIds.has(item.id)}
onChange={() => toggleSelectItem(item.id)}
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" />
)}
{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 className="flex gap-1 ml-4 shrink-0">
{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>
))}
</motion.div>
);
})}
</AnimatePresence>
</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 }}
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="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>
<h3 className="text-xl font-black text-slate-900">{editingItem ? '编辑题目' : '添加题目'}</h3>
</div>
<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>
<h3 className="text-xl font-black text-slate-900">{editingItem ? t('editQuestion') : t('addQuestionTitle')}</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>
</div>
<form id="item-form" onSubmit={editingItem ? handleUpdateItem : handleCreateItem} className="p-8 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"><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="输入题目内容" rows={3} required />
<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>
<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 />
</div>
<div className="grid grid-cols-2 gap-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" /> </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 appearance-none cursor-pointer">
{QUESTION_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<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>
<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>
</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-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 appearance-none cursor-pointer">
{DIFFICULTIES.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
<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>
<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>
</div>
</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"><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 appearance-none cursor-pointer">
{DIMENSIONS.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
<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>
<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>
</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"><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
要点2
要点3" rows={4} />
<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>
<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} />
</div>
<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="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 ? '保存中...' : (editingItem ? '更新' : '添加')}</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} 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>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
</AnimatePresence>, document.body
)}
{createPortal(
@@ -361,36 +503,26 @@ 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 }}
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="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>
<h3 className="text-xl font-black text-slate-900">AI生成题目</h3>
</div>
<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>
<h3 className="text-xl font-black text-slate-900">{t('aiGenerateTitle')}</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>
</div>
<div className="p-8 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"><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} />
</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 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>
<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>
<p className="text-[10px] text-slate-400 px-1"></p>
<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={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">
{generating ? <><Loader2 size={16} className="animate-spin" /> ...</> : <><Sparkles size={16} /> </>}</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} 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>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
</AnimatePresence>, document.body
)}
</div>
);
}
}
+275 -179
View File
@@ -1,16 +1,19 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
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 { templateService } from '../../services/templateService';
import { questionBankService } from '../../services/questionBankService';
import { AssessmentTemplate } from '../../types';
import { useToast } from '../../contexts/ToastContext';
import { useConfirm } from '../../contexts/ConfirmContext';
import { useLanguage } from '../../contexts/LanguageContext';
interface QuestionBankViewProps {
isAdmin?: boolean;
}
interface QuestionBank {
interface QuestionBankItem {
id: string;
name: string;
description?: string;
@@ -19,25 +22,27 @@ interface QuestionBank {
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 [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 [error, setError] = useState('');
const [showDrawer, setShowDrawer] = useState(false);
const [formData, setFormData] = useState({
name: '',
description: '',
templateId: ''
});
const [formData, setFormData] = useState({ name: '', description: '', templateId: '' });
const [saving, setSaving] = useState(false);
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
fetchData();
}, []);
useEffect(() => { fetchData(); }, []);
const fetchData = async () => {
try {
@@ -47,7 +52,8 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
const data = await res.json();
setBanks(Array.isArray(data) ? data : (data.data || []));
} catch (err: any) {
setError(err.message || '加载失败');
setError(err.message || t('actionFailed'));
showError(err.message || t('actionFailed'));
} finally {
setLoading(false);
}
@@ -58,7 +64,7 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
setLoadingTemplates(true);
templateService.getAll()
.then(data => setTemplates(data))
.catch(err => console.error('加载模板失败:', err))
.catch(() => showError(t('actionFailed')))
.finally(() => setLoadingTemplates(false));
setShowDrawer(true);
};
@@ -66,17 +72,10 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) return;
setSaving(true);
try {
const payload: any = {
name: formData.name,
description: formData.description,
};
if (formData.templateId) {
payload.templateId = formData.templateId;
}
const payload: any = { name: formData.name, description: formData.description };
if (formData.templateId) payload.templateId = formData.templateId;
const res = await apiClient.request('/question-banks', {
method: 'POST',
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 {}
throw new Error(msg);
}
setShowDrawer(false);
showSuccess(t('questionBankCreated'));
fetchData();
} catch (err: any) {
console.error('创建失败:', err);
alert('创建失败: ' + (err.message || '未知错误'));
showError(err.message || t('actionFailed'));
} finally {
setSaving(false);
}
@@ -101,182 +99,280 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
const handleDelete = async (e: React.MouseEvent, bankId: string, bankName: string) => {
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);
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();
} catch (err: any) {
console.error('删除失败:', err);
alert('删除失败: ' + (err.message || '未知错误'));
showError(err.message || t('questionBankDeleteFailed'));
} finally {
setDeletingId(null);
}
};
const handleCardClick = (bank: QuestionBank) => {
const handleCardClick = (bank: QuestionBankItem) => {
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 (
<div className="p-6 bg-white min-h-screen">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold"></h1>
<button
<div className="space-y-6 overflow-y-auto h-full">
<div className="flex items-center justify-between">
<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
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} />
<span></span>
{t('createQuestionBank')}
</button>
</div>
{loading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : error ? (
<div className="text-center py-8 text-red-500">: {error}</div>
) : banks.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<BookOpen size={48} className="mx-auto mb-4 text-gray-300" />
<p></p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{banks.map((bank) => (
<div
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>
{!loading && !error && banks.length > 0 && (
<div className="grid grid-cols-4 gap-4">
{[
{ label: t('totalBanks'), value: stats.total, color: 'bg-slate-50 border-slate-200 text-slate-700', icon: <Layers size={16} className="text-slate-500" /> },
{ 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" /> },
{ label: t('draft'), value: stats.draft, color: 'bg-slate-50 border-slate-200 text-slate-700', icon: <FileText size={16} className="text-slate-500" /> },
{ 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" /> },
].map((stat, i) => (
<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="flex items-center justify-between mb-1">
<span className="text-[10px] font-black uppercase tracking-widest opacity-70">{stat.label}</span>
{stat.icon}
</div>
<h3 className="font-semibold pr-16">{bank.name}</h3>
<p className="text-sm text-gray-500 mt-1">{bank.description || '暂无描述'}</p>
<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 className="text-2xl font-black">{stat.value}</div>
</motion.div>
))}
</div>
)}
{/* Drawer */}
<>
{showDrawer && (
<div
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 transition-opacity duration-300"
onClick={() => setShowDrawer(false)}
/>
)}
<div
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'}`}
>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-6 py-4 border-b bg-slate-50">
<h2 className="text-xl font-semibold text-slate-800 flex items-center gap-2">
<Plus className="w-6 h-6 text-blue-600" />
</h2>
<button
onClick={() => setShowDrawer(false)}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-200 rounded-full transition-colors"
>
<ChevronRight size={24} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<form id="create-form" onSubmit={handleCreate} className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
<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-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>
{!loading && !error && banks.length > 0 && (
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('searchQuestionBanksPlaceholder')}
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>
<div className="flex gap-1 bg-slate-50 rounded-2xl p-1 border border-slate-200">
{STATUS_TABS.map((tab) => {
const active = statusFilter === tab.key;
return (
<button
key={tab.key}
onClick={() => setStatusFilter(tab.key)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold transition-all ${
active
? 'bg-white text-slate-900 shadow-sm border border-slate-200/50'
: 'text-slate-500 hover:text-slate-700'
}`}
>
{tab.icon}
{tab.label}
<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`}>
{tab.count(banks)}
</span>
</button>
);
})}
</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>
);
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ class ApiClient {
headers['Authorization'] = `Bearer ${token}`;
}
if (activeTenantId && activeTenantId !== 'undefined' && activeTenantId !== 'null') {
if (activeTenantId && activeTenantId !== 'undefined' && activeTenantId !== 'null' && activeTenantId !== 'default') {
headers['x-tenant-id'] = activeTenantId;
}
+10 -2
View File
@@ -26,6 +26,9 @@ export interface AssessmentState {
status?: 'IN_PROGRESS' | 'COMPLETED';
report?: string;
finalScore?: number;
passed?: boolean;
dimensionScores?: Record<string, number>;
radarData?: Record<string, number>;
}
export interface Certificate {
@@ -139,8 +142,13 @@ export class AssessmentService {
return data;
}
async exportPdf(sessionId: string): Promise<{ filename: string; content: string }> {
const { data } = await apiClient.get<{ filename: string; content: string }>(`/assessment/${sessionId}/export/pdf`);
async exportPdf(sessionId: string): Promise<{ filename: string; buffer: string }> {
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;
}
+2
View File
@@ -17,6 +17,8 @@ export interface QuestionBankItem {
questionType: 'SHORT_ANSWER' | 'MULTIPLE_CHOICE' | 'TRUE_FALSE';
options?: string[] | null;
correctAnswer?: string | null;
judgment?: string | null;
followupHints?: string[] | null;
keyPoints: string[];
difficulty: 'STANDARD' | 'ADVANCED' | 'SPECIALIST';
dimension: 'PROMPT' | 'LLM' | 'IDE' | 'DEV_PATTERN' | 'WORK_CAPABILITY';
+14
View File
@@ -332,6 +332,12 @@ export interface TenantMember {
}
// Assessment Template Types
export interface AssessmentDimension {
name: string;
label: string;
weight: number;
}
export interface AssessmentTemplate {
id: string;
name: string;
@@ -343,6 +349,10 @@ export interface AssessmentTemplate {
knowledgeBaseId?: string;
knowledgeGroupId?: string;
knowledgeGroup?: KnowledgeGroup;
dimensions?: AssessmentDimension[];
passingScore?: number;
totalTimeLimit?: number;
perQuestionTimeLimit?: number;
isActive: boolean;
version: number;
creatorId: string;
@@ -359,6 +369,10 @@ export interface CreateTemplateData {
style?: string;
knowledgeBaseId?: string;
knowledgeGroupId?: string;
dimensions?: AssessmentDimension[];
passingScore?: number;
totalTimeLimit?: number;
perQuestionTimeLimit?: number;
}
export interface UpdateTemplateData extends Partial<CreateTemplateData> {
+232 -3
View File
@@ -636,6 +636,12 @@ export const translations = {
style: "风格要求",
createTemplate: "创建模板",
editTemplate: "编辑模板",
templateDimensions: "评估维度",
dimensionName: "维度名称",
dimensionLabel: "维度标签",
dimensionWeight: "权重",
addDimension: "添加维度",
removeDimension: "删除",
allNotes: "所有笔记",
filterNotesPlaceholder: "筛选笔记...",
@@ -813,7 +819,7 @@ export const translations = {
questionBasis: "出题依据",
viewBasis: "查看依据",
hideBasis: "隐藏依据",
verified: "已验证",
verified: "合格",
fail: "失败",
comprehensiveMasteryReport: "综合能力报告",
newAssessmentSession: "新评测会话",
@@ -828,6 +834,8 @@ export const translations = {
deleteAssessmentSuccess: "评测记录已成功删除",
deleteAssessmentFailed: '删除评估记录失败',
view: '查看',
exportAssessmentFailed: '导出评估报告失败',
cannotResumeInProgress: '此评估进行中,无法恢复查看',
// Plugins
pluginTitle: "插件中心",
@@ -933,6 +941,74 @@ export const translations = {
allFormats: "所有格式支持",
visualVision: "视觉识别",
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: {
aiCommandsError: "An error occurred",
@@ -1573,6 +1649,12 @@ export const translations = {
style: "Style Requirements",
createTemplate: "Create Template",
editTemplate: "Edit Template",
templateDimensions: "Evaluation Dimensions",
dimensionName: "Dimension Name",
dimensionLabel: "Label",
dimensionWeight: "Weight",
addDimension: "Add Dimension",
removeDimension: "Remove",
allNotes: "All Notes",
filterNotesPlaceholder: "Filter notes...",
@@ -1750,7 +1832,7 @@ export const translations = {
questionBasis: "Question Basis",
viewBasis: "View Basis",
hideBasis: "Hide Basis",
verified: "Verified",
verified: "Qualified",
fail: "Fail",
comprehensiveMasteryReport: "Comprehensive Mastery Report",
newAssessmentSession: "New Assessment Session",
@@ -1765,6 +1847,8 @@ export const translations = {
deleteAssessmentSuccess: "Assessment record deleted successfully",
deleteAssessmentFailed: 'Failed to delete assessment record',
view: 'View',
exportAssessmentFailed: 'Failed to export assessment report',
cannotResumeInProgress: 'Assessment in progress, cannot view',
// Plugins
pluginTitle: "Plugin Store",
@@ -1877,6 +1961,74 @@ export const translations = {
allFormats: "All Formats Supported",
visualVision: "Visual Recognition",
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: {
aiCommandsError: "エラーが発生しました",
@@ -2610,6 +2762,13 @@ export const translations = {
style: "スタイル要件",
createTemplate: "テンプレートを作成",
editTemplate: "テンプレートを編集",
templateDimensions: "評価ディメンション",
dimensionName: "ディメンション名",
dimensionLabel: "ラベル",
dimensionWeight: "重み",
addDimension: "ディメンションを追加",
removeDimension: "削除",
allNotes: "すべてのノート",
filterNotesPlaceholder: "ノートをフィルタリング...",
startWritingPlaceholder: "書き始める...",
@@ -2688,7 +2847,7 @@ export const translations = {
questionBasis: "出題の根拠",
viewBasis: "根拠を表示",
hideBasis: "根拠を非表示",
verified: "検証済み",
verified: "合格",
fail: "失敗",
comprehensiveMasteryReport: "包括的習熟度レポート",
newAssessmentSession: "新しいアセスメントセッション",
@@ -2703,6 +2862,8 @@ export const translations = {
deleteAssessmentSuccess: "評価記録が正常に削除されました",
deleteAssessmentFailed: 'アセスメント記録の削除に失敗しました',
view: '表示',
exportAssessmentFailed: '評価レポートのエクスポートに失敗しました',
cannotResumeInProgress: '評価進行中、表示できません',
// Plugins
pluginTitle: "プラグインストア",
@@ -2817,5 +2978,73 @@ export const translations = {
allFormats: "すべてのフォーマット対応",
visualVision: "視覚認識",
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: "この問題バンクを再公開しますか?",
},
};