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/probe-human-doc-system.ts

332 lines
9.7 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 组 — 系统全链路探针HTTP API非 RPA 代码直调)
*
* 流程:候选 API → POST /api/quotes → 轮询 GET /api/quotes/:id → 校验 done + tiers
* 地址来源docs/美国跨州货物运输报价汇总_edited3.docx
*
* 前置npm run dev:startNext + RPA Worker + Redis + MySQL
*
* PowerShell:
* npm run probe:human-doc-system
* $env:PROBE_HUMAN_ONLY="H01,H07"; npm run probe:human-doc-system
*/
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { loadDevEnv } from "@/lib/dev-env";
import { ensureNativeInfraIfNeeded } from "@/lib/infra/ensure-native-infra";
import {
hostCreateQuote,
hostFetchMothershipCandidates,
hostGetQuote,
} from "@/lib/frontend/api-client";
import { applyMothershipCandidate } from "@/lib/frontend/mothership-address";
import type { MothershipAddressCandidate, QuoteRequestBody } from "@/lib/frontend/types";
import { pollQuoteUntilDone } from "@/hooks/use-quote-polling";
import { isAnyRpaWorkerHealthy } from "@/lib/rpa/worker-health";
import { ensureRedisReady, resetRedisClient } from "@/lib/redis";
import {
HUMAN_DOC_10_PAIRS,
HUMAN_DOC_CARGO,
type HumanDocPair,
} from "./lib/human-doc-10pairs";
import { pickBestMothershipCandidate } from "./lib/pick-mothership-candidate";
loadDevEnv();
const BASE = process.env.PROBE_BASE_URL ?? "http://localhost:3000";
const TOKEN = "demo-host-token";
const CUSTOMER_ID = "CUST_001";
const POLL_TIMEOUT_MS = Number(
process.env.PROBE_HUMAN_POLL_MS ?? process.env.QUOTE_TIMEOUT_MS ?? 600_000,
);
const ONLY = (process.env.PROBE_HUMAN_ONLY ?? "")
.split(",")
.map((s) => s.trim().toUpperCase())
.filter(Boolean);
function selectPairs(): HumanDocPair[] {
if (!ONLY.length) {
return HUMAN_DOC_10_PAIRS;
}
return HUMAN_DOC_10_PAIRS.filter((p) => ONLY.includes(p.id));
}
function uniqueWeight(base: number): number {
const nonce = (parseInt(randomUUID().replace(/-/g, ""), 16) % 900) + 1;
return base + nonce;
}
async function waitForHttpOk(url: string, attempts = 60): 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}(请先 npm run dev:start`);
}
function draftAddress(
draft: { street: string; city: string; state: string },
): QuoteRequestBody["pickup_address"] {
const street = draft.street.trim();
const city = draft.city.trim();
const state = draft.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 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`;
}
type CaseResult = {
id: string;
route: string;
ok: boolean;
quoteId?: string;
tierCount?: number;
sourceType?: string;
durationMs: number;
pickupLabel?: string;
deliveryLabel?: string;
quotes?: Array<{
serviceLevel: string;
rateOption: string;
rawTotal: number;
carrier?: string;
}>;
error?: string;
};
async function runSystemQuote(pair: HumanDocPair): Promise<CaseResult> {
const started = Date.now();
const userPickup = { ...pair.pickup, zip: "" };
const userDelivery = { ...pair.delivery, zip: "" };
try {
const cand = await hostFetchMothershipCandidates(
BASE,
TOKEN,
CUSTOMER_ID,
userPickup,
userDelivery,
);
if (cand.code !== 0 || !cand.data) {
return {
id: pair.id,
route: pair.route,
ok: false,
durationMs: Date.now() - started,
error: cand.message ?? "地址候选 API 失败",
};
}
const selectedPickup = pickBestMothershipCandidate(
cand.data.pickup_candidates,
userPickup,
);
const selectedDelivery = pickBestMothershipCandidate(
cand.data.delivery_candidates,
userDelivery,
);
if (!selectedPickup || !selectedDelivery) {
return {
id: pair.id,
route: pair.route,
ok: false,
durationMs: Date.now() - started,
error: "地址候选为空",
};
}
const body: QuoteRequestBody = {
request_id: randomUUID(),
customer_id: CUSTOMER_ID,
quote_session_id: cand.data.quote_session_id,
pickup_address: applyMothershipCandidate(
draftAddress(userPickup),
selectedPickup,
),
delivery_address: applyMothershipCandidate(
draftAddress(userDelivery),
selectedDelivery,
),
weight: { value: uniqueWeight(HUMAN_DOC_CARGO.weightLb), unit: "lb" },
dimensions: {
length: HUMAN_DOC_CARGO.dimsIn.length,
width: HUMAN_DOC_CARGO.dimsIn.width,
height: HUMAN_DOC_CARGO.dimsIn.height,
unit: "in",
},
pallet_count: HUMAN_DOC_CARGO.palletCount,
cargo_type: HUMAN_DOC_CARGO.cargoType,
};
const created = await hostCreateQuote(BASE, TOKEN, body);
if (created.code !== 0 || !created.data?.quote_id) {
return {
id: pair.id,
route: pair.route,
ok: false,
durationMs: Date.now() - started,
error: created.message ?? "POST /api/quotes 失败",
};
}
const quoteId = created.data.quote_id;
let finalDetail = created.data;
if (finalDetail.status === "processing") {
const poll = await pollQuoteUntilDone(
async () => {
const detail = await hostGetQuote(BASE, TOKEN, CUSTOMER_ID, quoteId);
if (detail.code !== 0) {
return { ok: false, errorMessage: detail.message };
}
return { ok: true, data: detail.data };
},
{ timeoutMs: POLL_TIMEOUT_MS },
);
if (poll.type !== "done") {
return {
id: pair.id,
route: pair.route,
ok: false,
quoteId,
durationMs: Date.now() - started,
error:
poll.type === "timeout"
? `轮询超时(${fmtMs(POLL_TIMEOUT_MS)}`
: poll.message,
};
}
finalDetail = poll.quote;
}
const tierCount = finalDetail?.quotes?.length ?? 0;
const sourceType = String(finalDetail?.source_type ?? "");
const ok =
finalDetail?.status === "done" &&
tierCount >= 1 &&
(sourceType === "rpa" || sourceType === "cache");
return {
id: pair.id,
route: pair.route,
ok,
quoteId,
tierCount,
sourceType,
durationMs: Date.now() - started,
pickupLabel: selectedPickup.display_label,
deliveryLabel: selectedDelivery.display_label,
quotes: (finalDetail?.quotes ?? []).map((q) => ({
serviceLevel: q.service_level,
rateOption: q.rate_option,
rawTotal: q.raw_total,
carrier: q.carrier,
})),
error: ok
? undefined
: `status=${finalDetail?.status} tiers=${tierCount} source=${sourceType}`,
};
} catch (error) {
return {
id: pair.id,
route: pair.route,
ok: false,
durationMs: Date.now() - started,
error: error instanceof Error ? error.message : String(error),
};
}
}
async function main(): Promise<void> {
const runId = randomUUID().slice(0, 8);
const pairs = selectPairs();
if (!pairs.length) {
console.error(`[probe:human-doc] 无匹配路线 PROBE_HUMAN_ONLY=${ONLY.join(",")}`);
process.exit(1);
}
await ensureNativeInfraIfNeeded();
resetRedisClient();
await ensureRedisReady();
await waitForHttpOk(`${BASE}/`);
const workerOk = await isAnyRpaWorkerHealthy();
console.log(
`[probe:human-doc] 系统全链路探针 run=${runId} pairs=${pairs.length} worker=${workerOk ? "ok" : "未就绪"}`,
);
console.log(`[probe:human-doc] API=${BASE} 策略=direct-first-widget-fallback`);
const results: CaseResult[] = [];
let pass = 0;
for (const pair of pairs) {
console.log(`\n[probe:human-doc] ${pair.id} ${pair.route}`);
const outcome = await runSystemQuote(pair);
results.push(outcome);
if (outcome.ok) {
pass += 1;
const lowest = outcome.quotes?.find(
(q) => q.serviceLevel === "standard" && q.rateOption === "lowest",
);
console.log(
`[PASS] quote=${outcome.quoteId} tiers=${outcome.tierCount} source=${outcome.sourceType} ${fmtMs(outcome.durationMs)} lowest=${lowest?.rawTotal ?? "—"}`,
);
} else {
console.log(`[FAIL] ${outcome.error ?? "unknown"} (${fmtMs(outcome.durationMs)})`);
}
}
const report = {
runId,
mode: "system-api",
sourceDoc: "docs/美国跨州货物运输报价汇总_edited3.docx",
finishedAt: new Date().toISOString(),
baseUrl: BASE,
summary: { total: pairs.length, pass, fail: pairs.length - pass },
verdict: pass === pairs.length ? "PASS" : "FAIL",
env: {
quoteStrategy: "direct-first-widget-fallback",
RPA_ADDRESS_MODE: process.env.RPA_ADDRESS_MODE ?? "",
workerHealthy: workerOk,
},
results,
};
const outDir = join(process.cwd(), ".rpa", "human-doc-system-probe");
if (!existsSync(outDir)) {
mkdirSync(outDir, { recursive: true });
}
const outPath = join(outDir, `system-${runId}.json`);
writeFileSync(outPath, JSON.stringify(report, null, 2), "utf8");
console.log(`\n[probe:human-doc] ${pass}/${pairs.length} 通过 | 报告: ${outPath}`);
process.exit(pass === pairs.length ? 0 : 1);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});