Enhance Beryl Agent detail and report views
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
export const normalize = text => String(text ?? "").replace(/\s+/g, "").toLowerCase();
|
||||
export function checkContent({ purpose, task, template, originalTexts, finalText }) {
|
||||
if (purpose !== "final") return [];
|
||||
const failures = [];
|
||||
if (!task || task.kind !== "pptx" || task.status !== "pending") failures.push("No active PPT delivery task");
|
||||
if (task?.template && template !== task.template) failures.push("Requested template not used");
|
||||
for (const term of task?.requiredTerms ?? []) if (!normalize(finalText).includes(normalize(term))) failures.push(`Missing requirement: ${term}`);
|
||||
for (const text of originalTexts) if (normalize(finalText).includes(normalize(text))) failures.push(`Unreplaced template content: ${text.slice(0,100)}`);
|
||||
if (template === "swads" && originalTexts.length === 0) failures.push("Template content could not be audited");
|
||||
return failures;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { checkContent } from "./ppt-acceptance.mjs";
|
||||
const input = { purpose: "final", template: "swads", task: { kind: "pptx", status: "pending", template: "swads", requiredTerms: ["Ozon", "100", "彩妆"] }, originalTexts: ["糖果原始产品的完整定位与投放方案"], finalText: "Ozon 100元彩妆" };
|
||||
test("single replacement cannot pass while source content remains", () => assert.ok(checkContent({ ...input, finalText: input.finalText + input.originalTexts[0] }).length));
|
||||
test("wrong topic cannot pass", () => assert.ok(checkContent({ ...input, finalText: "糖果" }).length));
|
||||
test("wrong template cannot pass", () => assert.ok(checkContent({ ...input, template: "plain" }).length));
|
||||
test("covered text requirements pass", () => assert.deepEqual(checkContent(input), []));
|
||||
test("unbound final artifact cannot pass", () => assert.ok(checkContent({ ...input, task: null }).length));
|
||||
@@ -0,0 +1,83 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
import { checkContent, normalize } from "./ppt-acceptance.mjs";
|
||||
const [action, dir, reference, runtime, skill] = process.argv.slice(2);
|
||||
process.env.RUNTIME_NODE_MODULES = path.join(runtime, "node/node_modules");
|
||||
process.env.RUNTIME_PYTHON = path.join(runtime, "python/bin/python3");
|
||||
const { Presentation, PresentationFile, FileBlob } = await import(pathToFileURL(path.join(runtime, "node/node_modules/@oai/artifact-tool/dist/artifact_tool.mjs")));
|
||||
const { finalizePresentation, resolvePresentationFont } = await import(pathToFileURL(path.join(skill, "container_tools/artifact_tool_utils.mjs")));
|
||||
const input = JSON.parse(await fs.readFile(path.join(dir, "input.json"), "utf8"));
|
||||
const task = await fs.readFile(path.join(dir, "task.json"), "utf8").then(JSON.parse).catch(e => { if (e.code === "ENOENT") return null; throw e; });
|
||||
const purpose = input.purpose ?? "debug";
|
||||
let originalTexts = [];
|
||||
const templated = action === "inspect" || input.template === "swads";
|
||||
const deck = templated ? await PresentationFile.importPptx(await FileBlob.load(reference)) : Presentation.create({ slideSize: { width: 1280, height: 720 } });
|
||||
if (action === "inspect") {
|
||||
const snapshot = await deck.inspect({ kind: "slide,textbox,table", maxChars: 120000 });
|
||||
await fs.writeFile(path.join(dir, "result.json"), JSON.stringify({ template: "swads", snapshot: snapshot.ndjson }));
|
||||
process.exit(0);
|
||||
}
|
||||
if (templated) {
|
||||
if (!input.replacements?.length && !input.tableCells?.length) throw new Error("PPT_REPLACEMENTS_REQUIRED");
|
||||
const snapshot = await deck.inspect({ kind: "textbox,table", maxChars: 1000000 });
|
||||
const records = snapshot.ndjson.split("\n").filter(Boolean).map(line => JSON.parse(line));
|
||||
originalTexts = records.filter(r => r.kind === "textbox").map(r => r.text ?? r.textPreview ?? "").filter(t => normalize(t).length > 12);
|
||||
for (const change of input.replacements ?? []) {
|
||||
const record = records.find(r => r.id === change.id);
|
||||
if (!record || !(record.text ?? record.textPreview ?? "").includes(change.oldText)) throw new Error(`PPT_TEMPLATE_ANCHOR_MISMATCH: ${change.id}. Reinspect this object; do not generate subset test files as final.`);
|
||||
deck.resolve(change.id).text.replace(change.oldText, change.newText);
|
||||
}
|
||||
for (const cell of input.tableCells ?? []) {
|
||||
const record = records.find(r => r.id === cell.id && r.kind === "table");
|
||||
if (!record) throw new Error("PPT_TABLE_ANCHOR_MISMATCH");
|
||||
deck.resolve(cell.id).cells.set(cell.row, cell.column, cell.text);
|
||||
}
|
||||
} else {
|
||||
if (!input.slides?.length) throw new Error("PPT_SLIDES_REQUIRED");
|
||||
const font = resolvePresentationFont();
|
||||
for (const data of input.slides) {
|
||||
const slide = deck.slides.add();
|
||||
slide.background.fill = "#FAFAF7";
|
||||
for (const [text, top, height, size, bold] of [[data.title, 60, 100, 44, true], [data.body, 190, 440, 28, false]]) {
|
||||
const shape = slide.shapes.add({ geometry: "textbox", position: { left: 80, top, width: 1120, height }, fill: "none", line: { fill: "none", width: 0 } });
|
||||
shape.text = text;
|
||||
shape.text.style = { typeface: font, fontSize: size, color: "#17372B", bold, autoFit: "none" };
|
||||
}
|
||||
slide.speakerNotes.textFrame.setText(data.sources ?? "来源由调用方提供;未提供外部来源。");
|
||||
}
|
||||
}
|
||||
await fs.mkdir(path.join(dir, "output"));
|
||||
const candidate = path.join(dir, "candidate.pptx");
|
||||
await (await PresentationFile.exportPptx(deck)).save(candidate);
|
||||
const finalPath = path.join(dir, "output/deck.pptx");
|
||||
await finalizePresentation({ workspaceDir: dir, candidatePath: candidate, finalPath,
|
||||
pythonExecutable: path.join(runtime, "python/bin/python3"),
|
||||
integrityValidatorPath: path.join(skill, "container_tools/inspect_presentation_package_integrity.py"),
|
||||
layoutValidatorPath: path.join(skill, "container_tools/inspect_presentation_layout_geometry.py"),
|
||||
layoutArgs: ["--validate-heading-fit"], verifyArtifactToolImport: true,
|
||||
receiptPath: path.join(dir, "validation.json"),
|
||||
});
|
||||
const finalDeck = await PresentationFile.importPptx(await FileBlob.load(finalPath));
|
||||
let count = 0;
|
||||
for (const slide of finalDeck.slides.items) {
|
||||
const png = await finalDeck.export({ slide, format: "png", scale: 1 });
|
||||
await fs.writeFile(path.join(dir, `slide-${++count}.png`), new Uint8Array(await png.arrayBuffer()));
|
||||
}
|
||||
if (!count) throw new Error("PPT_EMPTY");
|
||||
const bytes = await fs.readFile(finalPath);
|
||||
const finalSnapshot = await finalDeck.inspect({ kind: "textbox,table", maxChars: 1000000 });
|
||||
await fs.writeFile(path.join(dir, "content.ndjson"), finalSnapshot.ndjson);
|
||||
const finalText = normalize(finalSnapshot.ndjson.split("\n").filter(Boolean).map(line => {
|
||||
const r = JSON.parse(line); return r.text ?? r.textPreview ?? JSON.stringify(r);
|
||||
}).join("\n"));
|
||||
const failures = checkContent({ purpose, task, template: input.template, originalTexts, finalText });
|
||||
await fs.writeFile(path.join(dir, "acceptance.json"), JSON.stringify({ purpose, runId: task?.runId, taskRevision: task?.revision,
|
||||
passed: purpose === "final" && failures.length === 0, failures,
|
||||
limits: "Text coverage and explicit task terms only; embedded images and factual accuracy are not semantically verified." }, null, 2));
|
||||
if (failures.length) throw new Error(`PPT_CONTENT_REJECTED: ${failures.slice(0,8).join("; ")}`);
|
||||
await fs.writeFile(path.join(dir, "result.json"), JSON.stringify({ title: input.title, slideCount: count, validated: true,
|
||||
purpose, deliveryAccepted: false, runId: task?.runId, taskRevision: task?.revision,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"), template: input.template,
|
||||
note: "Package, layout checks and rendering passed. Business facts and visual quality require review." }));
|
||||
@@ -6,24 +6,54 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Expose only built assets and the application API, with a temporary password.
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../apps/web/dist');
|
||||
const password = process.env.PREVIEW_PASSWORD || randomBytes(12).toString('base64url');
|
||||
const credential = Buffer.from('Basic ' + Buffer.from(`beryl:${password}`).toString('base64'));
|
||||
export function createPreviewServer({password=process.env.PREVIEW_PASSWORD || randomBytes(12).toString('base64url')}={}) {
|
||||
const sessions = new Map();
|
||||
let attempts = [];
|
||||
const equal = (a, b) => { const x=Buffer.from(a), y=Buffer.from(b); return x.length===y.length && timingSafeEqual(x,y); };
|
||||
const loginPage = (failed=false) => `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Beryl Agent · 测试登录</title><style>body{font:16px system-ui;background:#f5f7fa;color:#203040;display:grid;place-items:center;min-height:95vh;margin:0}main{background:white;padding:36px;border-radius:16px;width:min(340px,80vw);box-shadow:0 8px 40px #1231}input,button{box-sizing:border-box;width:100%;padding:12px;margin-top:16px;border-radius:8px;border:1px solid #ccd4da}button{background:#203040;color:white;cursor:pointer}p{line-height:1.6}small{color:#687684}</style><main><h1>Beryl Agent</h1><p>受密码保护的临时测试入口</p>${failed?'<p role="alert">密码不正确,请重试。</p>':''}<form method="post" action="/__preview/login"><label for="password">访问密码</label><input id="password" name="password" type="password" required autocomplete="current-password" maxlength="128"><button type="submit">登录 Agent</button></form><p><small>仅供受邀测试者使用。登录有效期 8 小时。</small></p></main></html>`;
|
||||
const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' };
|
||||
http.createServer(async (req, res) => {
|
||||
const supplied = Buffer.from(req.headers.authorization || '');
|
||||
if (supplied.length !== credential.length || !timingSafeEqual(supplied, credential)) {
|
||||
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Beryl preview"', 'Cache-Control': 'no-store' });
|
||||
return res.end('Authentication required');
|
||||
}
|
||||
if (req.headers.origin && req.headers.origin !== `https://${req.headers.host}` && req.headers.origin !== `http://${req.headers.host}`) {
|
||||
return http.createServer(async (req, res) => {
|
||||
res.setHeader('Cache-Control','no-store');
|
||||
res.setHeader('X-Content-Type-Options','nosniff');
|
||||
res.setHeader('Referrer-Policy','no-referrer');
|
||||
res.setHeader('X-Frame-Options','DENY');
|
||||
const externalLoginNavigation=req.method==='GET' && ['/', '/__preview/login'].includes(req.url) && req.headers['sec-fetch-site']==='cross-site' && req.headers['sec-fetch-dest']==='document';
|
||||
if(externalLoginNavigation) {res.writeHead(200,{'Content-Type':'text/html; charset=utf-8'});return res.end(loginPage());}
|
||||
if ((req.headers.origin && req.headers.origin !== `https://${req.headers.host}`) || req.headers['sec-fetch-site']==='cross-site' || (!['GET','HEAD'].includes(req.method) && req.headers.origin !== `https://${req.headers.host}`)) {
|
||||
res.writeHead(403); return res.end('Origin denied');
|
||||
}
|
||||
let pathname;
|
||||
try { pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); }
|
||||
catch { res.writeHead(400); return res.end(); }
|
||||
if(pathname==='/__preview/login' && req.method==='POST') {
|
||||
attempts=attempts.filter(t=>Date.now()-t<60000);
|
||||
if(attempts.length>=10) {res.writeHead(429,{'Retry-After':'60'});return res.end('Too many attempts; retry in one minute');}
|
||||
attempts.push(Date.now());
|
||||
if(!req.headers['content-type']?.startsWith('application/x-www-form-urlencoded')) {res.writeHead(415);return res.end();}
|
||||
let body='';
|
||||
try {for await(const chunk of req) {body+=chunk;if(Buffer.byteLength(body)>2048){res.writeHead(413);res.end();return;}}} catch {res.writeHead(400);return res.end();}
|
||||
if(!equal(new URLSearchParams(body).get('password')??'',password)) {res.writeHead(401,{'Content-Type':'text/html; charset=utf-8'});return res.end(loginPage(true));}
|
||||
for(const [key,expiry] of sessions) if(expiry<Date.now()) sessions.delete(key);
|
||||
if(sessions.size>=100) {res.writeHead(503);return res.end('Session limit reached');}
|
||||
const token=randomBytes(32).toString('base64url'); sessions.set(token,Date.now()+8*3600000);
|
||||
res.writeHead(303,{'Location':'/','Set-Cookie':`__Host-beryl_preview=${token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=28800`});return res.end();
|
||||
}
|
||||
const token=(req.headers.cookie??'').split(';').map(x=>x.trim()).find(x=>x.startsWith('__Host-beryl_preview='))?.slice('__Host-beryl_preview='.length);
|
||||
if(!token || (sessions.get(token)??0)<=Date.now()) {
|
||||
if((pathname==='/'||pathname==='/__preview/login')&&['GET','HEAD'].includes(req.method)) {res.writeHead(200,{'Content-Type':'text/html; charset=utf-8'});return res.end(req.method==='HEAD'?undefined:loginPage());}
|
||||
res.writeHead(401,{'Content-Type':'application/json'});return res.end(JSON.stringify({error:'PREVIEW_LOGIN_REQUIRED',message:'请打开网站首页输入访问密码'}));
|
||||
}
|
||||
if(pathname==='/__preview/logout' && req.method==='POST') {
|
||||
sessions.delete(token);
|
||||
res.writeHead(303,{'Location':'/','Set-Cookie':'__Host-beryl_preview=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0'});return res.end();
|
||||
}
|
||||
if(pathname==='/__preview/login' && req.method==='GET') {
|
||||
res.writeHead(200,{'Content-Type':'text/html; charset=utf-8'});return res.end('<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>测试访问管理</title><h1>已登录 Beryl Agent</h1><p><a href="/">进入 Agent</a></p><form method="post" action="/__preview/logout"><button>退出登录</button></form></html>');
|
||||
}
|
||||
if (pathname.startsWith('/api/') || pathname === '/health') {
|
||||
const headers = { ...req.headers, host: 'localhost:3001' };
|
||||
delete headers.authorization;
|
||||
delete headers.cookie;
|
||||
const upstream = http.request({ hostname: '127.0.0.1', port: 3001, path: req.url, method: req.method, headers }, response => {
|
||||
const safeHeaders = { ...response.headers, 'cache-control': 'no-store' };
|
||||
delete safeHeaders['access-control-allow-origin'];
|
||||
@@ -46,8 +76,13 @@ http.createServer(async (req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': types[path.extname(file)] || 'application/octet-stream', 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'no-store' });
|
||||
res.end(req.method === 'HEAD' ? undefined : data);
|
||||
} catch { res.writeHead(404); res.end('Not found'); }
|
||||
}).listen(4173, '127.0.0.1', () => {
|
||||
});
|
||||
}
|
||||
if(process.argv[1] && path.resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
|
||||
const password=process.env.PREVIEW_PASSWORD || randomBytes(12).toString('base64url');
|
||||
createPreviewServer({password}).listen(4173, '127.0.0.1', () => {
|
||||
console.log('Preview listening on 127.0.0.1:4173');
|
||||
console.log('Username: beryl');
|
||||
console.log('Login: password form (no username required)');
|
||||
console.log('Password: ' + password);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPreviewServer } from './share-preview.mjs';
|
||||
|
||||
async function fixture(t) {
|
||||
const server=createPreviewServer({password:'test-password'});
|
||||
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
|
||||
t.after(()=>{server.closeAllConnections();return new Promise(resolve=>server.close(resolve));});
|
||||
const base=`http://127.0.0.1:${server.address().port}`;
|
||||
const request=(url,options={})=>fetch(base+url,{...options,redirect:'manual'});
|
||||
const origin=base.replace('http:','https:');
|
||||
const login=async()=>{const r=await request('/__preview/login',{method:'POST',headers:{origin,'content-type':'application/x-www-form-urlencoded'},body:'password=test-password'});assert.equal(r.status,303);const cookie=r.headers.get('set-cookie');assert.match(cookie,/HttpOnly; Secure; SameSite=Strict/);return cookie.split(';')[0];};
|
||||
return {request,origin,login};
|
||||
}
|
||||
test('external link opens login page but cross-site API remains blocked',async t=>{
|
||||
const {request}=await fixture(t);
|
||||
const r=await request('/',{headers:{'sec-fetch-site':'cross-site','sec-fetch-mode':'navigate','sec-fetch-dest':'document'}});
|
||||
assert.equal(r.status,200);assert.match(await r.text(),/访问密码/);
|
||||
assert.equal((await request('/api/monitoring',{headers:{'sec-fetch-site':'cross-site'}})).status,403);
|
||||
});
|
||||
test('anonymous, forged cookie and wrong password never grant access',async t=>{
|
||||
const {request,origin}=await fixture(t);
|
||||
assert.equal((await request('/api/monitoring')).status,401);
|
||||
assert.equal((await request('/api/monitoring',{headers:{cookie:'__Host-beryl_preview=forged'}})).status,401);
|
||||
const r=await request('/__preview/login',{method:'POST',headers:{origin,'content-type':'application/x-www-form-urlencoded'},body:'password=wrong'});
|
||||
assert.equal(r.status,401);assert.equal(r.headers.get('set-cookie'),null);
|
||||
});
|
||||
test('logout revokes session and cannot be triggered cross-site',async t=>{
|
||||
const {request,origin,login}=await fixture(t),cookie=await login();
|
||||
assert.equal((await request('/__preview/logout',{method:'POST',headers:{cookie,origin:'https://evil.example'}})).status,403);
|
||||
const r=await request('/__preview/logout',{method:'POST',headers:{cookie,origin}});
|
||||
assert.equal(r.status,303);assert.match(r.headers.get('set-cookie'),/Max-Age=0/);
|
||||
assert.equal((await request('/api/monitoring',{headers:{cookie}})).status,401);
|
||||
});
|
||||
test('login attempts are bounded and foreign origin cannot login',async t=>{
|
||||
const {request,origin}=await fixture(t);
|
||||
assert.equal((await request('/__preview/login',{method:'POST',headers:{origin:'https://evil.example'}})).status,403);
|
||||
for(let i=0;i<10;i++)await request('/__preview/login',{method:'POST',headers:{origin,'content-type':'application/x-www-form-urlencoded'},body:'password=wrong'});
|
||||
assert.equal((await request('/__preview/login',{method:'POST',headers:{origin,'content-type':'application/x-www-form-urlencoded'},body:'password=test-password'})).status,429);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { SessionRegistry } from "../packages/harness/src/session.js";
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
process.loadEnvFile(path.join(root, ".env"));
|
||||
const registry = new SessionRegistry(root);
|
||||
const session = await registry.create();
|
||||
await registry.models.configure(session.sessionId, {
|
||||
providerId: "anthropic", api: "anthropic-messages",
|
||||
baseUrl: process.env.LLM_BASE_URL!, modelId: process.env.AGENT_MODEL!,
|
||||
apiKey: process.env.ANTHROPIC_API_KEY!, supportsImages: false,
|
||||
});
|
||||
const toolNames: string[] = [];
|
||||
let skillRead = false;
|
||||
let reportRead = false;
|
||||
registry.events.subscribe(session.sessionId, (event) => {
|
||||
if (event.type === "approval.required") {
|
||||
if (event.payload.toolName === "save_report_draft")
|
||||
registry.approvals.resolve(String(event.payload.approvalId), "approved");
|
||||
else registry.approvals.resolve(String(event.payload.approvalId), "denied");
|
||||
}
|
||||
if (event.type === "tool.requested") {
|
||||
const args = event.payload.arguments as { path?: string };
|
||||
if (event.payload.toolName === "read" && args.path?.includes("demo-report/SKILL.md")) skillRead = true;
|
||||
if (event.payload.toolName === "read" && args.path?.endsWith("report.md")) reportRead = true;
|
||||
}
|
||||
if (event.type === "tool.completed") toolNames.push(String(event.payload.toolName));
|
||||
});
|
||||
try {
|
||||
await registry.run(session, "请使用 demo-report Skill,读取 demo-account 最近7天演示数据,分析 Campaign,生成含3条建议的报告草稿。保存后读取报告并核对指标,明确验收是否通过。只使用演示数据,不调用网络或真实账户工具。");
|
||||
const deadline = Date.now() + 240_000;
|
||||
while (session.busy && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 500));
|
||||
if (session.busy) { await registry.cancel(session.sessionId); throw new Error("DEMO_TIMEOUT"); }
|
||||
const report = await readFile(path.join(session.workspace.root, "report.md"), "utf8");
|
||||
const normalized = report.replace(/[,,]/g, "");
|
||||
const checks = {
|
||||
skillRead, metricsCalled: toolNames.includes("mock_ads_metrics"),
|
||||
reportSaved: toolNames.includes("save_report_draft"), reportRead,
|
||||
demoLabel: report.includes("演示"),
|
||||
metrics: ["12400", "860000", "17200", "620", "37200"].every(n => normalized.includes(n)),
|
||||
campaign: report.includes("Prospecting Video"),
|
||||
ctr: /2(?:\.0+)?\s*%/.test(report),
|
||||
cpa: /CPA[\s\S]{0,80}?20/i.test(report),
|
||||
roas: /ROAS[\s\S]{0,80}?3/i.test(report),
|
||||
completed: registry.events.listAfter(session.sessionId, 0).some(e => e.type === "run.completed"),
|
||||
noToolErrors: !registry.events.listAfter(session.sessionId, 0).some(e => e.type === "tool.failed"),
|
||||
};
|
||||
console.log(JSON.stringify({ checks, tools: toolNames, report: path.join(session.workspace.root, "report.md") }, null, 2));
|
||||
if (!Object.values(checks).every(Boolean)) process.exitCode = 1;
|
||||
} finally { session.agent?.dispose(); }
|
||||
@@ -0,0 +1,29 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildApp } from "../apps/server/src/app.js";
|
||||
import type { SessionRegistry } from "../packages/harness/src/session.js";
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
process.loadEnvFile(path.join(root, ".env"));
|
||||
const app = await buildApp();
|
||||
const registry = (app as unknown as { registry: SessionRegistry }).registry;
|
||||
const session = await registry.create();
|
||||
await registry.models.configure(session.sessionId, {
|
||||
providerId: "anthropic", api: "anthropic-messages", baseUrl: process.env.LLM_BASE_URL!,
|
||||
modelId: process.env.AGENT_MODEL!, apiKey: process.env.ANTHROPIC_API_KEY!, supportsImages: true,
|
||||
});
|
||||
registry.events.subscribe(session.sessionId, e => {
|
||||
if (e.type === "approval.required") registry.approvals.resolve(String(e.payload.approvalId), e.payload.toolName === "generate_pptx" ? "approved" : "denied");
|
||||
});
|
||||
try {
|
||||
await registry.run(session, "请生成一页测试PPT,标题为Beryl文件交付测试,正文为本页仅用于验证PPT生成与下载,不包含真实业务数据。直接使用generate_pptx的plain模式和purpose=final,不调用网络或调研工具。不要使用模板。没有商业事实,无需外部来源。");
|
||||
const deadline = Date.now() + 300_000;
|
||||
while (session.busy && Date.now() < deadline) await new Promise(r => setTimeout(r, 500));
|
||||
if (session.busy) { await registry.cancel(session.sessionId); throw new Error("TIMEOUT"); }
|
||||
const events = registry.events.listAfter(session.sessionId);
|
||||
const saved = events.find(e => e.type === "tool.completed" && e.payload.toolName === "generate_pptx");
|
||||
const id = (saved?.payload.result as { details?: { artifactId?: string } })?.details?.artifactId;
|
||||
if (!id || !events.some(e => e.type === "run.completed")) throw new Error("PPT_RUN_FAILED");
|
||||
const response = await app.inject({ url: `/api/sessions/${session.sessionId}/presentations/${id}` });
|
||||
if (response.statusCode !== 200 || !response.headers["content-disposition"]?.includes("attachment")) throw new Error("DOWNLOAD_FAILED");
|
||||
console.log(JSON.stringify({ passed: true, model: process.env.AGENT_MODEL, downloadStatus: response.statusCode, bytes: response.rawPayload.length, directory: session.workspace.resolve(`presentations/${id}`) }));
|
||||
} finally { session.agent?.dispose(); await app.close(); }
|
||||
@@ -0,0 +1,34 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { SafeWorkspace } from "../packages/harness/src/workspace.js";
|
||||
import { createPptTools, validatedPpt } from "../packages/harness/src/ppt-tools.js";
|
||||
import { saveTask, updateTask } from "../packages/harness/src/task.js";
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const workspace = await SafeWorkspace.create(path.join(root, "workspace/sessions"), `ppt-test-${Date.now()}`);
|
||||
// Explicit stub: verifies generation/plumbing, not model review quality.
|
||||
const tools = createPptTools(workspace, root, async () => ({ passed: true, reasons: [] }));
|
||||
await saveTask(workspace, updateTask(undefined, "生成测试 PPT", "verification-run")!);
|
||||
if (process.argv.includes("--template")) {
|
||||
const inspected = await tools[0]!.execute("inspect-test", {}, undefined, undefined, {} as never);
|
||||
const records = String(inspected.details.snapshot).split("\n").filter(Boolean).map(line => JSON.parse(line));
|
||||
const first = records.find(r => r.kind === "textbox" && r.text && r.text.length > 2 && r.text.length < 50);
|
||||
if (!first) throw new Error("NO_TEMPLATE_TEXT");
|
||||
const table = records.find(r => r.kind === "table");
|
||||
const output = await tools[1]!.execute("template-test", { title: "模板验证副本", template: "swads", replacements: [{ id: first.id, oldText: first.text, newText: "模板验证" }], tableCells: table ? [{ id: table.id, row: 1, column: 0, text: "测试竞品" }] : [] }, undefined, undefined, {} as never);
|
||||
console.log(JSON.stringify({ ...output.details, directory: workspace.resolve(`presentations/${output.details.artifactId}`) }));
|
||||
try { await validatedPpt(workspace, output.details.artifactId); throw new Error("DEBUG_WAS_ACCEPTED"); }
|
||||
catch (e) { if (!(e instanceof Error) || e.message !== "PPT_NOT_ACCEPTED") throw e; }
|
||||
try {
|
||||
await tools[1]!.execute("partial-final-test", { purpose: "final", title: "局部替换不可交付", template: "swads", replacements: [{ id: first.id, oldText: first.text, newText: "局部测试" }] }, undefined, undefined, {} as never);
|
||||
throw new Error("PARTIAL_WAS_ACCEPTED");
|
||||
} catch (e) { if (!(e instanceof Error) || !e.message.includes("PPT_CONTENT_REJECTED")) throw e; }
|
||||
console.log("PASS: debug download rejected; partial final rejected");
|
||||
process.exit(0);
|
||||
}
|
||||
const result = await tools[1]!.execute("test", { purpose: "final", title: "Beryl PPT 能力验收", template: "plain", slides: [
|
||||
{ title: "Beryl PPT 能力验收", body: "本页使用测试内容,不包含客户数据。\n文件应保留可编辑标题和正文。", sources: "本地功能测试,无外部事实。" },
|
||||
{ title: "交付检查", body: "实际生成 PowerPoint 文件\n校验文件结构并重新导入\n逐页渲染并提供下载入口", sources: "测试检查项。" },
|
||||
] }, undefined, undefined, {} as never);
|
||||
const id = result.details.artifactId;
|
||||
const bytes = await validatedPpt(workspace, id);
|
||||
console.log(JSON.stringify({ ...result.details, bytes: bytes.length, directory: workspace.resolve(`presentations/${id}`) }));
|
||||
Reference in New Issue
Block a user