diff --git a/CLAUDE.md b/CLAUDE.md index a9e79b3..6b86828 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -179,6 +179,28 @@ docker-compose up -d # Builds and starts all services - Tika: 9998 - LibreOffice: 8100 +## AI 工作流指令 + +本项目已安装 **gstack**(54 个技能)和 **Superpowers**(14 个技能)。请按以下规则协调使用: + +### 自动触发规则 +当用户的意图匹配以下场景时,**自动调用对应的 gstack skill**(使用 Skill 工具),并在调用前向用户说明启动了哪个技能: + +- 用户讨论需求、想法、产品方向 → 调用 `office-hours` skill,说"正在启动 **office-hours**(产品策略顾问)..." +- 用户讨论功能范围、优先级 → 调用 `plan-ceo-review` skill,说"正在启动 **plan-ceo-review**(战略评审)..." +- 用户讨论技术方案、架构设计 → 调用 `plan-eng-review` skill,说"正在启动 **plan-eng-review**(架构评审)..." +- 用户要求审查代码 → 调用 `review` skill,说"正在启动 **review**(代码审查)..." +- 用户要求测试/QA → 调用 `qa` skill,说"正在启动 **qa**(自动化测试)..." +- 用户要求安全审查 → 调用 `cso` skill,说"正在启动 **cso**(安全审计)..." +- 用户要求发布/发版 → 调用 `ship` skill,说"正在启动 **ship**(发布流程)..." +- 用户报告 bug 需要调试 → 调用 `investigate` skill,说"正在启动 **investigate**(系统化调试)..." + +### Superpowers 保留自动触发 +Superpowers 的技能(brainstorming、test-driven-development、systematic-debugging 等)继续保持原有自动触发机制,不做干预。 + +### 通知机制 +每次自动调用 gstack skill 时,必须明确告知用户正在启动哪个技能及其作用。 + ## Troubleshooting ### Common Issues diff --git a/check-result.mjs b/check-result.mjs new file mode 100644 index 0000000..7045c9c --- /dev/null +++ b/check-result.mjs @@ -0,0 +1,156 @@ +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 } }); + + // 登录 + 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('**/'); + + // 进入考核页(首页会显示历史记录侧栏) + await page.goto(`${BASE}/assessment`, { waitUntil: 'networkidle' }); + await page.waitForTimeout(3000); + + // 截图1:首页(含历史记录侧栏) + await page.screenshot({ path: 'assessment-overview.png', fullPage: true }); + console.log('📸 1/5 首页截图(含历史侧栏)保存'); + + // 看看历史记录里有什么 + const historyInfo = await page.evaluate(() => { + const items = Array.from(document.querySelectorAll('.w-80 div.space-y-3 > div')); + return items.map(el => ({ + text: (el.textContent || '').replace(/\s+/g, ' ').trim(), + })); + }); + console.log('\n📋 历史记录:'); + historyInfo.forEach((h, i) => console.log(` [${i+1}] ${h.text}`)); + + if (historyInfo.length === 0) { + console.log(' 没有历史记录,可能是空状态'); + await browser.close(); + return; + } + + // 点击第一条历史记录查看详情(选分数最高的那条) + // 找分数最高的:解析数字 + let bestIdx = 0; + let bestScore = -1; + historyInfo.forEach((h, i) => { + const m = h.text.match(/([\d.]+)\/10/); + if (m) { + const s = parseFloat(m[1]); + if (s > bestScore) { bestScore = s; bestIdx = i; } + } + }); + console.log(`\n🔍 选择分数最高的记录 #${bestIdx+1} (${bestScore}/10)`); + + // 历史记录在右侧边栏,每条记录最右边有个查看按钮(FileText图标) + const histButtons = page.locator('.w-80 div.space-y-3 > div button'); + const btnCount = await histButtons.count(); + console.log(` 右侧历史栏共有 ${btnCount} 个按钮`); + + // 每条记录有2个按钮(删除+查看),查看按钮在最后 + // 第N条记录的查看按钮索引 = (N * 2 + 1) (从0开始) + const viewBtnIdx = bestIdx * 2 + 1; + if (viewBtnIdx < btnCount) { + await histButtons.nth(viewBtnIdx).click(); + await new Promise(r => setTimeout(r, 3000)); + + // 截图2:历史考核详情页 + await page.screenshot({ path: 'assessment-history-detail.png', fullPage: true }); + console.log('📸 2/5 考核详情页截图'); + + // 看看详情页有什么内容 + const detailInfo = await page.evaluate(() => { + const body = document.body.textContent || ''; + const scoreMatch = body.match(/([\d.]+)\/10/g); + const levelMatch = body.match(/(?:LEVEL|等级)[::]\s*(\w+)/i); + const reportSection = body.includes('综合报告') || body.includes('comprehensive'); + const detailSection = body.includes('每题详情') || body.includes('details'); + const hasPassed = body.includes('合格') || body.includes('VERIFIED'); + + // 按钮文字 + const btns = Array.from(document.querySelectorAll('button')) + .map(b => (b.textContent || '').trim()) + .filter(Boolean); + + return { scores: scoreMatch, level: levelMatch?.[1], reportSection, detailSection, hasPassed, btns }; + }); + + console.log(`\n📊 得分列表: ${detailInfo.scores?.join(', ') || '无'}`); + console.log(`🏆 等级: ${detailInfo.level || '未显示'}`); + console.log(`✅ 合格: ${detailInfo.hasPassed ? '是' : '否'}`); + console.log(`📋 每题详情: ${detailInfo.detailSection ? '✅ 有' : '❌ 无'}`); + console.log(`📝 综合报告: ${detailInfo.reportSection ? '✅ 有' : '❌ 无'}`); + console.log(`\n🔘 按钮列表:`); + detailInfo.btns.forEach(b => console.log(` - ${b}`)); + + // 找"查看证书"按钮 + const certBtnText = detailInfo.btns.find(b => + b.includes('证书') || b.includes('Certificate') || b.includes('certificate') + ); + console.log(`\n🔖 证书按钮: ${certBtnText || '没找到'}`); + + // 如果有证书按钮,点击它 + if (certBtnText) { + const certBtn = page.locator('button', { hasText: /证书|Certificate|certificate/ }); + if (await certBtn.isVisible().catch(() => false)) { + await certBtn.click(); + await new Promise(r => setTimeout(r, 2000)); + + // 截图3:证书弹窗 + await page.screenshot({ path: 'assessment-certificate-modal.png', fullPage: true }); + console.log('📸 3/5 证书弹窗截图'); + + // 读取证书弹窗内容 + const certData = await page.evaluate(() => { + // 找 portal(弹窗在 document.body 最下层) + const modal = document.querySelector('.fixed.inset-0.z-\\[1000\\]'); + if (!modal) return { found: false }; + + const text = modal.textContent || ''; + const level = text.match(/(\w+)/)?.[1] || ''; + const totalScore = text.match(/([\d.]+)\/10/)?.[1] || ''; + const dimScores = Array.from(text.matchAll(/(\w+)\s*([\d.]+)\/10/g)) + .map(m => `${m[1]}: ${m[2]}/10`); + const questionCount = text.match(/题目列表[\s\S]*?#(\d+)/)?.[1] || ''; + + return { + found: true, + text: text.substring(0, 500), + level, totalScore, dimScores, questionCount, + }; + }); + + if (certData.found) { + console.log(`\n📜 证书内容:`); + console.log(` 等级: ${certData.level}`); + console.log(` 总分: ${certData.totalScore}/10`); + console.log(` 维度得分: ${certData.dimScores.join(', ') || '无'}`); + console.log(` 题目数: ${certData.questionCount || '未知'}`); + } + + // 关掉弹窗 + await page.keyboard.press('Escape'); + await new Promise(r => setTimeout(r, 500)); + } + } + + // 看 PDF 和 Excel 导出 + const hasPdf = detailInfo.btns.some(b => b.includes('PDF')); + const hasExcel = detailInfo.btns.some(b => b.includes('Excel') || b.includes('excel')); + console.log(`\n📄 PDF下载: ${hasPdf ? '✅ 有' : '❌ 无'}`); + console.log(`📊 Excel导出: ${hasExcel ? '✅ 有' : '❌ 无'}`); + } + + await browser.close(); + console.log('\n=== 完成 ==='); +} +run().catch(e => { console.error('❌', e.message); process.exit(1); }); diff --git a/do-assessment.mjs b/do-assessment.mjs new file mode 100644 index 0000000..e09f7bf --- /dev/null +++ b/do-assessment.mjs @@ -0,0 +1,267 @@ +import { chromium } from 'playwright'; + +const BASE = 'http://localhost:13001'; + +async function waitForSpinner(page) { + await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {}); + await new Promise(r => setTimeout(r, 1000)); +} + +/** Extract the last assistant message (question text) */ +async function getLastQuestion(page) { + return await page.evaluate(() => { + // Find all message bubbles: elements with px-5 py-4 classes + const allBubbles = Array.from(document.querySelectorAll('.px-5.py-4')); + // The last one that's from assistant (white bg, not indigo) is the question + for (let i = allBubbles.length - 1; i >= 0; i--) { + const el = allBubbles[i]; + const text = el.textContent || ''; + const style = el.getAttribute('class') || ''; + // Skip user messages (indigo bg) and empty/footer text + if (style.includes('bg-indigo')) continue; + if (text.length < 20) continue; + return text.substring(0, 600); + } + return ''; + }); +} + +async function run() { + console.log('=== 🧑‍🎓 我来做题! ===\n'); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + + // 登录 + console.log('[1] 登录...'); + 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('**/'); + console.log(' ✅ 登录成功'); + + // 进入考核 + 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(); + + // 等出题 + console.log('\n[2] 等待出题...'); + 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 waitForSpinner(page); + console.log(' ✅ 题目已加载\n'); + + // 答题 + let qIdx = 1; + const totalQs = 4; + + while (qIdx <= totalQs) { + // 判断是选择题还是简答题 + const state = await page.evaluate(() => { + // 选择题选项按钮:CSS类 w-full text-left px-5 py-4 + const optionBtns = Array.from(document.querySelectorAll('button.w-full.text-left.px-5.py-4')) + .filter(b => !b.textContent?.includes('确认答案')); + // textarea 表示简答题 + const ta = document.querySelector('textarea'); + const busy = document.querySelector('.animate-spin') !== null; + // 确认答案按钮:找 button 文字包含"确认" + const confirmBtns = Array.from(document.querySelectorAll('button')) + .filter(b => (b.textContent || '').includes('确认')); + return { + optionCount: optionBtns.length, + optionTexts: optionBtns.map(b => b.textContent?.trim() || ''), + hasTextarea: ta !== null && ta.offsetParent !== null, + hasConfirmBtn: confirmBtns.length > 0, + busy, + }; + }); + + if (state.busy) { + await new Promise(r => setTimeout(r, 2000)); + continue; + } + + // 读取题目 + const question = await getLastQuestion(page); + console.log(`\n═══ 第 ${qIdx}/${totalQs} 题 ═══`); + console.log(`📖 ${question}\n`); + + if (state.optionCount > 0) { + // ── 选择题 ── + console.log('📋 选项:'); + state.optionTexts.forEach((t, i) => { + console.log(` ${String.fromCharCode(65 + i)}) ${t}`); + }); + console.log(''); + + // 凭常识选题 + const btns = page.locator('button.w-full.text-left.px-5.py-4'); + const count = await btns.count(); + + // 根据题目内容推理 + const qLower = question.toLowerCase(); + const texts = state.optionTexts.map(t => t.toLowerCase()); + let chosen = 1; // default B + + if (qLower.includes('提示词') || qLower.includes('prompt') || qLower.includes('prompts')) { + chosen = texts.findIndex(t => t.includes('清晰') || t.includes('具体') || t.includes('举例') || t.includes('角色')); + } else if (qLower.includes('安全') || qLower.includes('敏感') || qLower.includes('泄露')) { + chosen = texts.findIndex(t => t.includes('脱敏') || t.includes('敏感') || t.includes('安全')); + } else if (qLower.includes('测试') || qLower.includes('测试用例')) { + chosen = texts.findIndex(t => t.includes('测试') || t.includes('质量') || t.includes('验证')); + } else if (qLower.includes('选型') || qLower.includes('成本') || qLower.includes('模型')) { + chosen = texts.findIndex(t => t.includes('成本') || t.includes('任务') || t.includes('性价比')); + } else if (qLower.includes('代码审查') || qLower.includes('review')) { + chosen = texts.findIndex(t => t.includes('安全') || t.includes('质量') || t.includes('逻辑')); + } else if (qLower.includes('幻觉') || qLower.includes('hallucination')) { + chosen = texts.findIndex(t => t.includes('事实') || t.includes('验证') || t.includes('核对')); + } + if (chosen < 0) chosen = 1; + + await btns.nth(chosen).click(); + await new Promise(r => setTimeout(r, 500)); + console.log(` 👉 我选 ${String.fromCharCode(65 + chosen)}`); + + // 提交 + if (state.hasConfirmBtn) { + await page.locator('button:has-text("确认答案")').click(); + console.log(' ✅ 提交'); + } + + // 如果有下一题的过渡 + qIdx++; + + } else if (state.hasTextarea) { + // ── 简答题 ── + console.log('✏️ 简答题,我来回答:\n'); + + // 根据题目内容生成回答 + const qLower = question.toLowerCase(); + let answer = ''; + + if (qLower.includes('测试') || qLower.includes('测试用例') || (qLower.includes('写代码') && qLower.includes('测试'))) { + answer = '我觉得即使有AI写代码,测试还是必须写的。AI写的代码也可能有bug,需要人工验证。测试不只是找bug,还能帮我们理解代码逻辑。而且测试用例本身也是需求的一部分,能告诉我们代码应该怎么用。AI可以帮我们写测试代码,但不能完全代替人去思考测试场景。'; + } else if (qLower.includes('提示词') || qLower.includes('prompt')) { + answer = '写提示词要清晰具体,告诉AI它的角色是什么。可以给例子,让AI理解格式要求。复杂任务可以让AI一步一步思考。也要注意测试不同提示词的效果,找到最合适的。'; + } else if (qLower.includes('安全') || qLower.includes('敏感') || qLower.includes('泄露')) { + answer = '要注意不要把敏感信息发给AI,比如密码、API密钥、客户数据。如果要用真实数据,要先脱敏处理。也要检查AI生成的代码有没有安全漏洞。'; + } else if (qLower.includes('代码审查') || qLower.includes('review') || qLower.includes('代码质量')) { + answer = '代码评审要看代码的功能是否正确,有没有bug。还要看代码风格是否一致,性能好不好,有没有安全问题。AI可以帮我们做一部分检查,但最终还是需要人来做判断。'; + } else if (qLower.includes('ai协作') || qLower.includes('ai合作') || qLower.includes('协作技巧')) { + answer = '和AI协作要分工明确,AI做它擅长的(生成、总结、分析),人做判断和决策。要给AI清晰的任务描述,分步骤沟通。AI生成的内容要自己核实,不能完全相信。'; + } else { + answer = '我觉得首先要理解问题的本质,然后让AI帮忙分析。AI的输出要结合自己的经验和判断。不能完全依赖AI,要多验证AI给出的结果是否合理。安全意识和质量控制很重要。'; + } + + // 输入回答 + await page.locator('textarea').first().click(); + await page.evaluate((text) => { + const ta = document.querySelector('textarea'); + if (!ta) return; + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set; + setter?.call(ta, text); + ta.dispatchEvent(new Event('input', { bubbles: true })); + }, answer); + await new Promise(r => setTimeout(r, 800)); + console.log(` 💬 "${answer.substring(0, 50)}..."`); + + // 等 button 可用再点 + await page.waitForFunction(() => { + const btn = document.querySelector('button:has(svg.lucide-send)'); + return btn && !btn.disabled && btn.offsetParent !== null; + }, { timeout: 15000 }).catch(() => {}); + await page.locator('button:has(svg.lucide-send)').last().click({ timeout: 8000 }).catch(() => { + page.locator('button:has(svg.lucide-send)').last().click({ force: true, timeout: 5000 }).catch(() => {}); + }); + console.log(' ✅ 已提交\n'); + + // 等批改 + await waitForSpinner(page); + + // 检查是否有追问 + const stillTA = await page.evaluate(() => { + const ta = document.querySelector('textarea'); + return ta !== null && ta.offsetParent !== null; + }); + + if (stillTA) { + console.log(' 🔄 AI追问来了!再回答一轮'); + + // 读追问的题目 + const followQ = await getLastQuestion(page); + console.log(` 📖 追问: "${followQ.substring(0, 100)}..."`); + + const followAnswer = '还要看代码的可维护性和可读性,团队协作时需要统一的代码风格。也要考虑性能优化和异常处理。总之AI是工具,人是决策者,不能把责任推给AI。'; + await page.locator('textarea').first().click(); + await page.evaluate((text) => { + const ta = document.querySelector('textarea'); + if (!ta) return; + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set; + setter?.call(ta, text); + ta.dispatchEvent(new Event('input', { bubbles: true })); + }, followAnswer); + await new Promise(r => setTimeout(r, 800)); + 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(() => {}); + }); + console.log(' ✅ 追问已回答'); + await waitForSpinner(page); + } + + qIdx++; + } else { + console.log(' ⏳ 等待...'); + await new Promise(r => setTimeout(r, 3000)); + continue; + } + + // 等过渡 + await waitForSpinner(page); + } + + // 结果 + console.log('\n═══ 考核结果 ═══'); + await new Promise(r => setTimeout(r, 5000)); + await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {}); + + const result = await page.evaluate(() => { + const body = document.body.textContent || ''; + const scoreMatch = body.match(/(\d+\.?\d*)\/10/); + const levelMatch = body.match(/等级.*?(\w+)/i) || body.match(/LEVEL:\s*(\w+)/i); + const finalScoreMatch = body.match(/最终得分[::]\s*(\d+\.?\d*)/); + const passMatch = body.includes('合格') || body.includes('VERIFIED'); + const failMatch = body.includes('不合格') || body.includes('FAIL'); + // 各题得分 + const allScores = Array.from(body.matchAll(/(\d+)\/10/g)).map(m => m[1]); + return { + score: scoreMatch?.[1] || finalScoreMatch?.[1] || '?', + level: levelMatch?.[1] || '?', + passed: passMatch, + failed: failMatch, + allScores: allScores.join(', '), + }; + }); + + console.log(` 📊 各题得分: ${result.allScores || '无'}`); + console.log(` 🏆 等级: ${result.level}`); + console.log(` ${result.passed ? '🎉 合格!' : result.failed ? '😅 不合格...' : '...'}`); + + await page.screenshot({ path: 'assessment-result-beginner.png', fullPage: true }); + console.log(' 📸 截图已保存'); + + await browser.close(); + console.log('\n=== 完成 ==='); +} + +run().catch(e => { console.error('\n❌', e.message); process.exit(1); }); diff --git a/package.json b/package.json index d5ef44b..624e98f 100644 --- a/package.json +++ b/package.json @@ -11,5 +11,8 @@ }, "devDependencies": { "concurrently": "^8.2.2" + }, + "dependencies": { + "playwright": "^1.60.0" } -} \ No newline at end of file +} diff --git a/server/src/assessment/services/question-bank.service.ts b/server/src/assessment/services/question-bank.service.ts index 1bb7e9c..bfa88a6 100644 --- a/server/src/assessment/services/question-bank.service.ts +++ b/server/src/assessment/services/question-bank.service.ts @@ -541,8 +541,8 @@ export class QuestionBankService { 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); + let pool = availableItems.filter(i => i.dimension === dimName && !usedIds.has(i.id)); + pool = this.shuffleArray(pool); const take = Math.min(targetForDim, pool.length); for (let i = 0; i < take; i++) { selected.push(pool[i]); @@ -550,7 +550,7 @@ export class QuestionBankService { } } availableItems = availableItems.filter(i => !usedIds.has(i.id)); - this.shuffleArray(availableItems); + availableItems = this.shuffleArray(availableItems); while (selected.length < count && availableItems.length > 0) { const item = availableItems.pop()!; selected.push(item); @@ -572,7 +572,7 @@ export class QuestionBankService { if (dimIdx >= DIMENSIONS.length * 3) break; } if (selected.length < count && availableItems.length > 0) { - this.shuffleArray(availableItems); + availableItems = this.shuffleArray(availableItems); for (const item of availableItems) { if (selected.length >= count) break; if (!usedIds.has(item.id)) { diff --git a/test-multiround.mjs b/test-multiround.mjs new file mode 100644 index 0000000..a531cf0 --- /dev/null +++ b/test-multiround.mjs @@ -0,0 +1,187 @@ +import { chromium } from 'playwright'; + +const BASE = 'http://localhost:13001'; +const SA_REPLIES = ['需要审查代码质量和安全性', '还要检查逻辑正确性和性能问题']; + +/** Fill a textarea via native setter + input event (reliable for React controlled inputs) */ +async function fillTextarea(page, text) { + await page.evaluate((t) => { + const ta = document.querySelector('textarea'); + if (!ta) return; + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set; + setter?.call(ta, t); + ta.dispatchEvent(new Event('input', { bubbles: true })); + }, text); + await new Promise(r => setTimeout(r, 300)); +} + +/** Click the send button (wait for it to be enabled, then regular or force click) */ +async function clickSendButton(page) { + try { + await page.waitForFunction(() => { + const btn = document.querySelector('button:has(svg.lucide-send)'); + return btn && !btn.disabled && btn.offsetParent !== null; + }, { timeout: 15000 }); + await page.locator('button:has(svg.lucide-send)').last().click({ timeout: 5000 }); + } catch { + // Regular click failed (stale/detached element); wait briefly and force + await new Promise(r => setTimeout(r, 1000)); + await page.locator('button:has(svg.lucide-send)').last().click({ force: true, timeout: 5000 }).catch(() => {}); + } +} + +async function run() { + console.log('=== AuraK 考核多轮对话测试 ===\n'); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + + // Login + console.log('[1] 登录...'); + 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('**/'); + console.log(' ✅ 登录成功'); + + // Assessment page + await page.goto(`${BASE}/assessment`, { waitUntil: 'networkidle' }); + await page.waitForTimeout(2000); + + // Select template + 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 + console.log('[2] 等待出题...'); + for (let i = 0; i < 90; i++) { + const text = await page.textContent('body').catch(() => ''); + if (text.includes('问题 ') || text.includes('Question ')) break; + await new Promise(r => setTimeout(r, 2000)); + } + console.log(' ✅ 第 1 题已出现'); + + // Wait spinner to finish + await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {}); + await new Promise(r => setTimeout(r, 2000)); + + // Answer questions + let qIdx = 1; + let saCount = 0, followUpCount = 0; + const totalQs = 4; + + while (qIdx <= totalQs) { + const state = await page.evaluate(() => { + const choiceBtns = Array.from(document.querySelectorAll('button')) + .filter(b => /^[A-D]/.test(b.textContent || '') && (b.textContent || '').length > 5) + .filter(b => !(b.textContent || '').startsWith('AuraK')) + .filter(b => !(b.textContent || '').startsWith('Admin')); + + const ta = document.querySelector('textarea'); + return { + choiceCount: choiceBtns.length, + hasTextarea: ta !== null && ta.offsetParent !== null, + firstChoice: choiceBtns[0]?.textContent || '', + busy: document.querySelector('.animate-spin') !== null, + }; + }); + + if (state.busy) { + await new Promise(r => setTimeout(r, 2000)); + continue; + } + + if (state.choiceCount > 0) { + // ── CHOICE ── + console.log(`\n 🟦 第 ${qIdx}/${totalQs} 题 (选择) "${state.firstChoice.substring(0, 30)}..."`); + + const btns = page.locator('button.w-full.text-left.px-5.py-4'); + const count = await btns.count(); + if (count > 0) { + await btns.first().click(); + await new Promise(r => setTimeout(r, 500)); + } + + const confirm = page.locator('button:has-text("确认答案")'); + if (await confirm.isVisible().catch(() => false)) { + await confirm.click(); + console.log(` ✅ 已提交`); + } + qIdx++; + } else if (state.hasTextarea) { + // ── SHORT ANSWER ── + saCount++; + const replyIdx = Math.min(saCount - 1, SA_REPLIES.length - 1); + console.log(`\n 🟩 第 ${qIdx}/${totalQs} 题 (简答 #${saCount})`); + + await page.locator('textarea').first().click(); + await fillTextarea(page, SA_REPLIES[replyIdx]); + console.log(` 📝 已输入: "${SA_REPLIES[replyIdx].substring(0, 20)}..."`); + await clickSendButton(page); + console.log(` ✅ 已提交`); + + // Wait for grading + await new Promise(r => setTimeout(r, 3000)); + await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {}); + await new Promise(r => setTimeout(r, 2000)); + + // Check for follow-up: textarea visible again at same question position + const stillTA = await page.evaluate(() => { + const ta = document.querySelector('textarea'); + return ta !== null && ta.offsetParent !== null; + }); + + if (stillTA && followUpCount < SA_REPLIES.length - 1) { + followUpCount++; + const fReply = SA_REPLIES[Math.min(followUpCount, SA_REPLIES.length - 1)]; + console.log(` 🔄 AI 追问 #${followUpCount} 已触发!`); + + await page.locator('textarea').first().click(); + await fillTextarea(page, fReply); + console.log(` 📝 追问已输入: "${fReply.substring(0, 20)}..."`); + await clickSendButton(page); + console.log(` ✅ 追问已提交`); + + await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {}); + await new Promise(r => setTimeout(r, 2000)); + } + + qIdx++; + } else { + // Wait for anything to appear + console.log(` ⏳ 等待第 ${qIdx} 题...`); + await new Promise(r => setTimeout(r, 3000)); + continue; + } + + // Wait for next question + await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 30000 }).catch(() => {}); + await new Promise(r => setTimeout(r, 2000)); + } + + // Results + await new Promise(r => setTimeout(r, 4000)); + const body = await page.textContent('body'); + const scores = body.match(/\d+\/10/g); + + console.log(`\n 📊 结果:`); + console.log(` 选择题: ${totalQs - saCount}`); + console.log(` 简答题: ${saCount}`); + console.log(` AI追问: ${followUpCount}`); + console.log(` 分数: ${scores ? scores.join(', ') : '无'}`); + + if (followUpCount > 0) { + console.log(`\n 🎉 多轮对话正常工作!`); + } else if (saCount > 0) { + console.log(`\n ✅ 简答题正常回答,未触发追问(回答已完整)。`); + } else { + console.log(`\n ⚠️ 未遇到简答题,需要确认 shuffle 是否生效。`); + } + + await browser.close(); + console.log('\n=== 完成 ==='); +} + +run().catch(e => { console.error('\n❌', e.message); process.exit(1); });