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/workers/rpa/gold-standard-targets.ts

175 lines
4.8 KiB

import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import type { AddressLookupInput } from "@/modules/address/types";
import type { ManualInteractionRecord } from "@/workers/rpa/selection-trace";
export type GoldStandardClickTarget = {
textPreview: string;
className: string;
tagName: string;
dataOptionId: string | null;
side: "pickup" | "delivery";
sourceDir: string;
};
type GoldFile = {
pickup?: { selectionClicks: ManualInteractionRecord[] };
delivery?: { selectionClicks: ManualInteractionRecord[] };
};
const GOLD_ROOT = join(process.cwd(), ".rpa", "address-event-compare");
function normalizeText(text: string): string {
return text.replace(/\s+/g, " ").trim().toLowerCase();
}
function stripCountrySuffix(label: string): string {
return label.replace(/,\s*USA\s*$/i, "").trim();
}
/** 金样本 textPreview 与目标 description 的匹配分(越高越优先) */
export function scoreGoldTextMatch(
description: string,
query: AddressLookupInput,
textPreview: string,
): number {
const desc = normalizeText(description);
const preview = normalizeText(textPreview);
if (!desc || !preview) {
return 0;
}
if (desc === preview) {
return 100;
}
if (preview.includes(desc) || desc.includes(preview)) {
return 90;
}
const descNoCountry = normalizeText(stripCountrySuffix(description));
const previewNoCountry = normalizeText(stripCountrySuffix(textPreview));
if (descNoCountry === previewNoCountry) {
return 85;
}
if (
previewNoCountry.includes(descNoCountry) ||
descNoCountry.includes(previewNoCountry)
) {
return 75;
}
const street = normalizeText(query.street);
const city = normalizeText(query.city);
let score = 0;
if (street && preview.includes(street)) {
score += 40;
}
if (city && preview.includes(city)) {
score += 30;
}
const state = normalizeText(query.state);
if (state) {
if (state.length === 2) {
const stateRe = new RegExp(
`(,|\\b)${state.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\b|,|$)`,
);
if (stateRe.test(preview)) {
score += 20;
}
} else if (preview.includes(state)) {
score += 20;
}
}
const streetHead = street.split(",")[0]?.trim() ?? "";
if (score < 75 && streetHead.length >= 8 && !preview.includes(streetHead)) {
return 0;
}
const streetNum = query.street.match(/^\d+/)?.[0];
if (score < 75 && streetNum && !preview.includes(streetNum.toLowerCase())) {
return 0;
}
return score;
}
function toTarget(
click: ManualInteractionRecord,
side: "pickup" | "delivery",
sourceDir: string,
): GoldStandardClickTarget {
return {
textPreview: click.target.textPreview,
className: click.target.className,
tagName: click.target.tagName,
dataOptionId: click.target.dataOptionId,
side,
sourceDir,
};
}
function loadGoldTargetsFromDir(dir: string): GoldStandardClickTarget[] {
const path = join(dir, "manual-interactions-gold.json");
if (!existsSync(path)) {
return [];
}
let data: GoldFile;
try {
data = JSON.parse(readFileSync(path, "utf8")) as GoldFile;
} catch {
return [];
}
const out: GoldStandardClickTarget[] = [];
for (const click of data.pickup?.selectionClicks ?? []) {
out.push(toTarget(click, "pickup", dir));
}
for (const click of data.delivery?.selectionClicks ?? []) {
out.push(toTarget(click, "delivery", dir));
}
return out;
}
/** 扫描 .rpa/address-event-compare 下全部金样本点击目标 */
export function loadAllGoldStandardTargets(): GoldStandardClickTarget[] {
if (!existsSync(GOLD_ROOT)) {
return [];
}
const targets: GoldStandardClickTarget[] = [];
for (const name of readdirSync(GOLD_ROOT, { withFileTypes: true })) {
if (!name.isDirectory()) {
continue;
}
targets.push(...loadGoldTargetsFromDir(join(GOLD_ROOT, name.name)));
}
return targets;
}
/**
* 按 description/query 匹配最相关的金样本点击目标(同侧优先)。
* 用于运行时复现人工点选 DOM 特征。
*/
export function findGoldStandardTargets(
description: string,
query: AddressLookupInput,
side: "pickup" | "delivery",
minScore = 50,
): GoldStandardClickTarget[] {
const all = loadAllGoldStandardTargets();
const scored = all
.map((target) => ({
target,
score:
scoreGoldTextMatch(description, query, target.textPreview) +
(target.side === side ? 5 : 0),
}))
.filter((item) => item.score >= minScore)
.sort((a, b) => b.score - a.score);
const seen = new Set<string>();
const unique: GoldStandardClickTarget[] = [];
for (const { target } of scored) {
const key = `${target.textPreview}|${target.className}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
unique.push(target);
}
return unique.slice(0, 5);
}