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/record-address-event-compar...

533 lines
17 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.

/**
* 地址 commit 事件对照录证headed 人工 vs RPA 生产路径)
*
* 目标HAR + axel event trace → 对比缺失事件(不修 click/delay/retry
*
* 用法:
* # 仅人工headed + HAR
* $env:COMPARE_MODE="manual"; $env:COMPARE_PAIR_ID="99"; npx tsx scripts/record-address-event-compare.ts
*
* # 仅 RPAfillAddressStep 生产路径)
* $env:COMPARE_MODE="rpa"; $env:COMPARE_PAIR_ID="99"; npx tsx scripts/record-address-event-compare.ts
*
* # 先后执行人工 + RPA 并 diff
* $env:COMPARE_MODE="both"; npx tsx scripts/record-address-event-compare.ts
*
* 人工检查点(非 TTY
* $env:COMPARE_USE_FILE_TRIGGER="1"
* pickup 完成后: New-Item -Force .rpa\address-event-compare\trigger-pickup-done
* delivery 完成后: New-Item -Force .rpa\address-event-compare\trigger-delivery-done
*/
import "dotenv/config";
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import readline from "node:readline";
import { stdin as input, stdout as output } from "node:process";
import { chromium } from "playwright";
import { loadDevEnv } from "@/lib/dev-env";
import { ensureNativeInfraIfNeeded } from "@/lib/infra/ensure-native-infra";
import {
hostFetchMothershipCandidates,
} from "@/lib/frontend/api-client";
import { applyMothershipCandidate } from "@/lib/frontend/mothership-address";
import { getPrimaryMothershipQuoteUrl } from "@/lib/rpa/env";
import { isAnyRpaWorkerHealthy } from "@/lib/rpa/worker-health";
import { ensureRedisReady, resetRedisClient } from "@/lib/redis";
import type { QuoteRequest } from "@/modules/providers/quote-provider";
import {
AxelEventTracer,
diffAxelTraces,
normalizeAxelSequence,
} from "@/scripts/lib/axel-event-tracer";
import { fillAddressStep } from "@/workers/rpa/kernel/steps/address-step";
import { RPAContext } from "@/workers/rpa/kernel/context";
import { closeBrowser, openQuotePage } from "@/workers/rpa/session-manager";
import { quotePageAdapter } from "@/workers/rpa/quote-page-adapter";
import {
drainSelectionTraces,
installManualSelectionListeners,
logManualInteractionGoldSample,
pickSelectionGoldInteractions,
recordManualCheckpoint,
} from "@/workers/rpa/selection-trace";
import {
stabilizeQuoteLandingPage,
waitForQuoterWidgetHydrated,
} from "@/workers/rpa/page-prep";
loadDevEnv();
const BASE = process.env.PROVE_BASE_URL ?? "http://localhost:3000";
const TOKEN = "demo-host-token";
const CUSTOMER_ID = "CUST_001";
const OUT_ROOT = join(process.cwd(), ".rpa", "address-event-compare");
const TRIGGER_PICKUP = join(OUT_ROOT, "trigger-pickup-done");
const TRIGGER_DELIVERY = join(OUT_ROOT, "trigger-delivery-done");
const PAIRS: Record<
string,
{
label: string;
pickup: { street: string; city: string; state: string };
delivery: { street: string; city: string; state: string };
}
> = {
"99": {
label: "Key West FL → Los Angeles CADuval / Hollywood",
pickup: { street: "Duval Street", city: "Key West", state: "FL" },
delivery: {
street: "Hollywood Boulevard",
city: "Los Angeles",
state: "CA",
},
},
"01": {
label: "洛杉矶 CA → Farmers Branch TX",
pickup: { street: "1234 Warehouse Street", city: "Los Angeles", state: "CA" },
delivery: { street: "5678 Distribution Way", city: "Farmers Branch", state: "TX" },
},
};
const PAIR_ID = process.env.COMPARE_PAIR_ID ?? "99";
const MODE = (process.env.COMPARE_MODE ?? "both").toLowerCase();
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,
};
}
function toQuoteRequest(
pickup: QuoteRequest["pickup"],
delivery: QuoteRequest["delivery"],
): QuoteRequest {
return {
cargoHash: "event-compare",
pickup,
delivery,
weightLb: 503,
dimsIn: { l: 48, w: 40, h: 48 },
palletCount: 2,
cargoType: "general_freight",
};
}
async function fetchConfirmedAddresses(pairId: string): Promise<{
pair: (typeof PAIRS)[string];
pickup: QuoteRequest["pickup"];
delivery: QuoteRequest["delivery"];
pickupLabel: string;
deliveryLabel: string;
}> {
const pair = PAIRS[pairId];
if (!pair) {
throw new Error(`未知 COMPARE_PAIR_ID=${pairId}`);
}
const userPickup = draftAddress(pair.pickup);
const userDelivery = draftAddress(pair.delivery);
const candRes = await hostFetchMothershipCandidates(
BASE,
TOKEN,
CUSTOMER_ID,
userPickup,
userDelivery,
);
if (candRes.code !== 0 || !candRes.data) {
throw new Error(`候选 API 失败: ${candRes.message}`);
}
const selectedPickup = candRes.data.pickup_candidates[0];
const selectedDelivery = candRes.data.delivery_candidates[0];
if (!selectedPickup || !selectedDelivery) {
throw new Error("候选不足");
}
const pickupApplied = applyMothershipCandidate(userPickup, selectedPickup);
const deliveryApplied = applyMothershipCandidate(userDelivery, selectedDelivery);
return {
pair,
pickupLabel: selectedPickup.display_label,
deliveryLabel: selectedDelivery.display_label,
pickup: {
street: pickupApplied.street,
city: pickupApplied.city,
state: pickupApplied.state,
zip: pickupApplied.zip ?? "",
placeId: userPickup.place_id,
formattedAddress: pickupApplied.formatted_address,
selectedFromSuggestions: true,
mothershipOptionId: pickupApplied.mothership_option_id ?? "",
mothershipDisplayLabel: pickupApplied.mothership_display_label ?? "",
selectedFromMothership: true,
},
delivery: {
street: deliveryApplied.street,
city: deliveryApplied.city,
state: deliveryApplied.state,
zip: deliveryApplied.zip ?? "",
placeId: userDelivery.place_id,
formattedAddress: deliveryApplied.formatted_address,
selectedFromSuggestions: true,
mothershipOptionId: deliveryApplied.mothership_option_id ?? "",
mothershipDisplayLabel: deliveryApplied.mothership_display_label ?? "",
selectedFromMothership: true,
},
};
}
async function waitForCheckpoint(label: string, triggerPath: string): Promise<void> {
const useFile =
process.env.COMPARE_USE_FILE_TRIGGER === "1" || !process.stdin.isTTY;
console.log(`\n── ${label} ──`);
if (useFile) {
if (existsSync(triggerPath)) {
rmSync(triggerPath, { force: true });
}
console.log(
`文件触发:完成后执行\n New-Item -Force ${triggerPath.replace(/\\/g, "\\\\")}`,
);
while (!existsSync(triggerPath)) {
await new Promise((r) => setTimeout(r, 400));
}
rmSync(triggerPath, { force: true });
return;
}
const rl = readline.createInterface({ input, output, terminal: true });
await new Promise<void>((resolve) => {
rl.question("完成后按 Enter 继续… ", () => {
rl.close();
resolve();
});
});
}
async function runManualSession(
runDir: string,
pairId: string,
): Promise<{ events: ReturnType<AxelEventTracer["getEvents"]>; harPath: string }> {
const { pair, pickupLabel, deliveryLabel } = await fetchConfirmedAddresses(pairId);
const entryUrl = getPrimaryMothershipQuoteUrl();
const harPath = join(runDir, "manual.har");
const tracer = new AxelEventTracer();
console.log(`\n=== 人工 headed 录证:组 ${pairId} ${pair.label} ===`);
console.log(`入口:${entryUrl}`);
console.log(`
操作说明(金样本:必须让脚本录到「点中了哪颗 DOM」
① 提货框输入:${pair.pickup.street}, ${pair.pickup.city}, ${pair.pickup.state}
(不要一上来打完整酒店名;与 RPA 区分,先打出街道级联想)
② 等联想列表出现 → 用鼠标点「${pickupLabel}
或键盘:↓ 移到目标 → 浏览器里 Enter不是终端 Enter
③ 确认提货锁定后,终端检查点 pickup 按 Enter
④ 派送框输入:${pair.delivery.street}, ${pair.delivery.city}, ${pair.delivery.state}
⑤ 点选「${deliveryLabel}」或 ↓+Enter
⑥ 终端检查点 delivery 按 Enter
终端 Enter 仅用于检查点;浏览器 Enter 才用于选联想。
录证成功标志:检查点日志出现 selectionClicks>=1 或 selectionKeys 含 Enter@INPUT
`);
const browser = await chromium.launch({
headless: false,
channel: process.env.RPA_BROWSER_CHANNEL as "chrome" | "msedge" | undefined,
slowMo: Number(process.env.RPA_SLOW_MO_MS ?? 80) || 80,
});
const context = await browser.newContext({
locale: process.env.RPA_BROWSER_LOCALE?.trim() || "en-US",
recordHar: { path: harPath, mode: "full", content: "embed" },
});
const page = await context.newPage();
tracer.attach(page);
page.setDefaultTimeout(120_000);
tracer.setPhase("navigate");
await page.goto(entryUrl, { waitUntil: "domcontentloaded", timeout: 90_000 });
await page.waitForTimeout(3_000);
await installManualSelectionListeners(page);
console.log(
"[manual] 已注入 click/keydown 监听capture 阶段);成功点选后检查点会导出 interaction 日志",
);
tracer.setPhase("manual-pickup");
await waitForCheckpoint("检查点 pickup人工点选完成", TRIGGER_PICKUP);
const pickupForensics = await recordManualCheckpoint(page, "manual-pickup-done");
logManualInteractionGoldSample("pickup", pickupForensics.interactions);
const pickupGold = pickSelectionGoldInteractions(pickupForensics.interactions);
writeFileSync(
join(runDir, "manual-selection-pickup.json"),
JSON.stringify({ ...pickupForensics, gold: pickupGold }, null, 2),
"utf8",
);
tracer.setPhase("manual-delivery");
await waitForCheckpoint("检查点 delivery人工点选完成", TRIGGER_DELIVERY);
const deliveryForensics = await recordManualCheckpoint(page, "manual-delivery-done");
logManualInteractionGoldSample("delivery", deliveryForensics.interactions);
const deliveryGold = pickSelectionGoldInteractions(deliveryForensics.interactions);
writeFileSync(
join(runDir, "manual-selection-delivery.json"),
JSON.stringify({ ...deliveryForensics, gold: deliveryGold }, null, 2),
"utf8",
);
writeFileSync(
join(runDir, "manual-interactions-gold.json"),
JSON.stringify(
{
pairId,
pickup: pickupGold,
delivery: deliveryGold,
},
null,
2,
),
"utf8",
);
await page
.screenshot({ path: join(runDir, "manual-final.png") })
.catch(() => undefined);
tracer.detach();
await context.close();
await browser.close();
if (!existsSync(harPath)) {
throw new Error(`HAR 未生成: ${harPath}`);
}
const events = [...tracer.getEvents()];
writeFileSync(
join(runDir, "manual-trace.json"),
JSON.stringify(
{
mode: "manual",
pairId,
label: pair.label,
pickupHint: pickupLabel,
deliveryHint: deliveryLabel,
events,
sequence: normalizeAxelSequence(events),
harPath,
},
null,
2,
),
"utf8",
);
console.log(`[manual] HAR: ${harPath}`);
console.log(`[manual] trace: ${join(runDir, "manual-trace.json")}`);
console.log(`[manual] gold: ${join(runDir, "manual-interactions-gold.json")}`);
console.log(`[manual] place commits:`, events.filter((e) => e.kind === "place" && e.status === 200).map((e) => e.placeId));
return { events, harPath };
}
function writeSelectionTraceArtifact(runDir: string, mode: string): void {
const traces = drainSelectionTraces();
if (!traces.length) {
return;
}
const outPath = join(runDir, `${mode}-selection-trace.json`);
writeFileSync(outPath, JSON.stringify({ mode, traces }, null, 2), "utf8");
console.log(`[${mode}] selection-trace: ${outPath} (${traces.length} 条快照)`);
}
async function runRpaSession(
runDir: string,
pairId: string,
): Promise<ReturnType<AxelEventTracer["getEvents"]>> {
const { pair, pickup, delivery } = await fetchConfirmedAddresses(pairId);
const tracer = new AxelEventTracer();
console.log(`\n=== RPA 生产路径录证:组 ${pairId} ${pair.label} ===`);
console.log(`fillAddressStep与 Worker 询价相同)`);
process.env.RPA_HEADLESS = process.env.COMPARE_RPA_HEADED === "true" ? "false" : "true";
const opened = await openQuotePage(quotePageAdapter, false, undefined, {
freshCargoContext: true,
});
const ctx = RPAContext.fromSession(opened.page, opened.context);
tracer.attach(ctx.page);
await stabilizeQuoteLandingPage(ctx);
tracer.setPhase("hydrate");
await waitForQuoterWidgetHydrated(ctx.page, 60_000);
tracer.setPhase("rpa-fillAddress");
try {
await fillAddressStep(ctx, toQuoteRequest(pickup, delivery));
} catch (error) {
writeSelectionTraceArtifact(runDir, "rpa");
const events = [...tracer.getEvents()];
writeFileSync(
join(runDir, "rpa-trace.json"),
JSON.stringify(
{
mode: "rpa",
pairId,
label: pair.label,
error: error instanceof Error ? error.message : String(error),
events,
sequence: normalizeAxelSequence(events),
},
null,
2,
),
"utf8",
);
await closeBrowser().catch(() => undefined);
throw error;
}
writeSelectionTraceArtifact(runDir, "rpa");
const events = [...tracer.getEvents()];
writeFileSync(
join(runDir, "rpa-trace.json"),
JSON.stringify(
{
mode: "rpa",
pairId,
label: pair.label,
events,
sequence: normalizeAxelSequence(events),
},
null,
2,
),
"utf8",
);
await ctx.page
.screenshot({ path: join(runDir, "rpa-final.png") })
.catch(() => undefined);
tracer.detach();
await closeBrowser();
console.log(`[rpa] trace: ${join(runDir, "rpa-trace.json")}`);
console.log(`[rpa] place commits:`, events.filter((e) => e.kind === "place" && e.status === 200).map((e) => e.placeId));
return events;
}
async function main(): Promise<void> {
ensureNativeInfraIfNeeded();
resetRedisClient();
await ensureRedisReady(12, 1_000);
const res = await fetch(BASE, { method: "GET", redirect: "manual" });
if (res.status <= 0) {
throw new Error("Next 未响应,请先 npm run dev:start");
}
if (!(await isAnyRpaWorkerHealthy())) {
throw new Error("RPA Worker 无心跳");
}
const runId = randomUUID().slice(0, 8);
const runDir = join(OUT_ROOT, `${PAIR_ID}-${runId}`);
mkdirSync(runDir, { recursive: true });
mkdirSync(OUT_ROOT, { recursive: true });
console.log(`录证目录:${runDir}`);
console.log(`COMPARE_MODE=${MODE} COMPARE_PAIR_ID=${PAIR_ID}`);
let manualEvents: ReturnType<AxelEventTracer["getEvents"]> | null = null;
let rpaEvents: ReturnType<AxelEventTracer["getEvents"]> | null = null;
if (MODE === "manual" || MODE === "both") {
const manual = await runManualSession(runDir, PAIR_ID);
manualEvents = [...manual.events];
}
if (MODE === "rpa" || MODE === "both") {
try {
rpaEvents = [...(await runRpaSession(runDir, PAIR_ID))];
} catch (error) {
console.error("[rpa] fillAddress 失败trace 已写入):", error);
if (MODE === "rpa") {
process.exitCode = 1;
}
}
}
if (manualEvents && rpaEvents) {
const diff = diffAxelTraces(manualEvents, rpaEvents);
const comparePath = join(runDir, "compare.json");
writeFileSync(
comparePath,
JSON.stringify(
{
runId,
pairId: PAIR_ID,
finishedAt: new Date().toISOString(),
...diff,
interpretation: interpretDiff(diff),
},
null,
2,
),
"utf8",
);
console.log(`\n对照报告${comparePath}`);
console.log(JSON.stringify(interpretDiff(diff), null, 2));
}
const summaryPath = join(runDir, "README.txt");
writeFileSync(
summaryPath,
[
`runId=${runId}`,
`pair=${PAIR_ID}`,
`mode=${MODE}`,
"",
"产物:",
"- manual.har + manual-trace.json人工",
"- rpa-trace.jsonRPA fillAddressStep",
"- compare.jsonboth 模式)",
"",
"下一步:仅实现 compare.json 中 missingInRpa 对应的事件,禁止 patch click/delay/retry。",
].join("\n"),
"utf8",
);
}
function interpretDiff(
diff: ReturnType<typeof diffAxelTraces>,
): Record<string, unknown> {
return {
verdict: diff.verdict,
manualPlaceCommits: diff.manualPlaceCommits,
rpaPlaceCommits: diff.rpaPlaceCommits,
missingInRpaCount: diff.missingInRpa.length,
missingInRpa: diff.missingInRpa.slice(0, 20),
extraInRpa: diff.extraInRpa.slice(0, 20),
nextAction:
diff.verdict === "RPA_MISSING_PLACE_COMMIT"
? "RPA 未触发 GET place/{id};对照 manual trace 中 pickup/delivery 的 place 事件,补缺失 selection 事件"
: diff.verdict === "MANUAL_NO_PLACE_COMMIT"
? "人工录证未捕获 place commit请重跑 manual 并确认真实点选联想项"
: "查看 missingInRpa 逐行 diff",
};
}
main().catch((error) => {
console.error(error);
process.exit(1);
});