Files
writing-agent/tools/st-request-diff/conform.mjs
T
chenmoranzhi 1a5ef3b2bc 抓包对比工具补一批一次性脚本:拆预设、对请求、跑 A/B 与拒答抽样。
这些脚本都是我对着酒馆真实请求手工核对用的,留在仓库里方便下次重跑;跑出来的本地数据目录含接口 key,已经写进忽略规则。
2026-10-26 01:09:56 +08:00

228 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 「100% 对齐」的可测量化:同一个预设,两边各发一轮,逐字段/逐条消息比对,输出对齐度与差异明细。
// 用法: node conform.mjs --preset "C:/path/x.json" [--st-port 8124] [--wa-port 23377] [--st-capture 8801] [--wa-capture 8802]
// 退出码: 0 = 完全对齐, 1 = 有差异
import { spawn } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { waitFor } from "./cdp.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
function readArgs(argv) {
const out = {
preset: "",
message: "你好",
stPort: 8124,
waPort: 23377,
stCapture: 8801,
waCapture: 8802,
out: path.join(HERE, "captures"),
};
for (let i = 0; i < argv.length; i++) {
const k = argv[i];
const v = argv[i + 1];
if (k === "--preset") { out.preset = path.resolve(String(v)); i++; }
else if (k === "--message") { out.message = String(v); i++; }
else if (k === "--st-port") { out.stPort = Number(v); i++; }
else if (k === "--wa-port") { out.waPort = Number(v); i++; }
else if (k === "--st-capture") { out.stCapture = Number(v); i++; }
else if (k === "--wa-capture") { out.waCapture = Number(v); i++; }
}
if (!out.preset) throw new Error("缺少 --preset");
return out;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function runScript(script, args) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [path.join(HERE, script), ...args], { cwd: HERE, stdio: ["ignore", "pipe", "pipe"] });
let out = "";
const pipe = (c) => { const t = String(c); out += t; };
child.stdout.on("data", pipe);
child.stderr.on("data", pipe);
child.on("close", (code) => resolve({ code, out }));
});
}
function lastJsonLine(out) {
const lines = out.split(/\r?\n/).filter((l) => l.trim().startsWith("{") && l.includes("\"capture\""));
if (!lines.length) return null;
try { return JSON.parse(lines[lines.length - 1]); } catch (e) { return null; }
}
// ---- 比对 ----
const norm = (s) => String(s ?? "").replace(/\r/g, "");
function ratio(a, b) {
a = norm(a); b = norm(b);
if (a === b) return 1;
if (!a || !b) return 0;
const max = Math.max(a.length, b.length);
let same = 0;
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) if (a[i] === b[i]) same++;
return same / max;
}
const mark = (ok) => (ok ? "✅" : "❌");
function loadBody(file) {
const outer = JSON.parse(readFileSync(file, "utf8"));
return JSON.parse(outer.body);
}
const REQUEST_KEYS = [
"model", "temperature", "top_p", "max_tokens", "presence_penalty",
"frequency_penalty", "stop", "stream", "stream_options", "tools", "tool_choice",
"response_format", "reasoning_effort", "top_k", "min_p", "repetition_penalty",
];
function compareRequest(st, wa) {
const rows = [];
const keys = new Set([...Object.keys(st), ...Object.keys(wa)]);
for (const k of REQUEST_KEYS) if (!(k in st) && !(k in wa)) continue;
for (const k of keys) {
if (k === "messages") continue;
if (!REQUEST_KEYS.includes(k)) {
rows.push({ key: k, st: "<有>", wa: "<有>", ok: false, note: "未纳入比对的字段" });
continue;
}
const a = JSON.stringify(st[k]);
const b = JSON.stringify(wa[k]);
rows.push({ key: k, st: a === undefined ? "(无)" : a, wa: b === undefined ? "(无)" : b, ok: a === b });
}
return rows;
}
function compareMessages(stMsgs, waMsgs) {
const rows = [];
const n = Math.max(stMsgs.length, waMsgs.length);
for (let i = 0; i < n; i++) {
const a = stMsgs[i];
const b = waMsgs[i];
if (!a) { rows.push({ i, ok: false, note: "只在本仓库有", wa: b }); continue; }
if (!b) { rows.push({ i, ok: false, note: "只在酒馆有", st: a }); continue; }
const roleOk = a.role === b.role;
const nameOk = (a.name ?? null) === (b.name ?? null);
const contentOk = norm(a.content) === norm(b.content);
rows.push({
i,
ok: roleOk && nameOk && contentOk,
roleOk,
nameOk,
contentOk,
similarity: ratio(a.content, b.content),
st: a,
wa: b,
});
}
return rows;
}
function firstDiff(rows) {
const hit = rows.find((r) => !r.ok);
if (!hit) return "无";
if (hit.note) return "#" + hit.i + " " + hit.note;
const bits = [];
if (!hit.roleOk) bits.push("role " + hit.st.role + " vs " + hit.wa.role);
if (!hit.nameOk) bits.push("name " + (hit.st.name ?? "无") + " vs " + (hit.wa.name ?? "无"));
if (!hit.contentOk) bits.push("正文相似度 " + (hit.similarity * 100).toFixed(1) + "%");
return "#" + hit.i + " " + bits.join(", ");
}
async function main() {
const args = readArgs(process.argv.slice(2));
mkdirSync(args.out, { recursive: true });
const presetName = path.basename(args.preset, ".json");
const caps = [
spawn(process.execPath, [path.join(HERE, "capture-server.mjs"), "--label", "st", "--port", String(args.stCapture), "--out", args.out], { cwd: HERE, stdio: "ignore" }),
spawn(process.execPath, [path.join(HERE, "capture-server.mjs"), "--label", "wa", "--port", String(args.waCapture), "--out", args.out], { cwd: HERE, stdio: "ignore" }),
];
try {
for (const port of [args.stCapture, args.waCapture]) {
await waitFor(async () => (await fetch("http://127.0.0.1:" + port + "/v1/models").catch(() => null)) !== null, { timeoutMs: 15000, label: "假上游 " + port });
}
console.log("[conform] 酒馆侧发一轮…");
const st = await runScript("drive-st.mjs", ["--preset", args.preset, "--message", args.message, "--capture-port", String(args.stCapture), "--st-port", String(args.stPort), "--out", args.out]);
if (st.code !== 0) throw new Error("酒馆侧失败:\n" + st.out.slice(-2000));
const stMeta = lastJsonLine(st.out);
if (!stMeta) throw new Error("酒馆侧没拿到抓包文件:\n" + st.out.slice(-2000));
console.log("[conform] 本仓库侧发一轮…");
const wa = await runScript("drive-wa.mjs", ["--preset", args.preset, "--message", args.message, "--capture-port", String(args.waCapture), "--port", String(args.waPort), "--out", args.out]);
if (wa.code !== 0) throw new Error("本仓库侧失败:\n" + wa.out.slice(-2000));
const waMeta = lastJsonLine(wa.out);
if (!waMeta) throw new Error("本仓库侧没拿到抓包文件:\n" + wa.out.slice(-2000));
const stBody = loadBody(stMeta.capture);
const waBody = loadBody(waMeta.capture);
const reqRows = compareRequest(stBody, waBody);
const msgRows = compareMessages(stBody.messages, waBody.messages);
const msgOk = msgRows.filter((r) => r.ok).length;
const reqOk = reqRows.filter((r) => r.ok).length;
const total = msgRows.length + reqRows.length;
const okTotal = msgOk + reqOk;
const pct = ((okTotal / total) * 100).toFixed(1);
const lines = [];
lines.push("# 对齐报告: " + presetName);
lines.push("");
lines.push("| 项目 | 值 |");
lines.push("| --- | --- |");
lines.push("| 酒馆抓包 | `" + path.basename(stMeta.capture) + "` |");
lines.push("| 本仓库抓包 | `" + path.basename(waMeta.capture) + "` |");
lines.push("| 请求级字段 | " + reqOk + "/" + reqRows.length + " 对齐 |");
lines.push("| 消息条数 | " + stBody.messages.length + " vs " + waBody.messages.length + " |");
lines.push("| 消息逐条 | " + msgOk + "/" + msgRows.length + " 对齐 |");
lines.push("| **总对齐度** | **" + pct + "%** |");
lines.push("| 首个差异 | " + firstDiff(msgRows) + " |");
lines.push("");
lines.push("## 请求级字段");
lines.push("");
lines.push("| 对齐 | 字段 | 酒馆 | 本仓库 |");
lines.push("| --- | --- | --- | --- |");
for (const r of reqRows) {
lines.push("| " + mark(r.ok) + " | `" + r.key + "` | " + String(r.st).slice(0, 80) + " | " + String(r.wa).slice(0, 80) + " |");
}
lines.push("");
lines.push("## 消息逐条");
lines.push("");
lines.push("| # | 对齐 | 酒馆 | 本仓库 |");
lines.push("| --- | --- | --- | --- |");
for (const r of msgRows) {
const one = (m) => m ? "[" + m.role + (m.name ? "/" + m.name : "") + "] " + norm(m.content).length + " 字: " + JSON.stringify(norm(m.content).slice(0, 40)) : "—";
const why = r.ok ? "" : " **" + (r.note ? r.note : (!r.contentOk ? "正文差 " + ((1 - r.similarity) * 100).toFixed(1) + "%" : !r.roleOk ? "role 不同" : "name 不同")) + "**";
lines.push("| " + r.i + " | " + mark(r.ok) + " | " + one(r.st) + " | " + one(r.wa) + why + " |");
}
lines.push("");
lines.push("## 差异明细");
lines.push("");
const bad = msgRows.filter((r) => !r.ok);
if (!bad.length && reqRows.every((r) => r.ok)) lines.push("完全对齐。");
for (const r of bad) {
if (r.note) { lines.push("- #" + r.i + " " + r.note); continue; }
lines.push("- #" + r.i + (r.roleOk ? "" : " role " + r.st.role + " vs " + r.wa.role) + (r.nameOk ? "" : " name " + (r.st.name ?? "无") + " vs " + (r.wa.name ?? "无")));
if (!r.contentOk) {
lines.push(" - 酒馆: " + JSON.stringify(norm(r.st.content).slice(0, 160)));
lines.push(" - 我们: " + JSON.stringify(norm(r.wa.content).slice(0, 160)));
}
}
const report = path.join(args.out, "conform-" + presetName.replace(/[^\w.\-]+/g, "_") + ".md");
writeFileSync(report, lines.join("\n") + "\n", "utf8");
console.log("\n" + lines.slice(0, 12).join("\n"));
console.log("\n[conform] 对齐度 " + pct + "%(消息 " + msgOk + "/" + msgRows.length + ",字段 " + reqOk + "/" + reqRows.length + ")");
console.log("[conform] 报告: " + report);
process.exitCode = okTotal === total ? 0 : 1;
} finally {
for (const c of caps) { try { c.kill(); } catch (e) {} }
await sleep(300);
}
}
await main();