assistant/scripts/test-analysis-review.mjs

211 lines
35 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.

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { randomUUID } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
import { chromium } from 'playwright';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
function pythonPath() { return process.env.ASSISTANT_PYTHON || path.join(root, '.venv', process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python'); }
import { stopOwnedChild } from './processes.mjs';
// Own random schema, ports, profile, synthetic text/media, and loopback-only model.
// Deliberately does not load .env. Inject only the dedicated test DB; model settings are replaced by loopback fixtures.
const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('ASSISTANT_MODEL_')));
assert.match(process.env.ASSISTANT_TEST_DATABASE_URL || '', /^postgres(?:ql)?:\/\//, 'Set the dedicated PostgreSQL test database before running this isolated acceptance.');
const output = path.join(root, '.tmp', `analysis-review-acceptance-${Date.now()}`);
fs.mkdirSync(output, { recursive: true });
const note = { id: 'a'.repeat(24), title: '装修求助:新房准备装修', body: '准备装修,需要找施工团队。', author: '合成作者' };
const comments = ['我提供装修服务,可以找我。', null, '准备装修,预算 20 万。'];
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=', 'base64');
const secret = 'fixture-key-never-expose-' + randomUUID();
const fallbackSecret = 'fixture-fallback-never-expose-' + randomUUID();
const rawError = 'PRIVATE_PROVIDER_RESPONSE_NEVER_EXPOSE';
let fixtureRequests = 0;
let receivedWrites = 0;
let providerMode = 'success';
const modelCalls = [];
const providerErrors = [];
const fixture = createServer((request, response) => {
fixtureRequests += 1;
if (request.method !== 'GET') receivedWrites += 1;
const url = new URL(request.url, 'http://127.0.0.1');
if (url.pathname === '/media.png') { response.writeHead(200, { 'Content-Type': 'image/png' }); response.end(png); return; }
const detail = url.pathname === `/explore/${note.id}`;
const content = detail ? `<section data-testid="note-detail"><h1 class="title">${note.title}</h1><p class="body">${note.body}</p><span class="author">${note.author}</span></section><section data-testid="comments" style="height:360px;overflow:auto">${comments.map((text, index) => `<article class="comment" id="comment-${index + 1}" style="height:100px;box-sizing:border-box"><span class="author">合成评论作者${index + 1}</span><div class="content">${text ? `<span class="text">${text}</span>` : ''}${index > 0 ? '<img class="image" src="/media.png" width="32" height="32">' : ''}</div></article>`).join('')}</section>` : `<h1>隔离装修需求样本</h1><form action="/"><label>搜索<input data-testid="search" type="search" name="q"></label><button>搜索</button></form><section data-testid="results"><article class="card"><a data-testid="note-link" href="/explore/${note.id}">${note.title}</a><span class="author">${note.author}</span></article></section>`;
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
response.end(`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>隔离装修需求样本</title><style>body{font:16px/24px sans-serif;margin:18px}h1{font-size:24px}.author,.text{display:block}a{display:block}input,button{font:inherit}.content{min-height:45px}.comment{border-bottom:1px solid #ddd}</style><main>${content}</main></html>`);
});
const provider = createServer(async (request, response) => {
try {
assert.equal(request.method, 'POST'); assert.equal(request.url, '/v1/chat/completions');
const key = request.headers.authorization;
assert.ok(key === `Bearer ${secret}` || key === `Bearer ${fallbackSecret}`);
const slot = key === `Bearer ${secret}` ? 'primary' : 'fallback';
let raw = ''; for await (const chunk of request) { raw += chunk; assert.ok(raw.length < 100000); }
const body = JSON.parse(raw); const input = JSON.parse(body.messages.find(message => message.role === 'user').content);
assert.equal(body.model, 'fixture-demand-model');
assert.ok(!raw.includes('合成作者') && !raw.includes('合成评论作者') && !raw.includes('http://') && !raw.includes('.image') && !raw.includes('comment-'));
const task = Object.hasOwn(input, 'request_text') ? 'brief' : 'analysis';
modelCalls.push({ slot, mode: providerMode, task }); response.setHeader('Content-Type', 'application/json');
if (providerMode === 'allfail' || (providerMode === 'fallback' && slot === 'primary')) { response.writeHead(503); response.end(JSON.stringify({ error: { message: `${rawError} ${secret} ${fallbackSecret}` } })); return; }
let content;
if (task === 'brief') {
assert.deepEqual(Object.keys(input), ['request_text']);
const fact = text => input.request_text.includes(text) ? { value: text, evidence: text } : null;
content = input.request_text.includes('手机') ? { platform: fact('抖音'), service: fact('手机'), target_customer: fact('买手机的人'), search_terms: [fact('手机')], region: null, region_scope: null, exclude_service_promotions: null } : { platform: fact('小红书'), service: fact('装修服务'), target_customer: fact('准备装修的业主'), search_terms: [fact('装修')], region: fact('杭州'), region_scope: null, exclude_service_promotions: fact('排除商家广告') };
} else {
assert.deepEqual(Object.keys(input).sort(), ['business_context', 'sources']); assert.equal(input.sources.length, 3);
for (const source of input.sources) {
assert.deepEqual(Object.keys(source).sort(), ['context_refs', 'media_uninterpreted', 'source_ref', 'text']);
assert.deepEqual(source.context_refs, source.source_ref === 'note' ? [] : ['note']);
}
content = { results: input.sources.map(source => {
const own = source.text.includes('准备装修');
return { source_ref: source.source_ref, demand: own ? 'explicit' : 'discussion', evidence: [source.text], role: own ? 'suspected_demand_side' : 'participant', role_evidence: [source.text], expressions: [{ type: own ? 'self_need' : 'case_share', evidence: [source.text] }, ...(source.source_ref === 'note' ? [{ type: 'seek_advice', evidence: [source.text] }] : [])], business_relevance: { value: 'relevant', evidence: [source.text] }, stage: own ? { value: '准备装修', evidence: '准备装修' } : null, region: null, budget: source.text.includes('预算') ? { value: '20 万', evidence: '预算 20 万' } : null };
}) };
for (const result of content.results) result.consultation = { value: 'no', evidence: result.evidence, context_evidence: [] };
if (providerMode === 'invalid') content.results[0].role_evidence = ['模型凭空写出的不存在原文'];
}
response.end(JSON.stringify({ id: 'fixture-response-' + modelCalls.length, model: 'fixture-demand-model', choices: [{ message: { role: 'assistant', content: JSON.stringify(content) }, finish_reason: 'stop' }], usage: { prompt_tokens: 120, completion_tokens: 80, total_tokens: 200, prompt_cache_hit_tokens: 10, prompt_cache_miss_tokens: 110 } }));
} catch { providerErrors.push('Provider fixture contract assertion failed'); response.writeHead(500, { 'Content-Type': 'application/json' }); response.end('{"error":{"message":"fixture contract rejected"}}'); }
});
async function listen(server) { await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); return `http://127.0.0.1:${server.address().port}`; }
async function unusedPort() { const server = createServer(); const base = await listen(server); await new Promise(resolve => server.close(resolve)); return Number(new URL(base).port); }
const apiPort = await unusedPort(); let uiPort = await unusedPort(); if (uiPort === apiPort) uiPort = await unusedPort();
const apiBase = `http://127.0.0.1:${apiPort}`; const uiBase = `http://127.0.0.1:${uiPort}`;
Object.assign(environment, { ASSISTANT_DATABASE_URL: process.env.ASSISTANT_TEST_DATABASE_URL || '', ASSISTANT_DATABASE_SCHEMA: 'assistant_learning_demand_' + randomUUID().replaceAll('-', ''), ASSISTANT_LEARNING_HOME: path.join(output, 'learning'), PYTHONUTF8: '1', ASSISTANT_NODE: process.execPath, ASSISTANT_ALLOWED_ORIGINS: uiBase, ASSISTANT_API_TARGET: apiBase, ASSISTANT_BROWSER_HEADLESS: '1', ASSISTANT_BROWSER_CHANNEL: process.env.ASSISTANT_TEST_BROWSER_CHANNEL || (process.platform === 'win32' ? 'msedge' : 'chromium'), ASSISTANT_INSTANCE_ID: randomUUID() });
const children = new Set(); const errors = []; let browser; let page; let fixtureStarted = false; let providerStarted = false;
function launch(command, args, name) {
const child = spawn(command, args, { cwd: root, env: environment, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); child.assistantGraceful = name.startsWith('server'); children.add(child);
const log = fs.createWriteStream(path.join(output, name + '.log')); child.stdout.pipe(log); child.stderr.pipe(log); child.on('error', error => errors.push(`${name}: ${error.message}`)); return child;
}
async function stop(child) { await stopOwnedChild(child, { graceful: child.assistantGraceful }); children.delete(child); }
async function ready(base, child) {
for (let index = 0; index < 100; index += 1) { if (child.exitCode !== null) throw Error('Owned demand service exited'); try { const response = await fetch(base + '/api/health', { signal: AbortSignal.timeout(1500) }); if (response.ok) { const health = await response.json(); if (health.instance_id === environment.ASSISTANT_INSTANCE_ID) return health; } } catch {} await delay(250); }
throw Error('Owned demand service was not ready');
}
class Client {
cookies = new Map(); csrf = '';
async request(method, route, body, expected = 200) {
const response = await fetch(apiBase + route, { method, redirect: 'error', signal: AbortSignal.timeout(90000), headers: { Accept: 'application/json', Cookie: [...this.cookies].map(([key, value]) => `${key}=${value}`).join('; '), Origin: apiBase, ...(body === undefined ? {} : { 'Content-Type': 'application/json', 'X-CSRF-Token': this.csrf }) }, body: body === undefined ? undefined : JSON.stringify(body) });
for (const cookie of response.headers.getSetCookie()) { const [pair] = cookie.split(';', 1); const at = pair.indexOf('='); this.cookies.set(pair.slice(0, at), pair.slice(at + 1)); }
const data = await response.json(); assert.equal(response.status, expected, `Unexpected status for ${method} ${route}`); if (data.csrf_token) this.csrf = data.csrf_token; return data;
}
}
function assertNoSecrets(value) { const text = typeof value === 'string' ? value : JSON.stringify(value); for (const forbidden of [secret, fallbackSecret, rawError]) assert.ok(!text.includes(forbidden), 'Private model material was exposed'); }
try {
const fixtureBase = await listen(fixture); fixtureStarted = true; const providerBase = await listen(provider); providerStarted = true;
// Override all eight supported model settings; the child must never use .env model credentials.
Object.assign(environment, { ASSISTANT_MODEL_BASE_URL: providerBase + '/v1', ASSISTANT_MODEL_NAME: 'fixture-demand-model', ASSISTANT_MODEL_API_KEY: secret, ASSISTANT_MODEL_FALLBACK_API_KEY: fallbackSecret, ASSISTANT_MODEL_PROVIDER: 'isolated-fixture', ASSISTANT_MODEL_TIMEOUT_SECONDS: '10', ASSISTANT_MODEL_THINKING: 'disabled', ASSISTANT_MODEL_MAX_OUTPUT_TOKENS: '8192' });
for (const key of ['BASE_URL', 'NAME', 'API_KEY', 'FALLBACK_API_KEY', 'PROVIDER', 'TIMEOUT_SECONDS', 'THINKING', 'MAX_OUTPUT_TOKENS']) assert.equal(typeof environment[`ASSISTANT_MODEL_${key}`], 'string');
assert.equal(new URL(environment.ASSISTANT_MODEL_BASE_URL).hostname, '127.0.0.1');
const backendArgs = ['-m', 'scripts.serve', '--port', String(apiPort)]; let backend = launch(pythonPath(), backendArgs, 'server'); const health = await ready(apiBase, backend);
assert.equal(health.database_backend, 'postgresql');
const client = new Client(); await client.request('GET', '/api/auth/status'); await client.request('POST', '/api/auth/login', { username: 'admin', password: '123456' });
async function task(name, account, parameters) { const created = await client.request('POST', '/api/learning/tasks', { name, platform: 'demo', entry_url: fixtureBase, account_key: account, goal: '隔离模型判断,不对真实用户联系', parameters }, 201); await client.request('POST', `/api/learning/tasks/${created.id}/browser/open`, {}); await client.request('POST', `/api/learning/tasks/${created.id}/browser/ready`, { identity_label: '需求合成维护者' }); await client.request('POST', `/api/learning/tasks/${created.id}/browser/capture`, {}); return created; }
async function freeze(taskId, name) { const candidate = await client.request('POST', `/api/learning/tasks/${taskId}/candidates`, { name }, 201); const checked = await client.request('POST', `/api/learning/candidates/${candidate.id}/check`, {}); assert.equal(checked.status, 'passed'); return candidate; }
async function searchActions(taskId) { const act = body => client.request('POST', `/api/learning/tasks/${taskId}/browser/action`, body); await act({ kind: 'fill', selector: '[data-testid="search"]', parameter: 'keyword' }); await act({ kind: 'press', selector: '[data-testid="search"]', key: 'Enter' }); await act({ kind: 'wait', selector: '[data-testid="note-link"]', state: 'visible', min_count: 1, timeout_ms: 3000 }); return act; }
const searchTask = await task('需求夹具搜索', 'demand-search', { keyword: '装修' });
await (await searchActions(searchTask.id))({ kind: 'extract', selector: '[data-testid="results"]', row_selector: '.card', output_name: 'search_results', complete_only: true, limit: 5, fields: [{ name: 'title', selector: '[data-testid="note-link"]', source: 'text', required: false }, { name: 'author', selector: '.author', source: 'text', required: false }, { name: 'url', selector: '[data-testid="note-link"]', source: 'href', required: true }] });
const searchCandidate = await freeze(searchTask.id, '需求夹具搜索候选');
const run = await client.request('POST', '/api/discovery/runs', { search_candidate_id: searchCandidate.id, keyword: '装修', service: '装修服务', target_customer: '有装修需求的业主', region: '', max_selected: 1 }, 201); assert.equal(run.status, 'completed'); assert.equal(run.selected.length, 1);
await client.request('POST', `/api/learning/tasks/${searchTask.id}/browser/close`, {});
const detailTask = await task('需求夹具七步阅读', 'demand-detail', { keyword: '装修', note_title: note.title }); const act = await searchActions(detailTask.id);
await act({ kind: 'click', selector: '[data-testid="note-link"]', target: { field: 'text', parameter: 'note_title' } }); await act({ kind: 'wait', selector: '[data-testid="note-detail"]', state: 'visible', timeout_ms: 3000 });
await act({ kind: 'extract', selector: '[data-testid="note-detail"]', output_name: 'note_detail', limit: 1, fields: ['title', 'body', 'author'].map(name => ({ name, selector: '.' + name, source: 'text', required: true })) });
await act({ kind: 'extract', selector: '[data-testid="comments"]', row_selector: '.comment', output_name: 'comments', complete_only: true, limit: 3, batch: { scroll_selector: '[data-testid="comments"]', pixels: 100, max_batches: 2, dedupe_field: 'comment_id' }, fields: [{ name: 'comment_id', source: 'attribute', attribute: 'id', required: true }, { name: 'author', selector: '.author', source: 'text', required: true }, { name: 'body', selector: '.content', source: 'content', text_selector: '.text', media_selector: 'img.image', max_media: 1, required: true }] });
const detailCandidate = await freeze(detailTask.id, '需求夹具七步候选');
const reading = await client.request('POST', `/api/discovery/runs/${run.id}/readings`, { detail_candidate_id: detailCandidate.id, note_id: note.id }, 201); assert.equal(reading.status, 'completed', JSON.stringify(reading.error_codes)); assert.equal(reading.comments.count, 3); assert.deepEqual(reading.comments.items.map(item => item.body.kind), ['text', 'media', 'mixed']);
await client.request('POST', `/api/learning/tasks/${detailTask.id}/browser/close`, {}); const platformRequestsBeforeAnalysis = fixtureRequests;
const frontend = launch(process.execPath, [path.join(root, 'apps/admin/node_modules/vite/bin/vite.js'), '--config', path.join(root, 'apps/admin/vite.config.ts'), '--host', '127.0.0.1', '--port', String(uiPort), '--strictPort'], 'admin'); await ready(uiBase, frontend);
browser = await chromium.launch({ headless: true, channel: environment.ASSISTANT_BROWSER_CHANNEL }); page = await browser.newPage({ viewport: { width: 1460, height: 1050 } }); page.on('pageerror', error => errors.push(error.message));
let reviewPosts = 0;
page.on('request', request => { if (request.method() === 'POST' && /\/reviews$/.test(new URL(request.url()).pathname)) reviewPosts += 1; });
await page.goto(`${uiBase}/#/discovery/${run.id}?reading=${reading.id}`); await page.getByTestId('auth-username').fill('admin'); await page.getByTestId('auth-password').fill('123456'); await page.getByTestId('auth-submit').click();
const card = page.getByTestId('reading-demand-analysis'), drawer = page.getByTestId('demand-analysis-drawer'), editor = page.getByTestId('analysis-review-drawer');
await card.waitFor(); await card.getByTestId('demand-model-config').getByText('fixture-demand-model', { exact: true }).waitFor();
const created = page.waitForResponse(reply => reply.request().method() === 'POST' && reply.url().endsWith(`/api/discovery/readings/${reading.id}/analyses`));
await card.getByTestId('demand-analysis-start').click(); const createdResponse = await created; assert.equal(createdResponse.status(), 201); const analysis = await createdResponse.json(); assert.equal(analysis.status, 'completed'); assert.equal(analysis.result_version, 'demand-result-v3'); assert.equal(analysis.results.length, 4);
const modelCallsBeforeReviews = modelCalls.length, platformBeforeReviews = fixtureRequests;
const reviewRoute = `/api/discovery/analyses/${analysis.id}/reviews`, summaryRoute = `/api/discovery/analyses/${analysis.id}/review-summary`;
const initialSummary = await client.request('GET', summaryRoute); assert.equal(initialSummary.total, 4); assert.equal(initialSummary.pending, 4); assert.equal(initialSummary.judged, 0); assert.equal(initialSummary.agreement_rate, null);
const byIndex = index => analysis.sources.find(source => source.comment_index === index);
const noteSource = analysis.sources.find(source => source.kind === 'note'), comment1 = byIndex(1), mediaSource = byIndex(2), mixedSource = byIndex(3);
assert.ok(noteSource && comment1 && mediaSource && mixedSource); assert.equal(mediaSource.text, '');
async function choose(label, option) { await editor.getByLabel(label, { exact: true }).click(); await page.locator('.ant-select-dropdown:visible').getByText(option, { exact: true }).click(); }
async function closeEditor() { if (await editor.isVisible()) await editor.getByRole('button', { name: '关闭人工核对', exact: true }).click(); await editor.waitFor({ state: 'hidden' }); }
async function openSource(source) { await closeEditor(); await drawer.locator(`[data-testid="source-review-control"][data-source-ref="${source.source_ref}"]`).getByTestId('source-review-open').click(); await editor.waitFor(); await editor.locator(`[data-source-ref="${source.source_ref}"]`).waitFor(); }
async function save(expected = 201, doubleClick = false) {
const received = page.waitForResponse(reply => reply.request().method() === 'POST' && reply.url().endsWith(reviewRoute));
if (doubleClick) await editor.getByTestId('review-save').evaluate(button => { button.click(); button.click(); }); else await editor.getByTestId('review-save').click();
const response = await received; assert.equal(response.status(), expected); const result = await response.json();
if (expected === 201) await editor.getByTestId('review-saved').waitFor(); else await editor.getByTestId('review-save-error').waitFor();
await page.waitForFunction(() => !document.querySelector('[data-testid="review-save"]')?.classList.contains('ant-btn-loading'));
await visibleInViewport(editor.getByTestId(expected === 201 ? 'review-saved' : 'review-save-error'));
assertNoSecrets(result); return result;
}
async function visibleInViewport(locator) { await locator.waitFor({ state: 'visible' }); await page.waitForFunction(element => { const rect = element.getBoundingClientRect(); let top = 0, left = 0, right = innerWidth, bottom = innerHeight;
for (let parent = element.parentElement; parent; parent = parent.parentElement) { const style = getComputedStyle(parent), bounds = parent.getBoundingClientRect(); if (/(auto|scroll|hidden|clip)/.test(style.overflowY)) { top = Math.max(top, bounds.top); bottom = Math.min(bottom, bounds.bottom); } if (/(auto|scroll|hidden|clip)/.test(style.overflowX)) { left = Math.max(left, bounds.left); right = Math.min(right, bounds.right); } }
return rect.width > 0 && rect.height > 0 && rect.top >= top - 1 && rect.bottom <= bottom + 1 && rect.left >= left - 1 && rect.right <= right + 1;
}, await locator.elementHandle()); }
await drawer.getByTestId('review-counts').getByText('待核对 4', { exact: true }).waitFor();
await openSource(noteSource); await visibleInViewport(editor.getByTestId('review-source-original')); assert.equal((await client.request('GET', reviewRoute)).items.length, 0); assert.equal(await editor.getByTestId('review-save').count(), 0, 'Opening and prefilled model labels must not auto-save.');
await choose('人工核对结论', '判断正确'); const confirmed = await save(201, true); assert.equal(confirmed.decision, 'confirmed'); assert.equal(confirmed.contract_version, 'analysis-review-v2'); assert.equal(confirmed.labels.consultation, 'no'); assert.deepEqual(confirmed.context_evidence, []); assert.equal(confirmed.previous_review_id, null); assert.equal((await client.request('GET', reviewRoute)).items.length, 1); assert.equal(reviewPosts, 1, 'Rapid double click must create only one POST.');
await openSource(comment1); await choose('人工核对结论', '需要修正'); await choose('核对role', '疑似服务方');
await editor.getByLabel('核对expressions', { exact: true }).click(); await page.locator('.ant-select-dropdown:visible').getByText('案例分享', { exact: true }).click(); await page.locator('.ant-select-dropdown:visible').getByText('提供服务', { exact: true }).click(); await editor.getByText('选择本次核对结论', { exact: true }).click();
await editor.getByTestId('review-note').fill('原文明示提供装修服务,应是服务推广表达,不能作为其自身装修需求。'); await editor.getByRole('button', { name: '填入本条原文', exact: true }).click();
const corrected = await save(); assert.equal(corrected.decision, 'corrected'); assert.equal(corrected.labels.role, 'suspected_service_provider'); assert.deepEqual(corrected.labels.expressions, ['service_offer']); assert.deepEqual(corrected.evidence, [comment1.text]);
await page.screenshot({ path: path.join(output, 'review-correction-saved.png'), fullPage: false });
await openSource(mixedSource); await choose('人工核对结论', '暂不能确定'); await editor.getByTestId('review-note').fill('文字有装修计划,但媒体未解释,需人工继续核对。'); const uncertain = await save(); assert.equal(uncertain.labels, null);
await openSource(mediaSource); await editor.getByLabel('人工核对结论', { exact: true }).click(); const decisionOptions = page.locator('.ant-select-dropdown:visible .ant-select-item-option-content'); await decisionOptions.first().waitFor({ state: 'visible' }); assert.deepEqual(await decisionOptions.allTextContents(), ['暂不能确定', '当前文字不可核对']); await page.locator('.ant-select-dropdown:visible').getByText('当前文字不可核对', { exact: true }).click(); await editor.getByTestId('review-note').fill('纯图片没有可读文字,当前不解释媒体内容。'); const unreviewable = await save(); assert.equal(unreviewable.labels, null); assert.deepEqual(unreviewable.evidence, []);
await closeEditor(); const allFour = await client.request('GET', summaryRoute); assert.equal(allFour.pending, 0); assert.equal(allFour.reviewed, 4); assert.equal(allFour.judged, 2); assert.equal(allFour.agreement_rate, 0.5); assert.deepEqual(allFour.decision_counts, { confirmed: 1, corrected: 1, uncertain: 1, unreviewable: 1 });
await drawer.getByTestId('review-agreement').getByText('已判定 2 / 4;已判定样本一致率:50.0%。', { exact: true }).waitFor();
// Revising appends to the same source; statistics use its newest review only.
await openSource(comment1); await choose('人工核对结论', '需要修正'); await choose('核对match', '条件初步匹配'); await editor.getByTestId('review-note').fill('尝试修改匹配标签,校验应拒绝缺少自身需求和地区的匹配。'); await editor.getByRole('button', { name: '填入本条原文', exact: true }).click(); const beforeInvalid = reviewPosts; const invalid = await save(422); assert.equal(invalid.detail.code, 'REVIEW_LABELS_INVALID'); assert.equal(reviewPosts, beforeInvalid + 1); assert.equal((await client.request('GET', reviewRoute)).items.length, 4); assert.equal(await editor.getByTestId('review-save').isDisabled(), false); await editor.getByTestId('review-save-error').getByText(/匹配须有自身需求/).waitFor(); await page.screenshot({ path: path.join(output, 'review-validation-visible.png'), fullPage: false }); await delay(150); assert.equal(reviewPosts, beforeInvalid + 1);
await choose('人工核对结论', '暂不能确定'); await editor.getByTestId('review-note').fill('保留之前纠错,暂待另一位维护者核实角色。'); const revision = await save(); assert.equal(revision.previous_review_id, corrected.id);
assert.equal(await editor.getByTestId('review-history-item').count(), 2); const afterRevision = await client.request('GET', summaryRoute); assert.equal(afterRevision.judged, 1); assert.equal(afterRevision.agreement_rate, 1); assert.deepEqual(afterRevision.decision_counts, { confirmed: 1, corrected: 0, uncertain: 2, unreviewable: 1 });
// A concurrent API append makes the open editor stale; it must not retry or overwrite.
await openSource(noteSource); await choose('人工核对结论', '判断正确');
const concurrent = await client.request('POST', reviewRoute, { source_ref: noteSource.source_ref, previous_review_id: confirmed.id, decision: 'uncertain', labels: null, evidence: [], note: '并发维护者先追加为待核,原确认仍保留。' }, 201);
const postsBeforeConflict = reviewPosts; const conflict = await save(409); assert.equal(conflict.detail.code, 'REVIEW_STALE'); assert.equal(reviewPosts, postsBeforeConflict + 1); assert.equal(await editor.getByTestId('review-save').isDisabled(), true); await editor.getByTestId('review-save-error').getByText(/已有更新的核对/).waitFor();
const afterConflict = await client.request('GET', reviewRoute); assert.equal(afterConflict.items.length, 6); await delay(150); assert.equal(reviewPosts, postsBeforeConflict + 1);
await page.screenshot({ path: path.join(output, 'review-conflict-requires-reopen.png'), fullPage: false });
await openSource(noteSource); await choose('人工核对结论', '判断正确'); const restored = await save(); assert.equal(restored.previous_review_id, concurrent.id); assert.equal(await editor.getByTestId('review-history-item').count(), 3); await closeEditor();
const finalReviews = await client.request('GET', reviewRoute), finalSummary = await client.request('GET', summaryRoute); assert.equal(finalReviews.items.length, 7); assert.equal(finalSummary.total, 4); assert.equal(finalSummary.reviewed, 4); assert.equal(finalSummary.judged, 1); assert.equal(finalSummary.agreement_rate, 1);
for (const record of [confirmed, corrected, uncertain, unreviewable, revision, concurrent, restored]) assert.deepEqual(finalReviews.items.find(item => item.id === record.id), record);
assert.deepEqual(await client.request('GET', `/api/discovery/analyses/${analysis.id}`), analysis); assert.deepEqual(await client.request('GET', `/api/discovery/readings/${reading.id}`), reading); assert.deepEqual(await client.request('GET', `/api/discovery/runs/${run.id}`), run);
await page.setViewportSize({ width: 980, height: 760 }); await openSource(noteSource); await visibleInViewport(editor.getByTestId('review-source-original'));
// data-testid belongs to this drawer's wrapper. Never measure an ancestor
// content panel: it can be the parent analysis drawer shifted by nesting.
await editor.evaluate(async element => {
const content = element.querySelector(':scope > .ant-drawer-content') || element;
let previous = '', stable = 0;
for (let index = 0; index < 90; index += 1) {
await new Promise(resolve => requestAnimationFrame(resolve));
const rect = content.getBoundingClientRect(); const stamp = [rect.left, rect.right, rect.top, rect.bottom].map(value => value.toFixed(3)).join(',');
const animating = [element, ...element.querySelectorAll('*')].some(target => target.getAnimations().some(animation => animation.playState === 'running'));
stable = stamp === previous && !animating ? stable + 1 : 0; previous = stamp;
if (stable >= 3) return;
}
throw new Error('Review drawer layout did not stabilize');
});
const smallScreen = await editor.evaluate(element => { const content = element.querySelector(':scope > .ant-drawer-content') || element, rect = content.getBoundingClientRect(), wrapper = element.getBoundingClientRect(); return { measured_class: content.className, wrapper_class: element.className, left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, wrapper_bounds: { left: wrapper.left, right: wrapper.right, top: wrapper.top, bottom: wrapper.bottom }, overflow: [...content.querySelectorAll('[data-testid="review-source-original"], [data-testid="review-editor"], [data-testid="review-source-history"]')].filter(item => item.scrollWidth > item.clientWidth + 2).length }; });
fs.writeFileSync(path.join(output, 'review-geometry.json'), JSON.stringify(smallScreen, null, 2));
assert.ok(smallScreen.left >= -1 && smallScreen.right <= 981 && smallScreen.top >= -1 && smallScreen.bottom <= 761); assert.equal(smallScreen.overflow, 0); await page.screenshot({ path: path.join(output, 'review-small-screen-original.png'), fullPage: false }); await closeEditor();
// Seed a separate legacy analysis only in this random-schema fixture, never edit a fact.
const legacy = JSON.parse(JSON.stringify(analysis)); legacy.id = 'analysis_review_legacy'; legacy.prompt_version = 'renovation-demand-v2'; legacy.result_version = 'demand-result-v1'; delete legacy.context; for (const source of legacy.sources) delete source.context_refs; for (const result of legacy.results) for (const key of ['role', 'role_evidence', 'expressions', 'business_relevance', 'match', 'match_reasons', 'next_step', 'consultation', 'lead_disposition']) delete result[key];
const legacyFile = path.join(output, 'legacy-fixture.json'); fs.writeFileSync(legacyFile, JSON.stringify(legacy));
const seed = spawn(pythonPath(), ['-c', 'import json,os,sys; from pathlib import Path; from apps.server.db import PostgresStore,encoded; s=PostgresStore(Path(os.environ["ASSISTANT_LEARNING_HOME"]),os.environ["ASSISTANT_DATABASE_URL"],schema=os.environ["ASSISTANT_DATABASE_SCHEMA"]); r=json.loads(Path(sys.argv[1]).read_text()); owner=s.one("SELECT owner_id FROM reading_runs WHERE id=$1",(r["reading_id"],))["owner_id"]; s.execute("INSERT INTO analysis_runs(id,owner_id,reading_run_id,data_json,created_at) VALUES($1,$2,$3,$4,$5)",(r["id"],owner,r["reading_id"],encoded({k:v for k,v in r.items() if k not in ("id","created_at")}),r["created_at"]))', legacyFile], { cwd: root, env: environment, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
let seedError = ''; seed.stderr.on('data', value => { seedError += value; }); await new Promise((resolve, reject) => seed.once('error', reject).once('exit', code => code === 0 ? resolve() : reject(Error('Legacy fixture insert failed: ' + seedError))));
await drawer.getByRole('button', { name: '关闭分析详情', exact: true }).click(); await card.getByRole('button', { name: '刷新分析记录', exact: true }).click(); await card.getByTestId('demand-analysis-history').locator(`tr[data-row-key="${legacy.id}"]`).getByRole('button', { name: '查看分析', exact: true }).click(); await drawer.getByTestId('analysis-review-readonly').waitFor(); assert.equal(await drawer.getByTestId('source-review-open').count(), 0); assert.equal((await client.request('GET', `/api/discovery/analyses/${legacy.id}/review-summary`)).eligible, false);
await drawer.getByRole('button', { name: '关闭分析详情', exact: true }).click(); await stop(backend); assert.equal(backend.exitCode, 0); backend = launch(pythonPath(), backendArgs, 'server-restart'); await ready(apiBase, backend);
await page.reload(); await card.getByTestId('demand-analysis-history').locator(`tr[data-row-key="${analysis.id}"]`).getByRole('button', { name: '查看分析', exact: true }).click(); await drawer.getByTestId('review-counts').getByText('待核对 0', { exact: true }).waitFor(); await openSource(noteSource); assert.equal(await editor.getByTestId('review-history-item').count(), 3);
assert.deepEqual(await client.request('GET', reviewRoute), finalReviews); assert.deepEqual(await client.request('GET', summaryRoute), finalSummary); assert.deepEqual(await client.request('GET', `/api/discovery/analyses/${analysis.id}`), analysis); assert.equal(modelCalls.length, modelCallsBeforeReviews); assert.equal(fixtureRequests, platformBeforeReviews); assert.equal(receivedWrites, 0); assert.deepEqual(errors, []); assert.deepEqual(providerErrors, []); assertNoSecrets(await page.locator('body').innerText());
const summary = { result: 'passed', database_backend: health.database_backend, analysis_id: analysis.id, reading_id: reading.id, four_review_decisions: true, no_auto_save: true, rapid_double_click_one_post: true, correction_quoted_own_source: true, media_only_uncertain_or_unreviewable: true, revision_preserves_history: true, latest_only_statistics: true, validation_rejection_shows_safe_reason_and_form_editable: true, success_validation_conflict_feedback_in_viewport: true, stale_conflict_requires_explicit_reopen: true, original_analysis_reading_discovery_immutable: true, legacy_readonly: true, review_records: finalReviews.items.length, judged_sources: finalSummary.judged, agreement_is_not_accuracy: true, restart_persistence: true, model_calls_during_review: modelCalls.length - modelCallsBeforeReviews, platform_requests_during_review: fixtureRequests - platformBeforeReviews, platform_received_writes: receivedWrites, small_screen: smallScreen, errors };
fs.writeFileSync(path.join(output, 'summary.json'), JSON.stringify(summary, null, 2)); console.log(JSON.stringify({ ...summary, output }));
} catch (error) { if (page && !page.isClosed()) { await page.screenshot({ path: path.join(output, 'failure.png'), fullPage: false }).catch(() => {}); const text = await page.locator('body').innerText().catch(() => 'No UI'); assertNoSecrets(text); fs.writeFileSync(path.join(output, 'failure-ui.txt'), text); } console.error('Analysis review acceptance artifacts: ' + output); throw error; }
finally { if (browser) await browser.close().catch(() => {}); for (const child of [...children]) await stop(child); if (fixtureStarted) await new Promise(resolve => fixture.close(resolve)); if (providerStarted) await new Promise(resolve => provider.close(resolve)); }