mirror of
https://github.com/penpot/penpot.git
synced 2026-10-30 06:20:32 +00:00
Add a main RPC command, get-semantic-file, that turns a Penpot file into a small, neutral projection: pages, frames, text, links and interactions, with libraries, geometry and assets stripped. The projection is what a decision model sees, so the extractor stays model-agnostic and the deterministic checks live outside it. Add experiments/security-projection, a Node prototype that consumes the command: analyze.mjs (file -> projection -> checks -> decision model), compare.mjs (one file, several models), evaluate.mjs with a labeled set of projections, and seed.mjs, which builds real files to validate the extractor. It talks to OpenRouter decision models. Tests: backend_tests.rpc-semantic-test (3 tests, 21 assertions) and 24 Node tests across the prototype. AI-assisted-by: deepseek-v4.1-flash
139 lines
4.9 KiB
JavaScript
139 lines
4.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// Evaluate decision models against the labeled projection set.
|
|
//
|
|
// Reads fixtures/labeled-projections.json, asks every model the same questions
|
|
// about each projection, and reports precision / recall / false positives /
|
|
// false negatives, plus cost and latency. Entries tagged "ambiguous" are shown
|
|
// but excluded from the metrics; entries tagged "adversarial" stay in the
|
|
// metrics and can be read apart in the per-case table.
|
|
//
|
|
// Usage:
|
|
// node evaluate.mjs # default models, threshold 0.5
|
|
// node evaluate.mjs --threshold 0.7
|
|
// node evaluate.mjs typesafe/jev-1.13 cloudflare/clef-flash
|
|
//
|
|
// Config comes from .env, same as analyze.mjs. Needs no Penpot instance: the
|
|
// fixtures carry the projections.
|
|
|
|
import fs from "node:fs";
|
|
import process from "node:process";
|
|
import { pathToFileURL } from "node:url";
|
|
import { askDecisions, deterministicChecks } from "./analyze.mjs";
|
|
|
|
const FIXTURES = new URL("./fixtures/labeled-projections.json", import.meta.url);
|
|
|
|
const DEFAULT_MODELS = [
|
|
"typesafe/jev-1.13",
|
|
"cloudflare/clef-flash",
|
|
"liquid/d1",
|
|
"perplexity/pplx-decider-v1.1-27b",
|
|
"inception/mercury-decide:free",
|
|
];
|
|
|
|
function parseArgs(argv) {
|
|
const args = { threshold: 0.5, models: [] };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
if (argv[i] === "--threshold") args.threshold = Number(argv[++i]);
|
|
else args.models.push(argv[i]);
|
|
}
|
|
return args;
|
|
}
|
|
|
|
export function median(values) {
|
|
if (!values.length) return null;
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
return sorted[Math.floor(sorted.length / 2)];
|
|
}
|
|
|
|
export function summarize(rows, threshold) {
|
|
let tp = 0, fp = 0, fn = 0, tn = 0;
|
|
for (const row of rows) {
|
|
const predicted = row.prob >= threshold;
|
|
const actual = row.label === "phishing";
|
|
if (predicted && actual) tp++;
|
|
else if (predicted && !actual) fp++;
|
|
else if (!predicted && actual) fn++;
|
|
else tn++;
|
|
}
|
|
const precision = tp + fp ? tp / (tp + fp) : null;
|
|
const recall = tp + fn ? tp / (tp + fn) : null;
|
|
const f1 = precision && recall ? (2 * precision * recall) / (precision + recall) : null;
|
|
return { tp, fp, fn, tn, precision, recall, f1 };
|
|
}
|
|
|
|
const fmt = (value, digits = 3) =>
|
|
value === null || value === undefined ? "-" : value.toFixed(digits);
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const models = args.models.length ? args.models : DEFAULT_MODELS;
|
|
|
|
const { entries } = JSON.parse(fs.readFileSync(FIXTURES, "utf8"));
|
|
const scored = entries.filter((e) => e.tag !== "ambiguous");
|
|
const benign = entries.filter((e) => e.label === "benign").length;
|
|
|
|
console.log(`entries: ${entries.length} (benign ${benign}, phishing ${entries.length - benign})`);
|
|
console.log(`ambiguous (excluded from metrics): ${entries.length - scored.length}`);
|
|
console.log(`threshold: ${args.threshold}`);
|
|
console.log(`models: ${models.join(", ")}\n`);
|
|
|
|
// results[model][entryId] = { prob, ms, cost } | { error }
|
|
const results = {};
|
|
for (const model of models) {
|
|
results[model] = {};
|
|
for (const entry of entries) {
|
|
const started = Date.now();
|
|
try {
|
|
const body = await askDecisions(entry.projection, deterministicChecks(entry.projection), model);
|
|
results[model][entry.id] = {
|
|
prob: body.answers?.is_phishing?.noul ?? null,
|
|
ms: Date.now() - started,
|
|
cost: body.usage?.cost ?? 0,
|
|
};
|
|
} catch (err) {
|
|
results[model][entry.id] = { error: err.message };
|
|
}
|
|
}
|
|
process.stderr.write(` ${model} done\n`);
|
|
}
|
|
|
|
console.log("model\tthr\tTP\tFP\tFN\tTN\tprecision\trecall\tf1\tp50ms\tcost$");
|
|
for (const model of models) {
|
|
const rows = scored
|
|
.map((entry) => ({ label: entry.label, prob: results[model][entry.id]?.prob }))
|
|
.filter((row) => typeof row.prob === "number");
|
|
const m = summarize(rows, args.threshold);
|
|
const times = Object.values(results[model]).map((r) => r.ms).filter(Boolean);
|
|
const cost = Object.values(results[model]).reduce((acc, r) => acc + (r.cost ?? 0), 0);
|
|
console.log([
|
|
model,
|
|
args.threshold,
|
|
m.tp, m.fp, m.fn, m.tn,
|
|
fmt(m.precision, 2), fmt(m.recall, 2), fmt(m.f1, 2),
|
|
median(times) ?? "-",
|
|
cost.toFixed(6),
|
|
].join("\t"));
|
|
}
|
|
|
|
const errors = models.flatMap((model) =>
|
|
Object.entries(results[model]).filter(([, r]) => r.error).map(([id, r]) => `${model} ${id}: ${r.error}`));
|
|
if (errors.length) {
|
|
console.log("\nerrors:");
|
|
for (const line of errors) console.log(` ${line}`);
|
|
}
|
|
|
|
console.log("\nper-case is_phishing probability:");
|
|
console.log(["case", "label", "tag", ...models].join("\t"));
|
|
for (const entry of entries) {
|
|
const cells = models.map((model) => fmt(results[model][entry.id]?.prob));
|
|
console.log([entry.id, entry.label, entry.tag, ...cells].join("\t"));
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
main().catch((err) => {
|
|
console.error(`error: ${err.stack ?? String(err)}`);
|
|
process.exit(1);
|
|
});
|
|
}
|