PlantAI-rag-new 首次提交:文献知识工作台

- BFF 薄适配层(FastAPI,8090):QA 代理/SSE 中继/Neo4j 直连/抽取代理/会话持久化
- Vue3 前端:五相位检索流水线、文献来源与证据链、知识图谱、提取任务四 tab
- 四档响应式(桌面/笔记本/平板/手机)、深浅主题
- 81 个 FR 全部实现,测试追溯矩阵 81/81,后端 121 测/前端 156 测
- 真实凭据已脱敏,运行时配置在 backend/.env(不入 git)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jerry
2026-08-25 20:47:52 +08:00
co-authored by Claude Fable 5
commit 1e94de7464
171 changed files with 26291 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
// deploy/m2_e2e.js —— M2 集成验收脚本(PM 使用,在远端运行)
// 真实栈端到端:浏览器 → Vite dev(5180) → 真实 BFF(8090) → 真实上游(7920/8000/Neo4j)
// 流程:新建会话 → 问答提问 → 流水线相位推进 → 答案揭示 → 来源 tab → 图谱 tab → 刷新恢复历史
// 运行:cd ~/5090/teamWorkspace/zh/PlantAI-rag-new && node deploy/m2_e2e.js
// 注意:QA 上游 LLM 当前 402 降级,答案内容断言只验「有内容」不断言自然语言质量。
const { chromium } = require('playwright');
const BASE = 'http://127.0.0.1:5180';
const QUESTION = '水稻SPL基因家族的功能研究有哪些关键进展?';
const OUT_DIR = `${__dirname}/../artifacts/m2`;
const results = [];
function check(name, ok, extra = '') {
results.push({ name, ok });
console.log(`${ok ? ' ✅' : ' ❌'} ${name}${extra ? ' —— ' + extra : ''}`);
}
async function waitFor(fn, timeoutMs, intervalMs = 800, label = '') {
const start = Date.now();
let lastErr = null;
while (Date.now() - start < timeoutMs) {
try {
const v = await fn();
if (v) return v;
} catch (e) {
lastErr = e;
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`等待超时(${timeoutMs}ms): ${label}${lastErr ? ' last=' + lastErr.message : ''}`);
}
async function stepStates(page) {
return page.$$eval('.pipeline-card .p-step', (els) =>
els.map((el) => ({
name: el.querySelector('.p-name')?.textContent?.trim() || '',
state: el.className.includes('done') ? 'done' : el.className.includes('running') ? 'running' : 'waiting',
})),
);
}
(async () => {
const fs = require('fs');
fs.mkdirSync(OUT_DIR, { recursive: true });
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
page.on('console', (m) => {
if (m.type() === 'error') errors.push('console: ' + m.text());
});
try {
// 1. 加载
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await waitFor(() => page.locator('.app-shell').count(), 15000, 500, 'AppShell 渲染');
check('页面加载:AppShell 渲染', true);
await page.screenshot({ path: `${OUT_DIR}/01-loaded.png` });
// 2. 新建会话
await page.click('.new-task-btn');
await waitFor(() => page.locator('.composer textarea').count(), 15000, 500, '作曲器出现');
check('新建会话:作曲器出现', true);
// 3. 提问(问答模式为默认)
await page.fill('.composer textarea', QUESTION);
await page.click('.send-btn');
await waitFor(() => page.locator('.pipeline-card').count(), 20000, 500, '流水线卡出现');
check('提问:流水线卡出现', true);
// 4. 相位推进(真实 SSE,QA 上游约 40s 完成)
const statesSeen = [];
const t0 = Date.now();
let allDone = false;
while (Date.now() - t0 < 240000) {
const st = await stepStates(page);
const key = JSON.stringify(st);
if (!statesSeen.includes(key)) statesSeen.push(key);
if (st.length && st.every((s) => s.state === 'done')) { allDone = true; break; }
await new Promise((r) => setTimeout(r, 1500));
}
check('相位推进:观察到多次状态变化', statesSeen.length >= 3, `${statesSeen.length} 个不同状态快照`);
check('相位推进:全部完成', allDone, `${Math.round((Date.now() - t0) / 1000)}s`);
await page.screenshot({ path: `${OUT_DIR}/02-pipeline.png` });
// 5. 答案揭示(complete 后揭示动画,最长 8s 加速)
await waitFor(async () => {
const txt = await page.locator('.msg.assistant .msg-body').last().innerText().catch(() => '');
return txt.length > 30;
}, 60000, 1000, '助手答案出现');
const answerText = await page.locator('.msg.assistant .msg-body').last().innerText();
check('答案渲染:有内容', answerText.length > 30, `长度 ${answerText.length}`);
check('答案渲染:含文献证据(PMID/DOI 或降级提示)', /PMID|doi|evidence|证据/i.test(answerText));
await page.screenshot({ path: `${OUT_DIR}/03-answer.png` });
// 6. 文献来源 tab(真实 sources)
await page.click('.bench-tab:has-text("文献来源")');
await waitFor(async () => page.locator('.source-row').count(), 30000, 800, '来源条目出现');
const srcCount = await page.locator('.source-row').count();
check('文献来源:来源条目渲染', srcCount >= 1, `${srcCount} 条`);
await page.screenshot({ path: `${OUT_DIR}/04-sources.png` });
// 7. 知识图谱 tab(答案图谱)
await page.click('.bench-tab:has-text("知识图谱")');
await waitFor(async () => {
const n = await page.locator('.bench-body svg, .bench-body .kg-list, .bench-body .empty-note').count();
return n > 0;
}, 15000, 800, '图谱 tab 内容');
check('知识图谱:tab 有内容(图/列表/空态之一)', true);
await page.screenshot({ path: `${OUT_DIR}/05-kg.png` });
// 8. 刷新恢复(真实 BFF SQLite 持久化)
await page.reload({ waitUntil: 'domcontentloaded' });
await waitFor(() => page.locator('.session-item').count(), 15000, 500, '会话列表加载');
check('刷新:会话列表恢复(SQLite)', true);
await page.click('.session-item:has-text("水稻SPL")');
await waitFor(async () => {
const n = await page.locator('.msg.assistant').count();
return n > 0;
}, 20000, 800, '历史消息恢复');
check('刷新:助手消息历史恢复', true);
await page.screenshot({ path: `${OUT_DIR}/06-restored.png` });
// 9. 浏览器错误
const realErrors = errors.filter((e) => !e.includes('favicon'));
check('无页面级 JS 错误', realErrors.length === 0, realErrors.slice(0, 3).join(' | '));
} catch (e) {
check('流程异常中断', false, e.message);
await page.screenshot({ path: `${OUT_DIR}/99-failure.png` }).catch(() => {});
} finally {
await browser.close();
}
const passed = results.filter((r) => r.ok).length;
console.log(`\nM2 验收结果:${passed}/${results.length} 通过`);
process.exit(passed === results.length ? 0 : 1);
})();