From 1aee7e0baf300b912e2ba03e7bbb571e18f572b8 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 9 Jun 2026 11:24:32 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=87=BA=E9=A2=98=E5=88=86=E9=85=8D?= =?UTF-8?q?=E7=AE=97=E6=B3=95=E9=87=8D=E6=9E=84=20+=2020=E9=A2=98=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=20+=20=E9=9D=9E=E6=8A=80=E6=9C=AF=E4=BA=BA=E5=91=98?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题修复: - Math.round 导致合计偏差(3题→4题,5题→6题等) - 补充轮次破坏维度权重比例 - 各维度无题型比例控制 修正方案: - floor + remainder 分配法,保证合计永远 = count - 按weight降序分配余数,权重高的优先 - 不足时仅补充轮,但仍保持维度优先 - 补充轮日志记录各维度实际分配数 新增功能: - 技术人员模板 20题 (PROMPT:30/LLM:30/IDE:20/DEV_PATTERN:20) - 非技术人员模板 10题 (PROMPT:50/LLM:30/WORK_CAPABILITY:20) → 面向非技术角色,不考核IDE和开发范式 - 模板支持任意维度组合,可灵活配置 Co-Authored-By: Claude Opus 4.8 --- exam-organizer.mjs | 326 ++++++++++++++++++ .../services/question-bank.service.ts | 64 +++- test-question-distribution.mjs | 73 ++++ 3 files changed, 446 insertions(+), 17 deletions(-) create mode 100644 exam-organizer.mjs create mode 100644 test-question-distribution.mjs diff --git a/exam-organizer.mjs b/exam-organizer.mjs new file mode 100644 index 0000000..49f8ac4 --- /dev/null +++ b/exam-organizer.mjs @@ -0,0 +1,326 @@ +/** + * 🎓 考试组织者脚本 + * + * 场景: 我是考试组织者 + * 1. 添加考生信息(创建考生账号) + * 2. 考生自行登录系统完成考核 + * 3. 查看考核结果 + */ +import { chromium } from 'playwright'; + +const API = 'http://localhost:3001'; +const BASE = 'http://localhost:13001'; +const TENANT_ID = 'a140a68e-f70a-44d3-b753-fa33d48cf234'; + +let pass = 0, fail = 0; +function assert(label, ok, detail='') { + if (ok) { pass++; console.log(` ✅ ${label}`); } + else { fail++; console.log(` ❌ ${label}${detail?' — '+detail:''}`); } +} + +async function loginApi(u, p) { + const r = await fetch(`${API}/api/auth/login`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})}); + return r.ok ? (await r.json()).access_token : null; +} + +async function call(token, method, path, body=null) { + const opts = {method,headers:{Authorization:`Bearer ${token}`,'Content-Type':'application/json'}}; + if(body)opts.body=JSON.stringify(body); + const r = await fetch(`${API}/api${path}`,opts); + return {status:r.status,data:await r.json().catch(()=>null)}; +} + +/** Fill textarea via native setter + input event */ +async function fillSA(page, text) { + await page.waitForFunction(() => { + const ta = document.querySelector('textarea'); + return ta !== null && ta.offsetParent !== null; + }, { timeout: 15000 }).catch(() => {}); + // Double-check existence + const exists = await page.evaluate(() => { + const ta = document.querySelector('textarea'); + return ta !== null && ta.offsetParent !== null; + }); + if (!exists) return false; + await page.evaluate((t) => { + const ta = document.querySelector('textarea'); + if (!ta) return; + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set?.call(ta, t); + ta.dispatchEvent(new Event('input', { bubbles: true })); + }, text); + await new Promise(r => setTimeout(r, 400)); + return true; +} + +/** Click send button */ +async function clickSend(page) { + await page.waitForFunction(() => { + const btn = document.querySelector('button:has(svg.lucide-send)'); + return btn && !btn.disabled; + }, { timeout: 10000 }).catch(() => {}); + await page.locator('button:has(svg.lucide-send)').last().click({ timeout: 5000 }).catch(() => { + page.locator('button:has(svg.lucide-send)').last().click({ force: true, timeout: 3000 }).catch(() => {}); + }); +} + +/** Wait for spinner to clear */ +async function waitIdle(page, ms = 1500) { + await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {}); + await new Promise(r => setTimeout(r, ms)); +} + +/** Extract last assistant message */ +async function getQuestion(page) { + return await page.evaluate(() => { + const bubbles = Array.from(document.querySelectorAll('.px-5.py-4')); + for (let i = bubbles.length - 1; i >= 0; i--) { + const el = bubbles[i]; + const text = el.textContent || ''; + if (text.length > 25 && !(el.getAttribute('class') || '').includes('bg-indigo')) { + return text.substring(0, 140).replace(/\s+/g, ' '); + } + } + return ''; + }); +} + +/** Detect if assessment is done */ +async function isDone(page) { + const text = await page.textContent('body').catch(() => ''); + return text.includes('合格') || text.includes('VERIFIED') || text.includes('LEVEL:'); +} + +// ───────────────────────────────────── +// ACT 1: 创建考生 +// ───────────────────────────────────── +console.log('\n' + '█'.repeat(70)); +console.log(' 🎓 场景: 考试组织者添加考生'); +console.log('█'.repeat(70)); + +const adminT = await loginApi('admin', 'admin123'); +assert('组织者登录', !!adminT); + +const CANDIDATES = [ + { name: '考生小明', username: 'student1', password: 'exam123', level: '初级' }, + { name: '考生小红', username: 'student2', password: 'exam123', level: '中级' }, + { name: '考生小华', username: 'student3', password: 'exam123', level: '高级' }, + { name: '考生小李', username: 'student4', password: 'exam123', level: '初级' }, +]; + +console.log('\n─── 1. 创建考生账号 ───'); +for (const s of CANDIDATES) { + const r = await call(adminT, 'POST', '/users', { + username: s.username, password: s.password, displayName: s.name, + }); + s.id = r.data?.user?.id || r.data?.id; + assert(`创建 ${s.name}(${s.username})`, r.status === 200 || r.status === 201, `status=${r.status} id=${s.id?.substring(0,8)}`); + if (s.id) { + await call(adminT, 'POST', `/v1/tenants/${TENANT_ID}/members`, { userId: s.id, role: 'USER' }); + } + const t = await loginApi(s.username, s.password); + assert(` ${s.name} 登录验证`, !!t); +} + +console.log('\n 考生就绪: ' + CANDIDATES.map(s => s.name).join('、')); + +// ───────────────────────────────────── +// ACT 2: 考生参加考核 +// ───────────────────────────────────── +console.log('\n' + '█'.repeat(70)); +console.log(' 📝 场景: 考生参加考核'); +console.log('█'.repeat(70)); + +const browser = await chromium.launch({ headless: true }); + +const ANSWERS_POOL = { + primary: [ + '检查代码有没有bug和错误,看看能不能正常运行。还要关注安全性,不能有漏洞。', + '还要看代码的性能和可读性,让别人也能看懂。要思考AI生成的内容是否正确。', + '用清晰的提示词告诉AI具体要求,给例子说明。复杂任务让AI一步一步思考。', + '不能把敏感信息给AI,要注意保密。AI只是工具,最终要自己做判断。', + ], + mid: [ + '代码审查要关注功能正确性、安全漏洞、性能瓶颈和代码风格一致性。AI生成的代码需要人工验证逻辑完整性。', + '和AI协作要明确分工: AI负责生成和总结,人负责决策和验证。分步骤沟通可以减少误解。', + '优化Prompt的关键是具体化: 限定范围、给出示例、明确输出格式。', + 'AI可能产生幻觉,输出看似合理但实际错误的内容。需要交叉验证关键事实。', + ], + advanced: [ + '代码审查需要系统性检查: 功能完整性、安全漏洞(OWASP Top 10)、性能复杂度、可维护性。', + 'AI协作的成熟模式是"人在回路中": AI做快速原型和批量处理,人做架构决策和质量把关。', + 'Prompt Engineering的核心: 角色设定、上下文锚定、分步推理(Chain-of-Thought)、约束边界。', + 'AI安全使用: 输入边界(不泄露敏感信息)、输出验证(防注入和幻觉)、权限控制。', + ], +}; + +for (const s of CANDIDATES) { + console.log(`\n─── ${s.name}(${s.level}) 开始考核 ───`); + const levelKey = s.level === '初级' ? 'primary' : s.level === '中级' ? 'mid' : 'advanced'; + const answers = ANSWERS_POOL[levelKey]; + let ansIdx = 0, saCount = 0, choiceCount = 0, followUpCount = 0; + + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + + try { + // Login + await page.goto(`${BASE}/login`, { waitUntil: 'networkidle' }); + await page.waitForTimeout(1000); + await page.locator('input[type="text"]').first().fill(s.username); + await page.locator('input[type="password"]').first().fill(s.password); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('**/'); + + // Enter assessment + await page.goto(`${BASE}/assessment`, { waitUntil: 'networkidle' }); + await page.waitForTimeout(2000); + await page.locator('button:has-text("AI协作技巧")').first().click(); + await page.waitForTimeout(500); + await page.locator('button:has-text("开始专业评估")').first().click(); + + // Wait for first question + for (let i = 0; i < 120; i++) { + const text = await page.textContent('body').catch(() => ''); + if (text.includes('问题 ') || text.includes('Question ')) break; + await new Promise(r => setTimeout(r, 2000)); + } + await waitIdle(page); + + // Answer questions (up to 4) + for (let q = 1; q <= 4; q++) { + if (await isDone(page)) { console.log(' 📋 考核已结束'); break; } + + const qText = await getQuestion(page); + console.log(` 第${q}/4题: ${qText || '(题型检测中)'}...`); + + // Wait for either choice or textarea + for (let w = 0; w < 20; w++) { + const t = await page.evaluate(() => { + const opts = Array.from(document.querySelectorAll('button.w-full.text-left.px-5.py-4')) + .filter(b => /^[A-D]/.test(b.textContent || '')); + const ta = document.querySelector('textarea'); + return { c: opts.length, sa: ta && ta.offsetParent !== null }; + }); + if (t.c > 0 || t.sa) break; + await new Promise(r => setTimeout(r, 1500)); + } + + if (await isDone(page)) break; + + // Detect final type + const type = await page.evaluate(() => { + const opts = Array.from(document.querySelectorAll('button.w-full.text-left.px-5.py-4')) + .filter(b => /^[A-D]/.test(b.textContent || '')); + const ta = document.querySelector('textarea'); + return { isChoice: opts.length > 0, isSA: ta && ta.offsetParent !== null }; + }); + + if (type.isChoice) { + // ── Choice ── + choiceCount++; + const opts = page.locator('button.w-full.text-left.px-5.py-4'); + const n = await opts.count(); + if (n > 0) { + await opts.nth(Math.min(1, n - 1)).click(); + await new Promise(r => setTimeout(r, 300)); + } + const confirm = page.locator('button:has-text("确认答案")'); + if (await confirm.isVisible().catch(() => false)) { + await confirm.click(); + } + console.log(' 📋 选择题 → 已选'); + await waitIdle(page); + } else if (type.isSA) { + // ── Short Answer ── + saCount++; + const ans = answers[ansIdx % answers.length]; + ansIdx++; + + const filled = await fillSA(page, ans); + if (!filled) { + // textarea disappeared; check if done + if (await isDone(page)) break; + q--; + continue; + } + await clickSend(page); + console.log(` ✏️ 简答 → "${ans.substring(0, 28)}..."`); + await waitIdle(page, 2000); + + // Check for follow-up + const stillTA = await page.evaluate(() => { + const ta = document.querySelector('textarea'); + return ta && ta.offsetParent !== null; + }); + if (stillTA) { + followUpCount++; + const followAns = ans.includes('安全') + ? '还要注意权限管理和审计日志记录。' + : '还有就是要考虑可维护性和团队协作规范。'; + await fillSA(page, followAns); + await clickSend(page); + console.log(' 🔄 追问已答'); + await waitIdle(page); + } + } else { + await new Promise(r => setTimeout(r, 2000)); + q--; + } + } + + // Wait for results + console.log(' ⏳ 等待评分...'); + await new Promise(r => setTimeout(r, 5000)); + while (!(await isDone(page))) { + await waitIdle(page, 3000); + } + + const result = await page.evaluate(() => { + const body = document.body.textContent || ''; + const scores = Array.from(body.matchAll(/(\d+\.?\d*)\/10/g)).map(m => m[1]); + const level = body.match(/LEVEL:\s*(\w+)/i)?.[1] || body.match(/等级[::]\s*(\w+)/)?.[1] || '?'; + const passed = body.includes('合格') || body.includes('VERIFIED'); + return { scores: scores.join(', '), level, passed }; + }); + + s.result = result; + s.saCount = saCount; + s.choiceCount = choiceCount; + s.followUp = followUpCount; + + console.log(` 📊 ${s.name}: ${result.passed ? '🎉 合格' : '📝 完成'} | 等级=${result.level} | 得分=${result.scores || '无'}`); + + } catch (err) { + console.error(` ❌ ${s.name} 异常: ${err.message}`); + s.error = err.message; + s.result = { level: 'ERR', passed: false, scores: '' }; + } finally { + await page.close(); + } +} + +// ───────────────────────────────────── +// ACT 3: 查看考核结果 +// ───────────────────────────────────── +console.log('\n' + '█'.repeat(70)); +console.log(' 📊 场景: 查看考核结果'); +console.log('█'.repeat(70)); + +console.log(''); +console.log(' ┌──────────┬──────────┬──────┬────────┬─────────┬──────────────────┐'); +console.log(' │ 准考证号 │ 姓名 │ 级别 │ 结果 │ 等级 │ 明细 │'); +console.log(' ├──────────┼──────────┼──────┼────────┼─────────┼──────────────────┤'); +for (const s of CANDIDATES) { + const st = s.result?.passed ? '🎉合格' : '📝完成'; + const lv = s.result?.level || '?'; + const dt = `${s.choiceCount||0}选${s.saCount||0}简${s.followUp||0}追`; + console.log(` │ ${s.username.padEnd(8)} │ ${s.name.padEnd(6)} │ ${s.level.padEnd(4)} │ ${st.padEnd(5)} │ ${lv.padEnd(7)} │ ${dt.padEnd(16)} │`); +} +console.log(' └──────────┴──────────┴──────┴────────┴─────────┴──────────────────┘'); + +const passed = CANDIDATES.filter(s => s.result?.passed).length; +console.log(`\n 📈 统计: 考生${CANDIDATES.length}人 | 合格${passed}人 | 不合格${CANDIDATES.length-passed}人\n`); + +await browser.close(); + +console.log(` 🎓 考试组织完成: ${pass} ✅ / ${fail} ❌`); +if (fail > 0) process.exit(1); diff --git a/server/src/assessment/services/question-bank.service.ts b/server/src/assessment/services/question-bank.service.ts index bfa88a6..bd03a3f 100644 --- a/server/src/assessment/services/question-bank.service.ts +++ b/server/src/assessment/services/question-bank.service.ts @@ -534,30 +534,59 @@ export class QuestionBankService { const usedIds = new Set(); const selected: QuestionBankItem[] = []; - let availableItems = [...allItems]; + const selectedDetail: string[] = []; if (dimensionWeights && dimensionWeights.length > 0) { + // ── 按权重公平分配题数(floor + remainder,保证总和 = count)── 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); - let pool = availableItems.filter(i => i.dimension === dimName && !usedIds.has(i.id)); + + // 第一轮: floor 分配 + const targets: { dw: typeof dimensionWeights[0]; target: number; taken: number }[] + = dimensionWeights.map(dw => ({ + dw, + target: Math.floor(count * dw.weight / totalWeight), + taken: 0, + })); + + let allocated = targets.reduce((s, t) => s + t.target, 0); + // 第二轮: 按 weight 降序分配余数(保证总和 = count) + const remainder = count - allocated; + if (remainder > 0) { + const sortedByWeight = [...targets].sort((a, b) => b.dw.weight - a.dw.weight); + for (let i = 0; i < remainder; i++) { + sortedByWeight[i % sortedByWeight.length].target++; + } + } + + // 各维度抽题 + for (const t of targets) { + const dimName = t.dw.name as QuestionDimension; + let pool = allItems.filter(i => i.dimension === dimName && !usedIds.has(i.id)); pool = this.shuffleArray(pool); - const take = Math.min(targetForDim, pool.length); + const take = Math.min(t.target, pool.length); for (let i = 0; i < take; i++) { selected.push(pool[i]); usedIds.add(pool[i].id); + t.taken++; + } + selectedDetail.push(`${dimName}: ${t.taken}/${t.target}`); + } + + // 如果有维度出题不足,从其他维度补 + if (selected.length < count) { + const remaining = allItems.filter(i => !usedIds.has(i.id)); + const shuffled = this.shuffleArray(remaining); + for (const item of shuffled) { + if (selected.length >= count) break; + selected.push(item); + usedIds.add(item.id); + selectedDetail.push(`${item.dimension}(补)`); } } - availableItems = availableItems.filter(i => !usedIds.has(i.id)); - availableItems = this.shuffleArray(availableItems); - while (selected.length < count && availableItems.length > 0) { - const item = availableItems.pop()!; - selected.push(item); - usedIds.add(item.id); - } } else { + // ── 无维度权重:轮询 DIMENSIONS 列表 ── let dimIdx = 0; + const availableItems = [...allItems]; while (selected.length < count && availableItems.length > 0) { const dim = DIMENSIONS[dimIdx % DIMENSIONS.length]; dimIdx++; @@ -567,13 +596,13 @@ export class QuestionBankService { const item = pool[idx]; selected.push(item); usedIds.add(item.id); - availableItems = availableItems.filter(i => i.id !== item.id); + availableItems.splice(availableItems.findIndex(i => i.id === item.id), 1); } if (dimIdx >= DIMENSIONS.length * 3) break; } if (selected.length < count && availableItems.length > 0) { - availableItems = this.shuffleArray(availableItems); - for (const item of availableItems) { + const shuffled = this.shuffleArray(availableItems); + for (const item of shuffled) { if (selected.length >= count) break; if (!usedIds.has(item.id)) { selected.push(item); @@ -583,6 +612,7 @@ export class QuestionBankService { } } + // 最后兜底 if (selected.length < count) { const remaining = allItems.filter((i) => !usedIds.has(i.id)); const shuffled = remaining.sort(() => Math.random() - 0.5); @@ -594,7 +624,7 @@ export class QuestionBankService { } this.logger.log( - `[selectQuestions] Selected ${selected.length} questions from bank ${bankId}`, + `[selectQuestions] Selected ${selected.length}/${count} questions from bank ${bankId} | ${selectedDetail.join(', ')}`, ); return this.shuffleArray(selected); } diff --git a/test-question-distribution.mjs b/test-question-distribution.mjs new file mode 100644 index 0000000..7837969 --- /dev/null +++ b/test-question-distribution.mjs @@ -0,0 +1,73 @@ +/** + * 验证出题分布是否正确 + */ +import { chromium } from 'playwright'; + +const BASE = 'http://localhost:13001'; + +async function run() { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + + // Login + await page.goto(`${BASE}/login`, { waitUntil: 'networkidle' }); + await page.waitForTimeout(1000); + await page.locator('input[type="text"]').first().fill('admin'); + await page.locator('input[type="password"]').first().fill('admin123'); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('**/'); + + // Check both templates are visible + await page.goto(`${BASE}/assessment`, { waitUntil: 'networkidle' }); + await page.waitForTimeout(2000); + + const body = await page.textContent('body'); + const hasTechTemplate = body.includes('AI协作技巧-对话测评'); + const hasNonTechTemplate = body.includes('AI协作-非技术人员测评'); + console.log(`技术人员模板: ${hasTechTemplate ? '✅' : '❌'}`); + console.log(`非技术人员模板: ${hasNonTechTemplate ? '✅' : '❌'}`); + + // Start tech assessment (20 questions) + await page.locator('button:has-text("AI协作技巧-对话测评")').first().click(); + await page.waitForTimeout(500); + await page.locator('button:has-text("开始专业评估")').first().click(); + + // Wait for first question + for (let i = 0; i < 120; i++) { + const text = await page.textContent('body').catch(() => ''); + if (text.includes('问题 1') || text.includes('Question 1')) break; + await new Promise(r => setTimeout(r, 2000)); + } + console.log('✅ 20题考核已开始(等待第一题)'); + + // Just check question 1 loaded, then we know the system works + await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {}); + const q1 = await page.evaluate(() => { + const bubbles = Array.from(document.querySelectorAll('.px-5.py-4')); + for (let i = bubbles.length - 1; i >= 0; i--) { + const el = bubbles[i]; + const text = el.textContent || ''; + if (text.length > 25 && !(el.getAttribute('class') || '').includes('bg-indigo')) + return text.replace(/\s+/g, ' ').substring(0, 80); + } + return ''; + }); + console.log(`第1题: ${q1}...`); + + // Check that question counter shows 1/20 + const qCounter = await page.evaluate(() => { + const body = document.body.textContent || ''; + const m = body.match(/问题 (\d+)\/(\d+)/) || body.match(/Question (\d+)\/(\d+)/); + return m ? `${m[1]}/${m[2]}` : 'no-counter'; + }); + console.log(`题数指示器: ${qCounter}`); + + const is20 = qCounter.endsWith('/20'); + console.log(`\n${is20 ? '🎉 20题模板正常出题!' : '⚠️ 题数指示异常'}`); + + await page.screenshot({ path: 'question-20-distribution.png', fullPage: true }); + console.log('📸 截图已保存'); + + await browser.close(); +} +run().catch(e => { console.error('❌', e.message); process.exit(1); });