You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
chajia/scripts/benchmark-human-doc-10pairs.ts

457 lines
15 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/usr/bin/env tsx
/**
* 人工 docx 10 组 — RPA kernel 直调 + 人工价对比(开发调试用)
* 系统 API 验收请用npm run probe:human-doc-system
*
* npm run benchmark:human-doc-10pairs
*/
import { randomUUID } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { loadDevEnv } from "@/lib/dev-env";
import { hostFetchMothershipCandidates } from "@/lib/frontend/api-client";
import { applyMothershipCandidate } from "@/lib/frontend/mothership-address";
import { RPAContext } from "@/workers/rpa/kernel/context";
import { runMothershipQuoteKernel } from "@/workers/rpa/kernel/orchestrator";
import {
stabilizeQuoteLandingPage,
waitForQuoterWidgetHydrated,
} from "@/workers/rpa/page-prep";
import { closeBrowser, openQuotePage } from "@/workers/rpa/session-manager";
import { quotePageAdapter } from "@/workers/rpa/quote-page-adapter";
import type { QuoteItem, QuoteRequest } from "@/modules/providers/quote-provider";
import {
HUMAN_DOC_10_PAIRS,
HUMAN_DOC_CARGO,
type HumanDocPair,
} from "./lib/human-doc-10pairs";
process.env.RPA_MOCK_MODE = "false";
process.env.RPA_ADDRESS_MODE = "real";
/** 报价策略Direct 优先Widget 仅 fallback勿设 RPA_QUOTE_MODE=widget */
process.env.RPA_VISUAL_RUSH = process.env.RPA_VISUAL_RUSH ?? "false";
/** 默认与 benchmark 一致 rush 提交;设 RPA_HUMAN_SUBMIT_STRICT=true 启用 submit 前闸门 */
process.env.RPA_HUMAN_SUBMIT_STRICT = process.env.RPA_HUMAN_SUBMIT_STRICT ?? "false";
process.env.RPA_DUAL_ADDRESS_PATCH = process.env.RPA_DUAL_ADDRESS_PATCH ?? "true";
process.env.RPA_HEADLESS = process.env.RPA_HEADLESS ?? "true";
process.env.RPA_KEEP_BROWSER_OPEN = "false";
process.env.RPA_SLOW_MO_MS = "0";
process.env.RPA_HUMAN_PACING = "false";
process.env.RPA_BENCHMARK_QUOTE_MS = "45000";
process.env.RPA_REQUIRE_QUOTE_RATES = "true";
loadDevEnv();
const BASE = process.env.PROBE_BASE_URL ?? "http://localhost:3000";
const TOKEN = "demo-host-token";
const CUSTOMER_ID = "CUST_001";
/** 单档可接受≤12% 或 ≤$50 */
const TIER_OK_PCT = 0.12;
const TIER_OK_ABS = 50;
/** 触发重试:>20% 且 >$80 */
const TIER_RETRY_PCT = 0.2;
const TIER_RETRY_ABS = 80;
const MAX_ATTEMPTS = 3;
const CARGO = {
palletCount: HUMAN_DOC_CARGO.palletCount,
weightLb: HUMAN_DOC_CARGO.weightLb,
dimsIn: HUMAN_DOC_CARGO.dimsIn,
cargoType: HUMAN_DOC_CARGO.cargoType,
};
type HumanRef = HumanDocPair["humanQuotes"][number];
type HumanPair = HumanDocPair;
const PAIRS = HUMAN_DOC_10_PAIRS;
type TierCmp = {
key: string;
human: number;
rpa: number | null;
absDiff: number | null;
pctDiff: number | null;
status: "match" | "close" | "missing" | "retry";
};
type RowResult = {
id: string;
route: string;
pickup: {
input: { street: string; city: string; state: string };
selectedSuggestion: string;
optionId: string;
};
delivery: {
input: { street: string; city: string; state: string };
selectedSuggestion: string;
optionId: string;
};
cargo: typeof CARGO;
attempts: number;
timing: { totalMs: number; kernelMs: number };
comparisons: TierCmp[];
quotes: Array<{
serviceLevel: QuoteItem["serviceLevel"];
rateOption: QuoteItem["rateOption"];
rawTotal: number;
carrier: string;
transitDays: string;
}>;
result: { ok: boolean; compareOk: boolean; tierCount: number; finalUrl: string };
error?: string;
};
function tierKey(serviceLevel: string, rateOption: string): string {
return `${serviceLevel}/${rateOption}`;
}
function fmtMs(ms: number): string {
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const m = Math.floor(ms / 60_000);
const s = ((ms % 60_000) / 1000).toFixed(0);
return `${m}m${s}s`;
}
function fmtUsd(n: number): string {
return `$${n.toFixed(2)}`;
}
function draftAddress(d: { street: string; city: string; state: string }) {
return {
street: d.street.trim(),
city: d.city.trim(),
state: d.state.trim(),
zip: "",
place_id: `draft_${d.street}_${d.city}_${d.state}`.replace(/\s+/g, "_").slice(0, 64),
formatted_address: `${d.street}, ${d.city}, ${d.state}`,
selected_from_suggestions: true,
};
}
function compareToHuman(
humanQuotes: HumanRef[],
rpaItems: QuoteItem[],
): { comparisons: TierCmp[]; compareOk: boolean; shouldRetry: boolean } {
const rpaMap = new Map(
rpaItems.map((q) => [tierKey(q.serviceLevel, q.rateOption), q.rawTotal]),
);
const comparisons: TierCmp[] = [];
let shouldRetry = false;
for (const h of humanQuotes) {
const key = tierKey(h.serviceLevel, h.rateOption);
const rpa = rpaMap.get(key) ?? null;
if (rpa === null) {
comparisons.push({
key,
human: h.rawTotal,
rpa: null,
absDiff: null,
pctDiff: null,
status: "retry",
});
shouldRetry = true;
continue;
}
const absDiff = Math.abs(rpa - h.rawTotal);
const pctDiff = absDiff / h.rawTotal;
let status: TierCmp["status"] = "match";
if (pctDiff <= TIER_OK_PCT || absDiff <= TIER_OK_ABS) {
status = absDiff <= 1 ? "match" : "close";
} else if (pctDiff > TIER_RETRY_PCT && absDiff > TIER_RETRY_ABS) {
status = "retry";
shouldRetry = true;
} else {
status = "close";
}
comparisons.push({ key, human: h.rawTotal, rpa, absDiff, pctDiff, status });
}
return { comparisons, compareOk: !shouldRetry, shouldRetry };
}
async function waitForHttpOk(url: string, attempts = 20): Promise<void> {
for (let i = 0; i < attempts; i += 1) {
try {
const res = await fetch(url);
if (res.ok) return;
} catch {
/* retry */
}
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(`服务未就绪: ${url}`);
}
async function fetchCandidatesWithRetry(
pickup: ReturnType<typeof draftAddress>,
delivery: ReturnType<typeof draftAddress>,
attempts = 3,
) {
let lastErr = "候选 API 失败";
for (let i = 0; i < attempts; i += 1) {
try {
const cand = await hostFetchMothershipCandidates(
BASE,
TOKEN,
CUSTOMER_ID,
pickup,
delivery,
);
if (cand.code === 0 && cand.data?.pickup_candidates[0] && cand.data.delivery_candidates[0]) {
return cand;
}
lastErr = cand.message ?? lastErr;
} catch (e) {
lastErr = e instanceof Error ? e.message : String(e);
}
if (i < attempts - 1) await new Promise((r) => setTimeout(r, 2000));
}
return { code: -1, message: lastErr, data: null };
}
async function runOneCase(pair: HumanPair, attempt: number): Promise<RowResult> {
const caseStart = Date.now();
const userPickup = draftAddress(pair.pickup);
const userDelivery = draftAddress(pair.delivery);
const base = (): Omit<RowResult, "timing" | "comparisons" | "quotes" | "result" | "attempts"> => ({
id: pair.id,
route: pair.route,
pickup: {
input: { ...pair.pickup },
selectedSuggestion: "",
optionId: "",
},
delivery: {
input: { ...pair.delivery },
selectedSuggestion: "",
optionId: "",
},
cargo: { ...CARGO },
});
const cand = await fetchCandidatesWithRetry(userPickup, userDelivery);
if (cand.code !== 0 || !cand.data?.pickup_candidates[0] || !cand.data.delivery_candidates[0]) {
return {
...base(),
attempts: attempt,
timing: { totalMs: Date.now() - caseStart, kernelMs: 0 },
comparisons: [],
quotes: [],
result: { ok: false, compareOk: false, tierCount: 0, finalUrl: "" },
error: cand.message ?? "候选 API 失败",
};
}
const pc = cand.data.pickup_candidates[0];
const dc = cand.data.delivery_candidates[0];
const pickupApplied = applyMothershipCandidate(userPickup, pc);
const deliveryApplied = applyMothershipCandidate(userDelivery, dc);
const row = base();
row.pickup.selectedSuggestion = pc.display_label;
row.pickup.optionId = pc.option_id;
row.delivery.selectedSuggestion = dc.display_label;
row.delivery.optionId = dc.option_id;
const req: QuoteRequest = {
cargoHash: `human_${pair.id}_${randomUUID().slice(0, 8)}`,
pickup: {
street: pickupApplied.street,
city: pickupApplied.city,
state: pickupApplied.state,
zip: pickupApplied.zip ?? "",
placeId: pc.option_id,
formattedAddress: pickupApplied.formatted_address,
selectedFromSuggestions: true,
mothershipOptionId: pc.option_id,
mothershipDisplayLabel: pc.display_label,
selectedFromMothership: true,
},
delivery: {
street: deliveryApplied.street,
city: deliveryApplied.city,
state: deliveryApplied.state,
zip: deliveryApplied.zip ?? "",
placeId: dc.option_id,
formattedAddress: deliveryApplied.formatted_address,
selectedFromSuggestions: true,
mothershipOptionId: dc.option_id,
mothershipDisplayLabel: dc.display_label,
selectedFromMothership: true,
},
palletCount: CARGO.palletCount,
weightLb: CARGO.weightLb,
dimsIn: { l: CARGO.dimsIn.length, w: CARGO.dimsIn.width, h: CARGO.dimsIn.height },
cargoType: CARGO.cargoType,
};
let opened: Awaited<ReturnType<typeof openQuotePage>> | null = null;
try {
opened = await openQuotePage(quotePageAdapter, false, undefined, {
freshCargoContext: true,
});
const ctx = RPAContext.fromSession(opened.page, opened.context);
await stabilizeQuoteLandingPage(ctx);
await waitForQuoterWidgetHydrated(ctx.page, 90_000);
const kernelStart = Date.now();
const items = await runMothershipQuoteKernel(ctx, req);
const kernelMs = Date.now() - kernelStart;
const finalUrl = opened.page.url();
const { comparisons, compareOk, shouldRetry } = compareToHuman(pair.humanQuotes, items);
const quotes = items.map((q) => ({
serviceLevel: q.serviceLevel,
rateOption: q.rateOption,
rawTotal: q.rawTotal,
carrier: q.carrier,
transitDays: q.transitDays,
}));
const ok = items.length > 0;
return {
...row,
attempts: attempt,
timing: { totalMs: Date.now() - caseStart, kernelMs },
comparisons,
quotes,
result: { ok, compareOk, tierCount: items.length, finalUrl },
error: ok
? compareOk
? undefined
: shouldRetry
? "与人工报价偏差过大(链路已成功)"
: undefined
: "未抓到报价档位",
};
} catch (e) {
return {
...row,
attempts: attempt,
timing: { totalMs: Date.now() - caseStart, kernelMs: 0 },
comparisons: [],
quotes: [],
result: { ok: false, compareOk: false, tierCount: 0, finalUrl: opened?.page.url() ?? "" },
error: e instanceof Error ? e.message : String(e),
};
} finally {
await opened?.page.close().catch(() => undefined);
await opened?.context.close().catch(() => undefined);
}
}
async function runOneWithRetry(pair: HumanPair): Promise<RowResult> {
let last: RowResult | null = null;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
if (attempt > 1) {
const reason =
last?.quotes.length === 0
? "报价未抓到"
: last?.comparisons.some((c) => c.status === "retry")
? "与人工偏差过大"
: "链路失败";
console.log(`[human-bench] ${pair.id} ${reason},重试 ${attempt}/${MAX_ATTEMPTS}`);
await new Promise((r) => setTimeout(r, 3000));
}
last = await runOneCase(pair, attempt);
if (last.result.ok && last.quotes.length > 0) {
return last;
}
if (last.quotes.length === 0) continue;
}
return last!;
}
function printCmpLine(c: TierCmp): string {
if (c.rpa === null) return `${c.key}: 人工${fmtUsd(c.human)} RPA=缺失`;
const pct = c.pctDiff !== null ? `${(c.pctDiff * 100).toFixed(1)}%` : "—";
return `${c.key}: 人工${fmtUsd(c.human)} RPA${fmtUsd(c.rpa)} Δ${fmtUsd(c.absDiff ?? 0)}(${pct}) [${c.status}]`;
}
async function main(): Promise<void> {
const runId = randomUUID().slice(0, 8);
const startedAt = Date.now();
await waitForHttpOk(`${BASE}/`);
console.log(`[human-bench] 人工 docx 10 组 widget 对比 ${runId}`);
console.log(
`[human-bench] 链路: widget + visual-rush=${process.env.RPA_VISUAL_RUSH === "true"} dual-patch=${process.env.RPA_DUAL_ADDRESS_PATCH === "true"} | 货物: 2托 500lb 50×50×50 | 容忍 ≤${TIER_OK_PCT * 100}%或≤$${TIER_OK_ABS} | 重试 >${TIER_RETRY_PCT * 100}%且>$${TIER_RETRY_ABS}\n`,
);
const rows: RowResult[] = [];
const only = process.env.BENCH_HUMAN_ONLY?.trim();
const onlyIds = only
? only.split(/[,;]/).map((s) => s.trim()).filter(Boolean)
: null;
const pairs = onlyIds ? PAIRS.filter((p) => onlyIds.includes(p.id)) : PAIRS;
if (pairs.length === 0) {
throw new Error(`BENCH_HUMAN_ONLY 无效: ${only}`);
}
for (let i = 0; i < pairs.length; i += 1) {
const pair = pairs[i]!;
console.log(`[human-bench] ${i + 1}/${pairs.length} ${pair.route}`);
const result = await runOneWithRetry(pair);
rows.push(result);
console.log(
`${result.result.ok ? "OK" : "FAIL"} attempts=${result.attempts} tiers=${result.result.tierCount} ${fmtMs(result.timing.totalMs)}`,
);
console.log(` pickup: ${result.pickup.selectedSuggestion || "—"}`);
console.log(` delivery: ${result.delivery.selectedSuggestion || "—"}`);
for (const c of result.comparisons) {
console.log(` ${printCmpLine(c)}`);
}
if (result.error) console.log(` err: ${result.error}`);
}
await closeBrowser();
const pass = rows.filter((r) => r.result.ok).length;
const report = {
runId,
sourceDoc: "docs/美国跨州货物运输报价汇总_edited3.docx",
finishedAt: new Date().toISOString(),
durationMs: Date.now() - startedAt,
tolerance: { okPct: TIER_OK_PCT, okAbs: TIER_OK_ABS, retryPct: TIER_RETRY_PCT, retryAbs: TIER_RETRY_ABS },
summary: { pass, fail: rows.length - pass },
cargoTemplate: CARGO,
env: {
quoteStrategy: "direct-first-widget-fallback",
RPA_VISUAL_RUSH: process.env.RPA_VISUAL_RUSH === "true",
RPA_HUMAN_SUBMIT_STRICT: process.env.RPA_HUMAN_SUBMIT_STRICT === "true",
RPA_REQUIRE_QUOTE_RATES: true,
RPA_BENCHMARK_QUOTE_MS: 45_000,
RPA_HEADLESS: process.env.RPA_HEADLESS === "true",
},
rows,
};
const outDir = join(process.cwd(), ".rpa", "human-doc-benchmark");
mkdirSync(outDir, { recursive: true });
const outPath = join(outDir, `human-${runId}.json`);
writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log("\n| # | ID | Route | Result | Attempts | 比对 |");
console.log("|---|-----|-------|--------|----------|------|");
for (let i = 0; i < rows.length; i += 1) {
const r = rows[i]!;
const cmpSummary = r.comparisons
.map((c) => `${c.key.split("/")[1]}:${c.status === "match" ? "✓" : c.status === "close" ? "~" : "✗"}`)
.join(" ");
console.log(
`| ${i + 1} | ${r.id} | ${r.route} | ${r.result.ok ? "PASS" : "FAIL"} | ${r.attempts} | ${cmpSummary} |`,
);
}
console.log(`\n[human-bench] 报告: ${outPath}`);
process.exit(pass === rows.length ? 0 : 1);
}
main().catch(async (e) => {
console.error(e);
await closeBrowser().catch(() => undefined);
process.exit(1);
});