UNPKG

@arizeai/phoenix-client

Version:
179 lines 6.95 kB
import { endTaskSpanForRun, postAnnotation, runEvaluatorWithTracing, } from "./phoenix-test-tracking.js"; import { currentRun } from "./state.js"; /** * Log the output produced by the test for the current run. * * Calling this multiple times overwrites the previously recorded value. * The argument can be any JSON-serializable value — typically an object * matching the shape of the example's `expected` field. */ export function logOutput(output) { const run = currentRun(); if (!run) { throw new Error("logOutput() must be called inside a Phoenix eval test body"); } run.output = output; run.outputSet = true; endTaskSpanForRun(run); } /** * Record an annotation on the current run. * * Annotations are collected during the test and posted to Phoenix as * experiment evaluations after the test completes. The `name` is the * Phoenix evaluation name; `score`, `label`, and `explanation` map to * the standard Phoenix `EvaluationResult` fields. * * The annotation name `"pass"` is reserved — Phoenix eval tests always write * a `pass` annotation derived from the test's assertion outcome, so a * user-supplied annotation with that name would race / overwrite the * built-in one. Such calls are silently ignored. */ export function logAnnotation(annotation) { const run = currentRun(); if (!run) { throw new Error("logAnnotation() must be called inside a Phoenix eval test body"); } if (annotation.name === "pass") return; run.annotations.push(annotation); } /** * Run an evaluator object against the current test run and record the result. * * The evaluator may come from `@arizeai/phoenix-evals.createEvaluator`, * `asExperimentEvaluator`, or any plain object with `{ name, evaluate }`. * When `params` is omitted, the current test's `input`, recorded `output`, * `expected`, `metadata`, and task `traceId` are supplied. */ export async function evaluate(evaluator, params) { const run = currentRun(); if (!run) { return await evaluator.evaluate((params ?? {})); } if (!run.outputSet && !(params && "output" in params)) { warnEvaluateBeforeOutput(run.suite, evaluator.name, run.testName); } const evaluatorParams = { input: run.params.input, // `run.output` is only ever set together with `outputSet`, so it is already // `undefined` until a value is recorded. output: run.output, expected: run.params.expected, metadata: run.params.metadata, traceId: run.traceId ?? null, ...(params ?? {}), }; const { result, traceId } = await runEvaluatorWithTracing(run.suite, evaluator.name, evaluatorParams, (paramsToEvaluate) => evaluator.evaluate(paramsToEvaluate)); logAnnotation(toAnnotation({ name: evaluator.name, kind: evaluator.kind, result, traceId, })); return result; } /** * Trace an evaluator function so its execution shows up as a separate * `EVALUATOR` span in Phoenix and any `{ name, score }`-shaped return * value is automatically captured as an annotation on the current run. * * The annotation name defaults to the traced function's name, falling * back to `"evaluator"`. */ export function traceEvaluator(fn, options) { const evaluatorName = options?.name ?? (fn.name && fn.name !== "" ? fn.name : "evaluator"); return async (params) => { const run = currentRun(); if (!run) { // outside a test context, just call the function plainly return await fn(params); } const { result, traceId } = await runEvaluatorWithTracing(run.suite, evaluatorName, params, fn); if (isAnnotationShaped(result)) { logAnnotation({ ...result, traceId }); } return result; }; } /** * Warn (at most once per suite) when an evaluator runs before any output was * recorded and none was passed explicitly. Such an evaluator receives * `output: undefined`, which silently scores against nothing — almost always a * forgotten `logOutput()`. Harmless for evaluators that only read `input`. */ const warnedOutputSuites = new WeakSet(); function warnEvaluateBeforeOutput(suite, evaluatorName, testName) { if (warnedOutputSuites.has(suite)) return; warnedOutputSuites.add(suite); // eslint-disable-next-line no-console console.warn(`[@arizeai/phoenix-client] evaluate("${evaluatorName}") ran before ` + `logOutput() on test "${testName}", so the evaluator received ` + `output=undefined. Call logOutput(...) first, or pass { output } ` + `explicitly. (Ignore if this evaluator only needs input.)`); } /** * Normalize an evaluator's return value into an {@link Annotation}. The value * is already typed as an {@link EvaluationResult}, so we only dispatch on its * runtime shape: a string becomes a `label`, a number/boolean/null becomes a * `score`, and an object contributes its `score`/`label`/`explanation`/ * `metadata` directly. */ function toAnnotation({ name, kind, result, traceId, }) { const annotatorKind = kind ?? "CODE"; if (typeof result === "string") { return { name, label: result, annotatorKind, traceId }; } if (typeof result === "number" || typeof result === "boolean" || result === null) { return { name, score: result, annotatorKind, traceId }; } if (typeof result === "object" && !Array.isArray(result)) { const { score, label, explanation, metadata } = result; return { name, score, label, explanation, metadata, annotatorKind, traceId, }; } return { name, annotatorKind, traceId }; } function isAnnotationShaped(value) { if (!value || typeof value !== "object") return false; const v = value; if (typeof v.name !== "string") return false; if (v.score !== undefined && typeof v.score !== "number" && typeof v.score !== "boolean" && v.score !== null) { return false; } return true; } /** * Internal: persist all collected annotations for the run. * * Phoenix's `experiment_evaluations` endpoint is keyed by * `(experiment_run_id, name)` so two annotations with the same name on * the same run race each other. We collapse duplicates by name (last * wins) up front, which makes the final state deterministic; the * remaining writes target distinct names, so they post in parallel. */ export async function flushAnnotations(runId, annotations, suite) { if (!annotations.length) return; const byName = new Map(); for (const annotation of annotations) { byName.set(annotation.name, annotation); } await Promise.all(Array.from(byName.values(), (annotation) => postAnnotation(suite, runId, annotation))); } //# sourceMappingURL=helpers.js.map