211 lines
35 KiB
JavaScript
211 lines
35 KiB
JavaScript
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)); }
|