UNPKG

@arizeai/phoenix-client

Version:
252 lines (240 loc) 7.68 kB
import { endTaskSpanForRun, postAnnotation, runEvaluatorWithTracing, } from "./phoenix-test-tracking"; import { currentRun, type SuiteState } from "./state"; import type { Annotation, EvaluationParams, EvaluationResult, EvaluationResultObject, Evaluator, KVMap, } from "./types"; /** * 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: unknown): void { 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: Annotation): void { 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< Params extends KVMap = EvaluationParams & KVMap, Result = EvaluationResult, >( evaluator: Evaluator<Params, Result>, params?: Partial<Params> & KVMap ): Promise<Result> { const run = currentRun(); if (!run) { return await evaluator.evaluate((params ?? {}) as 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 ?? {}), } as unknown as 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<EvaluatorParams extends KVMap, EvaluatorResult>( fn: (params: EvaluatorParams) => EvaluatorResult | Promise<EvaluatorResult>, options?: { name?: string } ): (params: EvaluatorParams) => Promise<EvaluatorResult> { const evaluatorName = options?.name ?? (fn.name && fn.name !== "" ? fn.name : "evaluator"); return async (params: EvaluatorParams) => { 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<SuiteState>(); function warnEvaluateBeforeOutput( suite: SuiteState, evaluatorName: string, testName: string ): void { 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, }: { name: string; kind?: Annotation["annotatorKind"]; result: unknown; traceId?: string | null; }): Annotation { 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 as EvaluationResultObject; return { name, score, label, explanation, metadata, annotatorKind, traceId, }; } return { name, annotatorKind, traceId }; } function isAnnotationShaped(value: unknown): value is Annotation { if (!value || typeof value !== "object") return false; const v = value as { name?: unknown; score?: unknown }; 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: string | undefined, annotations: Annotation[], suite: SuiteState ): Promise<void> { if (!annotations.length) return; const byName = new Map<string, Annotation>(); for (const annotation of annotations) { byName.set(annotation.name, annotation); } await Promise.all( Array.from(byName.values(), (annotation) => postAnnotation(suite, runId, annotation) ) ); }