206 lines
32 KiB
JavaScript
206 lines
32 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 { pythonPath, root } from './server.mjs';
|
|
import { stopOwnedChild } from './processes.mjs';
|
|
|
|
// Own random schema, ports, profile, synthetic text/media, and loopback-only model.
|
|
// server.mjs loads .env: remove every inherited model setting before constructing 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', `business-workbench-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 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));
|
|
await page.goto(`${uiBase}/#/discovery`); await page.getByTestId('auth-username').fill('admin'); await page.getByTestId('auth-password').fill('123456'); await page.getByTestId('auth-submit').click();
|
|
const briefPanel = page.getByTestId('business-brief-panel'), briefDrawer = page.getByTestId('business-brief-drawer');
|
|
await briefPanel.waitFor();
|
|
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());
|
|
}
|
|
async function submitBrief(text, mode = 'success') {
|
|
providerMode = mode;
|
|
if (!await briefDrawer.isVisible()) await briefPanel.getByTestId('business-brief-open').click();
|
|
await briefDrawer.getByTestId('business-request-text').fill(text);
|
|
const received = page.waitForResponse(reply => reply.request().method() === 'POST' && reply.url().endsWith('/api/business/briefs'));
|
|
await briefDrawer.getByTestId('business-brief-parse').click(); const response = await received; assert.equal(response.status(), 201); const result = await response.json();
|
|
await briefDrawer.locator(`[data-brief-id="${result.id}"]`).waitFor(); await page.waitForFunction(() => !document.querySelector('[data-testid="business-brief-parse"]')?.disabled); assertNoSecrets(result); return result;
|
|
}
|
|
const platformBeforeBriefs = fixtureRequests;
|
|
const draft = await submitBrief('我是做装修服务的,想在小红书找准备装修的业主,搜索装修。');
|
|
assert.equal(draft.status, 'needs_clarification'); assert.equal(draft.conditions.region, null); assert.equal(draft.conditions.region_scope, 'unspecified'); assert.ok(draft.missing_fields.includes('exclude_service_promotions') && draft.missing_fields.includes('region'));
|
|
await briefDrawer.getByTestId('business-brief-missing').waitFor(); await visibleInViewport(briefDrawer.getByText('解析字段与原文依据', { exact: true }));
|
|
const parsedCallCount = modelCalls.length; assert.deepEqual(await client.request('POST', '/api/business/briefs', { request_text: draft.request_text }, 201), draft); assert.equal(modelCalls.length, parsedCallCount);
|
|
async function choose(label, option) { await briefDrawer.getByLabel(label, { exact: true }).click(); await page.locator('.ant-select-dropdown:visible').getByText(option, { exact: true }).click(); }
|
|
await choose('确认地区范围', '指定服务地区'); await briefDrawer.getByLabel('确认服务地区', { exact: true }).fill('杭州'); await choose('确认推广排除条件', '排除服务推广信号');
|
|
const confirmResponse = page.waitForResponse(reply => reply.request().method() === 'POST' && reply.url().endsWith(`/api/business/briefs/${draft.id}/confirm`));
|
|
await briefDrawer.getByTestId('business-brief-confirm').click(); const confirmedReply = await confirmResponse; assert.equal(confirmedReply.status(), 201); const confirmed = await confirmedReply.json();
|
|
assert.equal(confirmed.parent_id, draft.id); assert.equal(confirmed.conditions.region, '杭州'); assert.equal(confirmed.conditions.exclude_service_promotions, true); assert.equal(modelCalls.length, parsedCallCount);
|
|
assert.deepEqual(await client.request('GET', `/api/business/briefs/${draft.id}`), draft); assert.equal(confirmed.route.execution_started, false); assert.ok(confirmed.route.follow_up.every(item => item.execution_available === false));
|
|
await briefDrawer.locator(`[data-brief-id="${confirmed.id}"]`).waitFor(); await page.screenshot({ path: path.join(output, 'confirmed-merchant-conditions.png'), fullPage: false });
|
|
await briefDrawer.getByTestId('business-brief-confirmed-use').click(); await briefDrawer.waitFor({ state: 'hidden' }); await page.getByTestId('discovery-business-context').waitFor();
|
|
assert.equal(fixtureRequests, platformBeforeBriefs, 'Parsing and confirmation may not execute the platform.');
|
|
const discoveryReply = page.waitForResponse(reply => reply.request().method() === 'POST' && reply.url().endsWith(`/api/business/briefs/${confirmed.id}/discover`), { timeout: 90000 });
|
|
await page.getByTestId('discovery-form').getByLabel('搜索结果读取候选', { exact: true }).click(); await page.locator('.ant-select-dropdown:visible').getByText('需求夹具搜索候选 · v1', { exact: true }).click();
|
|
await page.getByTestId('discovery-start').click(); const runResponse = await discoveryReply; assert.equal(runResponse.status(), 201); const run = await runResponse.json();
|
|
assert.deepEqual(Object.keys(runResponse.request().postDataJSON()).sort(), ['max_selected', 'search_candidate_id']); assert.equal(run.status, 'completed'); assert.equal(run.business_brief_id, confirmed.id); assert.equal(run.business_brief_digest, confirmed.digest); assert.equal(run.keyword, confirmed.conditions.search_terms[0]); 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, '需求夹具七步候选');
|
|
await page.getByRole('button', { name: '刷新记录', exact: true }).click();
|
|
await page.getByLabel('详情读取候选', { exact: true }).click(); await page.locator('.ant-select-dropdown:visible').getByText('需求夹具七步候选 · v1', { exact: true }).click();
|
|
const readingReply = page.waitForResponse(reply => reply.request().method() === 'POST' && reply.url().endsWith(`/api/discovery/runs/${run.id}/readings`), { timeout: 90000 });
|
|
await page.getByTestId('discovery-selected-note').getByRole('button', { name: '读取正文与评论', exact: true }).click(); const readingResponse = await readingReply; assert.equal(readingResponse.status(), 201); const reading = await readingResponse.json();
|
|
assert.equal(reading.status, 'completed', JSON.stringify(reading.error_codes)); assert.equal(reading.comments.count, 3); assert.equal(reading.business_brief_id, confirmed.id); assert.equal(reading.business_brief_digest, confirmed.digest);
|
|
await client.request('POST', `/api/learning/tasks/${detailTask.id}/browser/close`, {}); const platformBeforeAnalysis = fixtureRequests;
|
|
const card = page.getByTestId('reading-demand-analysis'), drawer = page.getByTestId('demand-analysis-drawer'), history = card.getByTestId('demand-analysis-history');
|
|
await card.waitFor(); await card.getByTestId('demand-model-config').getByText('fixture-demand-model', { exact: true }).waitFor();
|
|
async function closeAnalysis() { if (await drawer.isVisible()) await drawer.getByRole('button', { name: '关闭分析详情', exact: true }).click(); await drawer.waitFor({ state: 'hidden' }); }
|
|
async function shownAnalysis(analysis) { await drawer.waitFor({ state: 'visible' }); await drawer.locator(`[data-analysis-id="${analysis.id}"]`).waitFor(); await visibleInViewport(drawer.getByText('逐项需求与原文依据', { exact: true })); assertNoSecrets(await drawer.innerText()); }
|
|
async function analyze(mode = 'success') {
|
|
await closeAnalysis(); providerMode = mode; const received = page.waitForResponse(reply => reply.request().method() === 'POST' && reply.url().endsWith(`/api/discovery/readings/${reading.id}/analyses`), { timeout: 90000 });
|
|
await card.getByTestId('demand-analysis-start').click(); const response = await received; assert.equal(response.status(), 201); const result = await response.json(); await shownAnalysis(result); await page.waitForFunction(() => !document.querySelector('[data-testid="demand-analysis-start"]')?.disabled); return result;
|
|
}
|
|
const first = await analyze(); assert.equal(first.status, 'completed'); assert.equal(first.result_version, 'demand-result-v3'); assert.equal(first.context.business_brief_id, confirmed.id); assert.equal(first.results.length, 4);
|
|
const noteResult = first.results.find(item => item.source_ref === 'note'); assert.equal(noteResult.role, 'suspected_demand_side'); assert.deepEqual(noteResult.expressions.map(item => item.type), ['self_need', 'seek_advice']); assert.equal(noteResult.match, 'insufficient'); assert.ok(noteResult.match_reasons.includes('region_missing'));
|
|
const media = first.results.find(result => first.sources.find(source => source.source_ref === result.source_ref)?.text === ''); assert.equal(media.match, 'insufficient'); assert.equal(media.media_uninterpreted, true); assert.equal(media.role, 'unknown');
|
|
await drawer.getByTestId('demand-analysis-context').getByText('本次商家条件', { exact: true }).waitFor(); await drawer.getByText('疑似需求方', { exact: true }).first().waitFor(); await page.screenshot({ path: path.join(output, 'multi-axis-analysis.png'), fullPage: false });
|
|
const callsAfterFirst = modelCalls.length; assert.deepEqual(await analyze(), first); assert.equal(modelCalls.length, callsAfterFirst); await closeAnalysis();
|
|
// A different confirmed context uses the same saved reading, with a new immutable analysis.
|
|
const otherConditions = { ...confirmed.conditions, region_scope: 'unrestricted', region: null };
|
|
const other = await client.request('POST', `/api/business/briefs/${draft.id}/confirm`, otherConditions, 201);
|
|
await card.getByRole('button', { name: '刷新分析记录', exact: true }).click(); await card.getByLabel('分析所用商家条件', { exact: true }).click(); await page.locator('.ant-select-dropdown:visible').getByText(/装修服务 · 不限地区/).click();
|
|
const second = await analyze(); assert.equal(second.status, 'completed'); assert.equal(second.context.business_brief_id, other.id); assert.equal(second.results[0].match, 'matched'); assert.equal(second.results[0].next_step, 'review_author_need'); assert.notEqual(first.id, second.id);
|
|
await closeAnalysis();
|
|
// Unsupported and failed merchant drafts stay visible, never become an execution context.
|
|
const unsupported = await submitBrief('我卖手机,想在抖音找买手机的人'); assert.equal(unsupported.status, 'unsupported'); await briefDrawer.getByTestId('business-brief-unsupported').waitFor(); assert.equal(await briefDrawer.getByTestId('business-brief-confirm-form').count(), 0);
|
|
const failedBrief = await submitBrief('我是做装修服务的,这次解析请求失败样本', 'allfail'); assert.equal(failedBrief.status, 'failed'); assert.equal(await briefDrawer.getByTestId('business-brief-confirm-form').count(), 0); await briefDrawer.getByRole('button', { name: '关闭商家需求', exact: true }).click(); await briefDrawer.waitFor({ state: 'hidden' });
|
|
assert.equal(fixtureRequests, platformBeforeAnalysis);
|
|
// Add two frozen legacy records only to this random-schema fixture; never edit existing records.
|
|
const legacyFile = path.join(output, 'legacy-fixture.json');
|
|
const legacy = [1, 2].map(version => { const value = JSON.parse(JSON.stringify(first)); value.id = `analysis_legacy_v${version}`; value.prompt_version = `renovation-demand-v${version}`; value.result_version = 'demand-result-v1'; delete value.context; for (const source of value.sources) delete source.context_refs; for (const result of value.results) for (const key of ['role', 'role_evidence', 'expressions', 'business_relevance', 'match', 'match_reasons', 'next_step', 'consultation', 'lead_disposition']) delete result[key]; return value; });
|
|
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; store=PostgresStore(Path(os.environ["ASSISTANT_LEARNING_HOME"]),os.environ["ASSISTANT_DATABASE_URL"],schema=os.environ["ASSISTANT_DATABASE_SCHEMA"]); records=json.loads(Path(sys.argv[1]).read_text()); owner=store.one("SELECT owner_id FROM reading_runs WHERE id=$1",(records[0]["reading_id"],))["owner_id"]; [store.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"])) for r in records]', 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 card.getByRole('button', { name: '刷新分析记录', exact: true }).click(); await history.locator(`tr[data-row-key="${legacy[0].id}"]`).waitFor();
|
|
const callsBeforeViewing = modelCalls.length;
|
|
async function openHistory(record) { await closeAnalysis(); await history.locator(`tr[data-row-key="${record.id}"]`).getByRole('button', { name: '查看分析', exact: true }).click(); await shownAnalysis(record); }
|
|
for (const record of [first, legacy[0], legacy[1], first, second, first]) { await openHistory(record); assert.equal(await drawer.getByTestId('demand-result-axes').count(), record.context ? 4 : 0); }
|
|
await page.setViewportSize({ width: 980, height: 760 }); await visibleInViewport(drawer.getByText('逐项需求与原文依据', { exact: true }));
|
|
const bounds = await drawer.evaluate(element => { const content = element.closest('.ant-drawer-content') || element.querySelector('.ant-drawer-content') || element; const rect = content.getBoundingClientRect(); return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, overflow: [...content.querySelectorAll('[data-testid="demand-analysis-detail"], [data-testid="demand-analysis-results"], [data-testid="demand-source-result"]')].filter(item => item.scrollWidth > item.clientWidth + 2).length }; });
|
|
assert.ok(bounds.left >= -1 && bounds.right <= 981 && bounds.top >= -1 && bounds.bottom <= 761); assert.equal(bounds.overflow, 0); await page.screenshot({ path: path.join(output, 'analysis-small-screen.png'), fullPage: false }); await closeAnalysis();
|
|
const analyses = await client.request('GET', `/api/discovery/readings/${reading.id}/analyses`), briefs = await client.request('GET', '/api/business/briefs'); assertNoSecrets(analyses); assertNoSecrets(briefs); assert.equal(analyses.items.length, 4);
|
|
assert.deepEqual(await client.request('GET', `/api/discovery/readings/${reading.id}`), reading); assert.deepEqual(await client.request('GET', `/api/discovery/runs/${run.id}`), run); assert.deepEqual(await client.request('GET', `/api/business/briefs/${draft.id}`), draft);
|
|
await stop(backend); assert.equal(backend.exitCode, 0); backend = launch(pythonPath(), backendArgs, 'server-restart'); await ready(apiBase, backend); await page.reload(); await history.locator(`tr[data-row-key="${first.id}"]`).waitFor(); await openHistory(legacy[0]);
|
|
assert.deepEqual(await client.request('GET', `/api/discovery/readings/${reading.id}/analyses`), analyses); assert.deepEqual(await client.request('GET', '/api/business/briefs'), briefs); assert.equal(modelCalls.length, callsBeforeViewing); assert.equal(fixtureRequests, platformBeforeAnalysis); assert.equal(receivedWrites, 0); assert.deepEqual(errors, []); assert.deepEqual(providerErrors, []);
|
|
const summary = { result: 'passed', database_backend: health.database_backend, discovery_run_id: run.id, reading_id: reading.id, brief_ids: [draft.id, confirmed.id, other.id, unsupported.id, failedBrief.id], analysis_ids: analyses.items.map(item => item.id), merchant_missing_fields_shown: true, explicit_confirmation_appends: true, parse_reused: true, first_term_fixed_route: true, discovery_and_reading_brief_bound: true, multi_axis_grounded_results: true, context_change_separate_analysis: true, same_context_analysis_reused: true, unsupported_and_failed_not_confirmable: true, legacy_v1_v2_history_visible: true, history_reopens_visible_drawer: true, next_steps_unexecuted: true, viewing_zero_model_calls: true, model_calls: modelCalls, no_platform_requests_during_parse_or_analysis: true, platform_received_writes: receivedWrites, immutable_history_restart: true, small_screen_bounds: bounds, private_material_absent: true, 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('Business workbench 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)); }
|