Ctrl K
ComunidadGuías…

scripts/eval-relations.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Evaluates the production pairwise event-relation judge on a user-supplied gold set.
// Usage: node --env-file=.env scripts/eval-relations.ts --gold .data/relation-gold.jsonl
//        [--models default,deepseek-flash] [--split development] [--n 200] [--thresholds <review,tie>]
// The same pair prompt/schema as production is used; receipts make identical re-runs reusable.
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { parseArgs } from "node:util";
import { REPO_ROOT } from "@aihot/backend/config";
import { closeDb, sql } from "@aihot/backend/db";
import { PAIR_SYSTEM, PairSchema, RELATE_PROMPT_VERSION, pairUser } from "@aihot/backend/events/relate";
import { chatJson } from "@aihot/backend/providers/llm";
import { completeReceipt, failedReceiptId } from "@aihot/backend/providers/receipts";
import {
  DEFAULT_RELATION_THRESHOLDS,
  parseRelationGoldJsonl,
  relationMetrics,
  storyTieMetrics,
  toReportView,
  type RelationPrediction,
} from "./eval-relations-core.ts";
import { evalModels, pmap, positiveInt, safeReportNamePart, sampleCases, usageFor } from "./eval-tools.ts";

const { values } = parseArgs({
  options: {
    gold: { type: "string", default: ".data/relation-gold.jsonl" },
    models: { type: "string" },
    n: { type: "string", default: "200" },
    split: { type: "string", default: "all" },
    concurrency: { type: "string", default: "6" },
    seed: { type: "string", default: "7" },
    thresholds: { type: "string" },
  },
});

async function main() {
  const n = positiveInt(values.n!, "n");
  const concurrency = positiveInt(values.concurrency!, "concurrency");
  const seed = Number(values.seed);
  if (!Number.isInteger(seed)) throw new Error("--seed must be an integer");
  const thresholds = values.thresholds
    ? values.thresholds.split(",").map((value) => Number(value.trim()))
    : DEFAULT_RELATION_THRESHOLDS;
  if (!thresholds.length || thresholds.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) {
    throw new Error("--thresholds must be comma-separated numbers between 0 and 1");
  }

  const rows = parseRelationGoldJsonl(readFileSync(path.resolve(REPO_ROOT, values.gold!), "utf8"));
  const sample = sampleCases(rows, { split: values.split!, n, seed });

  const models = await evalModels(values.models, "groupReview");

  const report: Record<string, unknown> = {};
  for (const model of models) {
    const started = performance.now();
    // Distinct gold cases can render identical prompts. Share their result, including failures,
    // so a cold run scores the same cases as a cached run and never retries a pair within one run.
    const requests = new Map<string, ReturnType<typeof chatJson<typeof PairSchema>>>();
    const results = await pmap(sample, concurrency, async (row) => {
      let shared = false;
      try {
        const user = pairUser(toReportView(row.a), toReportView(row.b));
        let request = requests.get(user);
        shared = request !== undefined;
        if (!request) {
          request = (async () => {
            const res = await chatJson({
              model,
              purpose: "eval_relation_pair",
              subject: `relation-gold:${row.caseId}`,
              promptVersion: RELATE_PROMPT_VERSION,
              system: PAIR_SYSTEM,
              user,
              schema: PairSchema,
              temperature: 0,
              maxTokens: 400,
            });
            await completeReceipt(sql, res.receiptId);
            return res;
          })();
          requests.set(user, request);
        }
        const res = await request;
        return { row, out: res.data, receiptId: res.receiptId, reused: shared || res.reused, error: null as string | null };
      } catch (error) {
        return { row, out: null, receiptId: failedReceiptId(error), reused: shared, error: String(error).slice(0, 300) };
      }
    });

    const predictions: RelationPrediction[] = results.flatMap((result) =>
      result.out
        ? [{
            caseId: result.row.caseId,
            gold: result.row.gold.relation,
            relation: result.out.relation,
            confidence: result.out.confidence,
            stratum: result.row.samplingContext?.samplingStratum ?? null,
          }]
        : [],
    );
    const metrics = relationMetrics(predictions, sample.length);
    const usage = await usageFor(results.flatMap((result) => result.receiptId === null ? [] : [result.receiptId]));
    const summary = {
      model,
      n: sample.length,
      evaluated: metrics.evaluated,
      errors: metrics.errors,
      coverage: metrics.coverage,
      accuracy: metrics.accuracy,
      completeAccuracy: metrics.completeAccuracy,
      macroF1: metrics.macroF1,
      reused: results.filter((result) => result.reused).length,
      ...usage,
      wallSeconds: Math.round((performance.now() - started) / 1000),
    };
    const storyThresholds = thresholds.map((threshold) => storyTieMetrics(predictions, threshold, sample.length));
    console.log(JSON.stringify(summary));
    console.log(storyThresholds.map((metric) =>
      `  story t=${metric.threshold} P=${metric.precision} R=${metric.recall} F1=${metric.f1} coverage=${metric.coverage} completeAccuracy=${metric.completeAccuracy}`,
    ).join("\n"));

    report[model] = {
      summary,
      confusionMatrix: metrics.confusionMatrix,
      perClass: metrics.perClass,
      storyThresholds,
      cases: results.map((result) => ({
        caseId: result.row.caseId,
        stratum: result.row.samplingContext?.samplingStratum ?? null,
        gold: result.row.gold.relation,
        decision: result.out?.relation ?? null,
        confidence: result.out?.confidence ?? null,
        a: result.out?.a ?? null,
        b: result.out?.b ?? null,
        difference: result.out?.difference ?? null,
        receiptId: result.receiptId,
        reused: result.reused,
        error: result.error,
      })),
    };
  }

  const outDir = path.join(REPO_ROOT, ".data/eval");
  mkdirSync(outDir, { recursive: true });
  const splitName = safeReportNamePart(values.split!);
  const file = path.join(outDir, `relations-${splitName}-${sample.length}-${Date.now()}.json`);
  const meta = {
    split: values.split,
    n: sample.length,
    seed,
    thresholds,
    promptVersion: RELATE_PROMPT_VERSION,
    createdAt: new Date().toISOString(),
  };
  writeFileSync(file, JSON.stringify({ meta, models: report }, null, 2));
  console.log(`report: ${file}`);
}

try {
  await main();
} finally {
  await closeDb();
}