Files
launchdarkly__ai-tooling/tests/print-results.mjs
ld-ahartmann 6e8416b8be Add eval suite and sample test project (#40)
* Add eval suite and sample test project to tests/

Adds:
- tests/evals/ — Promptfoo LLM eval suite covering all 14 agent skills
  (metrics, feature flags, AI configs) using claude-3-haiku-20240307
- tests/checkout_proj/ — sample React/Vite app used by skill scenarios
  that require a real codebase (e.g. metric-instrument, flag-create)
- tests/fixtures/ — MCP tool schema fixtures for eval context
- tests/package.json — promptfoo dev dependency and npm scripts
  (eval, eval:metrics, eval:flags, eval:aiconfigs)
- .github/workflows/integration-tests.yml — manual workflow for
  running gram-functions integration tests against catfood

Made-with: Cursor

* fix: add checkout_proj as plain files, not submodule

Made-with: Cursor

* Scope PR down to metric-create eval only

Remove all other eval files, checkout_proj sample app, fixtures, and
CI workflow added earlier. Keep only the metric-create eval with
llm-rubric assertions and the print-results.mjs helper script.

Made-with: Cursor

* Delete tests/.gitignore

* test: add output mutation tests and tighten llm-rubric assertions

Replaces the previous bad-skills negative test approach with output
mutation testing: 7 hand-crafted defective canned outputs fed through
the same llm-rubric graders via a custom echo-output provider. No LLM
call, zero variance — only the grader's judgment is under test.

- Adds tests/providers/echo-output.mjs (returns vars.canned_output directly)
- Adds 7 [NEG] mutation tests to metric-create.eval.yaml, one per positive test
- Tightens 4 llm-rubric assertions that had blind spots (no-premature-create,
  shows-proposal-step, checks-for-duplicates, asks-measure-type)
- Updates print-results.mjs: [NEG]+FAIL = green (defect detected),
  [NEG]+PASS = red (blind spot); adds token usage + cost summary
- Adds --no-cache to npm run eval for guaranteed fresh results

Result: 7/7 positive tests pass, 7/7 mutation tests detect their defects.
Made-with: Cursor

* refactor: replace relative file:// paths with env var aliases

Removes all ../../../ traversal from eval configs. The npm eval script
now exports SKILL_ROOT and PROVIDER_ROOT as absolute paths, which the
YAML references via {{env.SKILL_ROOT}} and {{env.PROVIDER_ROOT}}.
Adding evals for new skills only requires changing the path segment,
not counting directory levels.

Made-with: Cursor

* ci: add LLM eval workflow + path aliases + CI exit code gate

- Adds .github/workflows/llm-evals.yml — runs on push/PR when
  skills/metrics/**, tests/evals/**, tests/providers/** change, plus
  manual workflow_dispatch. Requires ANTHROPIC_API_KEY secret.
- Replaces file://../../../ traversal with SKILL_ROOT / PROVIDER_ROOT
  env vars set in the npm eval script (absolute paths via subshell).
- print-results.mjs now exits 1 when positive tests fail, exits 0
  otherwise — mutation tests are informational and don't gate CI.

Made-with: Cursor

* Add eval suites for metric-choose and metric-instrument skills

- tests/evals/metrics/metric-choose.eval.yaml: 6 positive + 6 mutation tests
  covering hypothesis-first, release-policy lookup, event health, advisory-only,
  inventory step, and context disambiguation behaviors
- tests/evals/metrics/metric-instrument.eval.yaml: 6 positive + 6 mutation tests
  covering server-side track() signature, metricValue usage, event verification,
  placement confirmation, SDK key fetching, and existing-pattern detection
- tests/package.json: split eval into named per-skill scripts (eval:create,
  eval:choose, eval:instrument) with combined eval running all three
- .github/workflows/llm-evals.yml: run each skill eval as a separate CI step

Results: metric-create 7/7+7/7, metric-choose 6/6+6/6, metric-instrument 6/6+6/6.
Made-with: Cursor

* Set eval provider temperature to 0 for deterministic results

Made-with: Cursor

* chore: drop CI Promptfoo; add test:llm-evals for manual runs

Remove llm-evals GitHub Action (push/PR) so evals are not run automatically
in CI. Add npm test:llm-evals at repo root and in tests/ as an explicit
name for the full eval suite, and document in CONTRIBUTING.

Made-with: Cursor
2026-04-24 12:10:24 -04:00

115 lines
3.8 KiB
JavaScript

import { readFileSync } from "fs";
const RESULTS_FILE = "/tmp/promptfoo-results.json";
let raw;
try {
raw = readFileSync(RESULTS_FILE, "utf8");
} catch {
console.error(`Could not read ${RESULTS_FILE} — did the eval run with --output?`);
process.exit(1);
}
const data = JSON.parse(raw);
const results = data?.results?.results ?? [];
if (results.length === 0) {
console.log("No test results found.");
process.exit(0);
}
const GREEN = (s) => `\x1b[32m${s}\x1b[0m`;
const RED = (s) => `\x1b[31m${s}\x1b[0m`;
const DIM = (s) => `\x1b[2m${s}\x1b[0m`;
const SEP = "─".repeat(72);
console.log("\n" + SEP);
console.log("EVAL RESULTS — full LLM output");
console.log(SEP);
let posTotal = 0, posPassed = 0, negTotal = 0, negConfirmed = 0;
results.forEach((r, i) => {
const description = r.testCase?.description ?? r.vars?.user_message?.slice(0, 60) ?? `Test ${i + 1}`;
const isNeg = description.startsWith("[NEG]");
const passed = r.success;
// Mutation tests: grader sees a defective output and should FAIL
// FAIL = grader correctly caught the defect (green)
// PASS = grader missed the defect (red — rubric has a blind spot)
let statusLabel;
if (isNeg) {
negTotal++;
if (!passed) {
negConfirmed++;
statusLabel = GREEN("FAIL (grader correctly rejected defect)");
} else {
statusLabel = RED("PASS (grader missed defect — rubric blind spot)");
}
} else {
posTotal++;
if (passed) {
posPassed++;
statusLabel = GREEN("PASS");
} else {
statusLabel = RED("FAIL");
}
}
const failedAssertions = (r.gradingResult?.componentResults ?? [])
.filter((c) => !c.pass)
.map((c) => c.assertion?.metric ?? c.assertion?.type ?? "?");
console.log(`\n=== TEST ${i + 1}: ${description} ===`);
if (r.vars?.user_message) {
console.log(`INPUT: ${r.vars.user_message}`);
}
console.log(`STATUS: ${statusLabel}`);
if (!passed && failedAssertions.length > 0) {
const label = isNeg ? "DETECTED" : "FAILED";
console.log(`${label}: ${failedAssertions.join(", ")}`);
}
console.log("OUTPUT:");
console.log(r.response?.output ?? "(no output)");
console.log(SEP);
});
console.log(`\nPositive tests: ${posPassed}/${posTotal} passed`);
console.log(`Mutation tests: ${negConfirmed}/${negTotal} defects correctly detected`);
if (posPassed < posTotal) {
console.error(`\n[CI] ${posTotal - posPassed} positive test(s) failed — exiting 1`);
process.exitCode = 1;
}
// Token usage — read from top-level stats (most accurate source)
const stats = data?.results?.stats ?? {};
const tu = stats.tokenUsage ?? {};
const promptTokens = tu.prompt ?? 0;
const completionTokens = tu.completion ?? 0;
const gradingPromptTokens = tu.assertions?.prompt ?? 0;
const gradingCompletionTokens = tu.assertions?.completion ?? 0;
const totalPrompt = promptTokens + gradingPromptTokens;
const totalCompletion = completionTokens + gradingCompletionTokens;
const totalTokens = totalPrompt + totalCompletion;
// Claude 3 Haiku pricing (as of 2026): $0.25/M input, $1.25/M output
const INPUT_COST_PER_M = 0.25;
const OUTPUT_COST_PER_M = 1.25;
const inputCost = (totalPrompt / 1_000_000) * INPUT_COST_PER_M;
const outputCost = (totalCompletion / 1_000_000) * OUTPUT_COST_PER_M;
const totalCost = inputCost + outputCost;
console.log(`
Token Usage:
Eval — input: ${promptTokens.toLocaleString()}, output: ${completionTokens.toLocaleString()}
Grading — input: ${gradingPromptTokens.toLocaleString()}, output: ${gradingCompletionTokens.toLocaleString()}
Total — input: ${totalPrompt.toLocaleString()}, output: ${totalCompletion.toLocaleString()} (${totalTokens.toLocaleString()} tokens)
Estimated cost (claude-3-haiku @ $0.25/M input, $1.25/M output):
Input: $${inputCost.toFixed(4)}
Output: $${outputCost.toFixed(4)}
Total: $${totalCost.toFixed(4)}
`);