Files
aurak/exam-organizer.mjs
T
Developer 1aee7e0baf 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>
2026-06-09 11:24:32 +08:00

327 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 🎓 考试组织者脚本
*
* 场景: 我是考试组织者
* 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);