fix: 出题分配算法重构 + 20题模板 + 非技术人员模板

问题修复:
- 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 <noreply@anthropic.com>
This commit is contained in:
Developer
2026-06-09 11:24:32 +08:00
parent c166d298b8
commit 1aee7e0baf
3 changed files with 446 additions and 17 deletions
+326
View File
@@ -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);
@@ -534,30 +534,59 @@ export class QuestionBankService {
const usedIds = new Set<string>();
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);
}
+73
View File
@@ -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); });