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/compare-human-rpa-network.ts

316 lines
10 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
/**
* 真人 vs RPA 网络差分(同一地址对,不新增 fix-place 策略)
*
* 默认对齐录证 5519e0ea293 Pike St Seattle → 826 Ocean Dr Miami Beach
*
* 用法:
* npm run compare:human-rpa-network
* $env:HUMAN_RUN_ID="5519e0ea"; $env:RPA_HEADLESS="false"; npm run compare:human-rpa-network
*/
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { loadDevEnv } from "@/lib/dev-env";
import { shouldKeepBrowserOpen } from "@/lib/rpa/env";
import { hostFetchMothershipCandidates } from "@/lib/frontend/api-client";
import { applyMothershipCandidate } from "@/lib/frontend/mothership-address";
import { MotherShipAddressAdapter } from "@/workers/rpa/address-adapter/mothership-address-adapter";
import { openAddressSide } from "@/workers/rpa/address-side";
import { RPAContext } from "@/workers/rpa/kernel/context";
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 { QuoteRequest } from "@/modules/providers/quote-provider";
import {
diffHumanRpaNetwork,
loadNetworkNdjson,
} from "./human-commit-baseline/network-diff";
import { NetworkLogger } from "./human-commit-baseline/network-logger";
process.env.RPA_MOCK_MODE = "false";
process.env.RPA_ADDRESS_MODE = "real";
loadDevEnv();
const HUMAN_RUN_ID = process.env.HUMAN_RUN_ID ?? "5519e0ea";
const BASE = process.env.PROVE_BASE_URL ?? "http://localhost:3000";
const TOKEN = "demo-host-token";
const CUSTOMER_ID = "CUST_001";
/** 与真人录证 5519e0ea 一致的用户输入(候选 API 会解析为具体 display_label */
const BASELINE_PAIR = {
label: "5519e0ea 基准Seattle Pike St → Miami Beach Ocean Dr",
pickup: { street: "293 Pike St", city: "Seattle", state: "WA" },
delivery: { street: "826 Ocean Dr", city: "Miami Beach", state: "FL" },
};
function draftAddress(d: { street: string; city: string; state: string }) {
const street = d.street.trim();
const city = d.city.trim();
const state = d.state.trim();
return {
street,
city,
state,
zip: "",
place_id: `draft_${street}_${city}_${state}`.replace(/\s+/g, "_").slice(0, 64),
formatted_address: `${street}, ${city}, ${state}`,
selected_from_suggestions: true,
};
}
async function selectSide(
ctx: RPAContext,
side: "pickup" | "delivery",
address: QuoteRequest["pickup"],
logger: NetworkLogger,
pickupAnchor?: QuoteRequest["pickup"],
): Promise<{ ok: boolean; error?: string; commitState?: string }> {
const widgetSelector = ctx.selectors.resolve("RPA_SELECTOR_QUOTE_WIDGET");
logger.setCheckpoint(side === "pickup" ? "rpa_pickup" : "rpa_delivery");
const street = await openAddressSide(ctx, side);
if (!street) {
return { ok: false, error: `${side} 输入框不可见` };
}
const adapter = new MotherShipAddressAdapter();
try {
await adapter.selectAddress({
ctx,
side,
address,
street,
widgetSelector,
pickupAnchor: side === "delivery" ? pickupAnchor : undefined,
});
await adapter.waitForCommit();
} catch (e) {
return {
ok: false,
error: e instanceof Error ? e.message : String(e),
commitState: adapter.getCommitState(),
};
}
const diag = adapter.getDiagnostics();
return {
ok: diag.placeRequestObserved && adapter.getCommitState() === "COMMITTED",
commitState: adapter.getCommitState(),
error: diag.placeRequestObserved ? undefined : "placeRequestObserved=false",
};
}
async function main(): Promise<void> {
const humanNetworkPath = join(
process.cwd(),
".rpa",
"human-commit-baseline",
HUMAN_RUN_ID,
"network.ndjson",
);
if (!existsSync(humanNetworkPath)) {
throw new Error(`找不到真人 network: ${humanNetworkPath}`);
}
const humanEvents = loadNetworkNdjson(humanNetworkPath);
const rpaRunId = randomUUID().slice(0, 8);
const outDir = join(
process.cwd(),
".rpa",
"human-commit-baseline",
HUMAN_RUN_ID,
`rpa-diff-${rpaRunId}`,
);
mkdirSync(outDir, { recursive: true });
const rpaNetworkPath = join(outDir, "rpa-network.ndjson");
console.log(`[diff] 真人基准: ${HUMAN_RUN_ID}`);
console.log(`[diff] RPA 录网: ${BASELINE_PAIR.label}`);
console.log(`[diff] 产出: ${outDir}`);
const userPickup = draftAddress(BASELINE_PAIR.pickup);
const userDelivery = draftAddress(BASELINE_PAIR.delivery);
const cand = await hostFetchMothershipCandidates(
BASE,
TOKEN,
CUSTOMER_ID,
userPickup,
userDelivery,
);
if (cand.code !== 0 || !cand.data?.pickup_candidates[0] || !cand.data.delivery_candidates[0]) {
throw new 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 pickup: QuoteRequest["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,
};
const delivery: QuoteRequest["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,
};
console.log(`[diff] 确认提货: ${pc.display_label}`);
console.log(`[diff] 确认派送: ${dc.display_label}`);
const logger = new NetworkLogger(rpaNetworkPath);
const opened = await openQuotePage(quotePageAdapter, false, undefined, {
freshCargoContext: true,
});
logger.setCheckpoint("rpa_page_loaded");
logger.attach(opened.page);
const ctx = RPAContext.fromSession(opened.page, opened.context);
console.log("[diff] 页面已打开,正在处理 Cookie 并滚动到报价区…");
await stabilizeQuoteLandingPage(ctx);
console.log(
"[diff] 等待 MotherShip 报价 widget 加载(含地图脚本,界面可能数十秒无输入,属正常)…",
);
const hydration = await waitForQuoterWidgetHydrated(ctx.page, 90_000);
if (hydration.hydrated) {
console.log(
`[diff] widget 已就绪(可编辑地址框 ${hydration.editableSearchInputs} 个)`,
);
} else {
console.warn(
`[diff] widget 未完全就绪,继续尝试:${hydration.widgetTextPreview.slice(0, 80)}`,
);
}
console.log("[diff] 开始填写提货地址…");
const rpaResults: Record<string, unknown> = {};
const pickupResult = await selectSide(ctx, "pickup", pickup, logger);
rpaResults.pickup = pickupResult;
console.log("[diff] RPA pickup:", pickupResult);
let deliveryResult: Awaited<ReturnType<typeof selectSide>> | null = null;
if (pickupResult.ok) {
console.log("[diff] 提货完成,开始填写派送地址…");
deliveryResult = await selectSide(ctx, "delivery", delivery, logger, pickup);
rpaResults.delivery = deliveryResult;
console.log("[diff] RPA delivery:", deliveryResult);
} else {
rpaResults.delivery = { skipped: true, reason: "pickup failed" };
}
logger.flush();
const dwellMs = Number(process.env.RPA_DWELL_MS ?? 0);
if (dwellMs > 0) {
console.log(`[diff] 完成后停留 ${dwellMs}ms 便于观察…`);
await opened.page.waitForTimeout(dwellMs).catch(() => undefined);
}
if (!shouldKeepBrowserOpen()) {
await opened.page.close().catch(() => undefined);
await opened.context.close().catch(() => undefined);
await closeBrowser();
} else {
console.log(
"[diff] 浏览器保持打开RPA_HEADLESS=false 默认行为;关闭窗口或 Ctrl+C 结束)",
);
}
const rpaEvents = loadNetworkNdjson(rpaNetworkPath);
const diff = diffHumanRpaNetwork(humanEvents, rpaEvents, HUMAN_RUN_ID, rpaRunId);
const humanPlaceIds = readFileSync(
join(process.cwd(), ".rpa", "human-commit-baseline", HUMAN_RUN_ID, "commit-moments.json"),
"utf8",
);
const report = {
finishedAt: new Date().toISOString(),
humanRunId: HUMAN_RUN_ID,
rpaRunId,
baselinePair: BASELINE_PAIR,
confirmedPickup: pc.display_label,
confirmedDelivery: dc.display_label,
humanCommitMoments: JSON.parse(humanPlaceIds),
rpaSelectResults: rpaResults,
diff,
artifacts: {
humanNetwork: humanNetworkPath,
rpaNetwork: rpaNetworkPath,
report: join(outDir, "diff-report.json"),
},
};
writeFileSync(
join(outDir, "diff-report.json"),
`${JSON.stringify(report, null, 2)}\n`,
"utf8",
);
console.log("\n════════ 差分摘要 ════════");
console.log(`verdict: ${diff.verdict}`);
console.log(
`place GET 200 — 真人: ${diff.human.placeGet200} | RPA: ${diff.rpa.placeGet200}`,
);
console.log(
`search→place 完整链 — 真人: ${diff.human.chains.filter((c) => c.complete).length}/${diff.human.chains.length} | RPA: ${diff.rpa.chains.filter((c) => c.complete).length}/${diff.rpa.chains.length}`,
);
if (diff.gaps.length) {
console.log("\n差异:");
for (const g of diff.gaps) {
console.log(` - ${g}`);
}
}
if (diff.recommendations.length) {
console.log("\n建议:");
for (const r of diff.recommendations) {
console.log(`${r}`);
}
}
console.log(`\n完整报告: ${join(outDir, "diff-report.json")}`);
const exitCode = diff.verdict === "MATCH" ? 0 : 1;
if (!shouldKeepBrowserOpen()) {
process.exit(exitCode);
}
console.log(
"[diff] 填址已完成。按 Ctrl+C 结束会话headed 模式默认保持浏览器窗口)",
);
await new Promise<void>((resolve) => {
const done = () => resolve();
process.once("SIGINT", done);
process.once("SIGTERM", done);
});
await opened.page.close().catch(() => undefined);
await opened.context.close().catch(() => undefined);
await closeBrowser();
process.exit(exitCode);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});