From 6cef41a0ce161b0343d06d7610eaaea52bf49b2f Mon Sep 17 00:00:00 2001 From: "user.email" Date: Mon, 26 Oct 2026 16:08:37 -0700 Subject: [PATCH 1/2] feat: add Clairic extraction beside keyed decisions --- package.json | 5 + src/decisions.test.ts | 1 + src/decisions.ts | 18 ++- src/extraction.test.ts | 157 +++++++++++++++++++ src/extraction.ts | 346 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 519 insertions(+), 8 deletions(-) create mode 100644 src/extraction.test.ts create mode 100644 src/extraction.ts diff --git a/package.json b/package.json index 22b619a..39861e4 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,11 @@ "corbits-system-one-src": "./src/decisions.ts", "types": "./dist/decisions.d.ts", "default": "./dist/decisions.js" + }, + "./extraction": { + "corbits-system-one-src": "./src/extraction.ts", + "types": "./dist/extraction.d.ts", + "default": "./dist/extraction.js" } }, "publishConfig": { diff --git a/src/decisions.test.ts b/src/decisions.test.ts index a8a537a..176bd9e 100644 --- a/src/decisions.test.ts +++ b/src/decisions.test.ts @@ -260,6 +260,7 @@ describe("systemOne", () => { systemOne( { state: "x", + // @ts-expect-error extraction questions are not decisions questions: { field: { type: "extract", instructions: "pull it" } }, config: { apiKey: "k" }, }, diff --git a/src/decisions.ts b/src/decisions.ts index 7470e22..6aa0d38 100644 --- a/src/decisions.ts +++ b/src/decisions.ts @@ -8,6 +8,7 @@ import { DecisionQuestionRecord, QuestionList, type Decision, + type DecisionQuestion, type Description, type EvaluateConfig, type FallbackResult, @@ -125,13 +126,14 @@ export type SystemOneResult> = | SystemOneSuccess | FallbackResult; -export type SystemOneInput> = { - state: unknown; - questions: Questions; - assets?: Record; - model?: string; - config?: EvaluateConfig; -}; +export type SystemOneInput> = + { + state: unknown; + questions: Questions; + assets?: Record; + model?: string; + config?: EvaluateConfig; + }; function withModel( config: EvaluateConfig | undefined, @@ -218,7 +220,7 @@ async function loadImages( * `fallback` and `reason` and do not throw. */ export async function systemOne< - const Questions extends Record, + const Questions extends Record, >( input: SystemOneInput, options?: Pick, diff --git a/src/extraction.test.ts b/src/extraction.test.ts new file mode 100644 index 0000000..2362e85 --- /dev/null +++ b/src/extraction.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from "bun:test"; + +import { choice, systemOne } from "./decisions"; +import { date, estimate, extract, list, number, text } from "./extraction"; + +describe("extract", () => { + test("posts Clairic fields and maps answers", async () => { + const calls: { url: string; init: RequestInit | undefined }[] = []; + const result = await extract( + { + state: "Invoice INV-2291 issued 2026-09-02, net 30.", + fields: { + invoice_number: text(), + due_date: date("Issue date plus the net payment days"), + total: number({ + decimals: 2, + unit: "USD", + instructions: "Amount due including shipping", + }), + contacts: list("text"), + days_to_pay: estimate({ + min: 0, + max: 120, + step: 1, + instructions: "Days until paid", + }), + }, + }, + { + accountId: "acct", + token: "tok", + fetch: async (url, init) => { + calls.push({ url: String(url), init }); + return new Response( + JSON.stringify({ + result: { + answers: { + invoice_number: { value: "INV-2291", start: 8, end: 16 }, + due_date: { value: "2026-10-02" }, + total: { value: 1080, unit: "USD" }, + contacts: { value: ["Dana Ruiz"] }, + days_to_pay: { median: 30, interval: { p10: 12, p90: 45 } }, + }, + }, + }), + { status: 200 }, + ); + }, + }, + ); + + expect(calls).toHaveLength(1); + const call = calls[0]; + if (call === undefined) throw new Error("expected a fetch"); + expect(call.url).toBe( + "https://api.cloudflare.com/client/v4/accounts/acct/ai/run/@cf/cloudflare/clairic", + ); + expect(new Headers(call.init?.headers).get("authorization")).toBe( + "Bearer tok", + ); + expect(JSON.parse(String(call.init?.body))).toEqual({ + model: "clairic", + state: "Invoice INV-2291 issued 2026-09-02, net 30.", + fields: { + invoice_number: { type: "text" }, + due_date: { + type: "date", + instructions: "Issue date plus the net payment days", + }, + total: { + type: "number", + decimals: 2, + unit: "USD", + instructions: "Amount due including shipping", + }, + contacts: { type: "list", items: "text" }, + days_to_pay: { + type: "estimate", + min: 0, + max: 120, + step: 1, + instructions: "Days until paid", + }, + }, + }); + if (result.fallback) throw new Error("expected answers"); + expect(result.answers.invoice_number).toEqual({ + value: "INV-2291", + start: 8, + end: 16, + }); + expect(result.answers.due_date.value).toBe("2026-10-02"); + expect(result.answers.total.value).toBe(1080); + expect(result.answers.contacts.value).toEqual(["Dana Ruiz"]); + expect(result.answers.days_to_pay.median).toBe(30); + expect(result.answers.days_to_pay.interval).toEqual({ p10: 12, p90: 45 }); + }); + + test("falls back without credentials, on non-2xx, and on bad JSON", async () => { + const missing = await extract( + { state: "x", fields: { invoice_number: text() } }, + { + fetch: () => Promise.reject(new Error("fetch must not be called")), + }, + ); + expect(missing).toMatchObject({ fallback: true, reason: "no-key" }); + + const http = await extract( + { state: "x", fields: { invoice_number: text() } }, + { + accountId: "acct", + token: "tok", + fetch: async () => new Response("no", { status: 500 }), + }, + ); + expect(http).toMatchObject({ + fallback: true, + reason: "http-error", + httpStatus: 500, + }); + + const bad = await extract( + { state: "x", fields: { invoice_number: text() } }, + { + accountId: "acct", + token: "tok", + fetch: async () => new Response("not-json", { status: 200 }), + }, + ); + expect(bad).toMatchObject({ fallback: true, reason: "parse-error" }); + }); +}); + +describe("extraction and decisions stay disjoint", () => { + test("number is not a decision question and choice is not an extraction field", () => { + async function probe(): Promise { + await systemOne({ + state: "x", + questions: { + category: choice("What is this?", { billing: null }), + // @ts-expect-error number fields are not decision questions + total: number(), + }, + }); + + await extract({ + state: "x", + fields: { + invoice_number: text(), + // @ts-expect-error choice questions are not extraction fields + category: choice("What is this?", { billing: null }), + }, + }); + } + expect(probe).toBeFunction(); + }); +}); diff --git a/src/extraction.ts b/src/extraction.ts new file mode 100644 index 0000000..93c1318 --- /dev/null +++ b/src/extraction.ts @@ -0,0 +1,346 @@ +import type { FallbackReason, FallbackResult } from "./schemas.js"; + +const CLAIRIC_MODEL = "@cf/cloudflare/clairic"; + +export type TextField = { type: "text"; instructions?: string }; +export type DateField = { type: "date"; instructions?: string }; +export type NumberField = { + type: "number"; + decimals?: number; + unit?: string; + instructions?: string; +}; +export type ListField = { + type: "list"; + items: Items; +}; +export type EstimateField = { + type: "estimate"; + min?: number; + max?: number; + step?: number; + instructions?: string; +}; + +export type ExtractionField = + | TextField + | DateField + | NumberField + | ListField + | EstimateField; + +export type TextAnswer = { + value: string | null; + start?: number; + end?: number; +}; + +export type DateAnswer = { value: string | null }; + +export type NumberAnswer = { value: number | null; unit?: string }; + +export type ListAnswer = { value: string[] | null }; + +export type EstimateAnswer = { + median: number | null; + interval?: { p10: number; p90: number }; +}; + +type AnswerOf = Field extends { type: "text" } + ? TextAnswer + : Field extends { type: "date" } + ? DateAnswer + : Field extends { type: "number" } + ? NumberAnswer + : Field extends { type: "list" } + ? ListAnswer + : Field extends { type: "estimate" } + ? EstimateAnswer + : never; + +export type ExtractionAnswers> = + { + [Key in keyof Fields]: AnswerOf; + }; + +export type ExtractionSuccess> = + { + fallback: false; + answers: ExtractionAnswers; + modelId: string; + latencyMs: number; + }; + +export type ExtractionResult> = + | ExtractionSuccess + | FallbackResult; + +export type ExtractInput> = { + state: unknown; + fields: Fields; +}; + +export type ExtractFetch = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +export type ExtractOptions = { + accountId?: string; + token?: string; + fetch?: ExtractFetch; +}; + +function definedString(value: string | undefined): string | undefined { + if (value === undefined || value === "") return undefined; + return value; +} + +function text(instructions?: string): TextField { + return instructions === undefined + ? { type: "text" } + : { type: "text", instructions }; +} + +function date(instructions?: string): DateField { + return instructions === undefined + ? { type: "date" } + : { type: "date", instructions }; +} + +function number(options?: { + decimals?: number; + unit?: string; + instructions?: string; +}): NumberField { + const field: NumberField = { type: "number" }; + if (options?.decimals !== undefined) field.decimals = options.decimals; + if (options?.unit !== undefined) field.unit = options.unit; + if (options?.instructions !== undefined) + field.instructions = options.instructions; + return field; +} + +function list(items: Items): ListField { + return { type: "list", items }; +} + +function estimate(options?: { + min?: number; + max?: number; + step?: number; + instructions?: string; +}): EstimateField { + const field: EstimateField = { type: "estimate" }; + if (options?.min !== undefined) field.min = options.min; + if (options?.max !== undefined) field.max = options.max; + if (options?.step !== undefined) field.step = options.step; + if (options?.instructions !== undefined) + field.instructions = options.instructions; + return field; +} + +export { date, estimate, list, number, text }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function numberOrUndefined(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; +} + +function textAnswer(raw: unknown): TextAnswer { + if (typeof raw === "string") return { value: raw }; + if (raw === null || raw === undefined) return { value: null }; + if (!isRecord(raw)) return { value: null }; + const direct = + typeof raw["value"] === "string" + ? raw["value"] + : typeof raw["text"] === "string" + ? raw["text"] + : null; + const offsets = isRecord(raw["offsets"]) ? raw["offsets"] : undefined; + const start = + numberOrUndefined(raw["start"]) ?? + (offsets !== undefined ? numberOrUndefined(offsets["start"]) : undefined); + const end = + numberOrUndefined(raw["end"]) ?? + (offsets !== undefined ? numberOrUndefined(offsets["end"]) : undefined); + const answer: TextAnswer = { value: direct }; + if (start !== undefined) answer.start = start; + if (end !== undefined) answer.end = end; + return answer; +} + +function dateAnswer(raw: unknown): DateAnswer { + if (typeof raw === "string") return { value: raw }; + if (raw === null || raw === undefined) return { value: null }; + if (!isRecord(raw)) return { value: null }; + const value = raw["value"] ?? raw["date"]; + return { value: typeof value === "string" ? value : null }; +} + +function numberAnswer(raw: unknown): NumberAnswer { + if (typeof raw === "number" && Number.isFinite(raw)) return { value: raw }; + if (raw === null || raw === undefined || !isRecord(raw)) + return { value: null }; + const value = numberOrUndefined(raw["value"] ?? raw["number"]); + const unit = typeof raw["unit"] === "string" ? raw["unit"] : undefined; + const answer: NumberAnswer = { value: value ?? null }; + if (unit !== undefined) answer.unit = unit; + return answer; +} + +function listAnswer(raw: unknown): ListAnswer { + if (Array.isArray(raw)) { + return { + value: raw.filter((item): item is string => typeof item === "string"), + }; + } + if (raw === null || raw === undefined || !isRecord(raw)) + return { value: null }; + const value = raw["value"] ?? raw["items"]; + if (value === null) return { value: null }; + if (!Array.isArray(value)) return { value: null }; + return { + value: value.filter((item): item is string => typeof item === "string"), + }; +} + +function estimateAnswer(raw: unknown): EstimateAnswer { + if (raw === null || raw === undefined || !isRecord(raw)) { + return { median: null }; + } + const median = numberOrUndefined(raw["median"] ?? raw["value"]); + const intervalRaw = raw["interval"]; + let interval: { p10: number; p90: number } | undefined; + if (Array.isArray(intervalRaw) && intervalRaw.length >= 2) { + const p10 = numberOrUndefined(intervalRaw[0]); + const p90 = numberOrUndefined(intervalRaw[1]); + if (p10 !== undefined && p90 !== undefined) interval = { p10, p90 }; + } else if (isRecord(intervalRaw)) { + const p10 = numberOrUndefined(intervalRaw["p10"] ?? intervalRaw["low"]); + const p90 = numberOrUndefined(intervalRaw["p90"] ?? intervalRaw["high"]); + if (p10 !== undefined && p90 !== undefined) interval = { p10, p90 }; + } + const answer: EstimateAnswer = { median: median ?? null }; + if (interval !== undefined) answer.interval = interval; + return answer; +} + +function mapAnswer(field: ExtractionField, raw: unknown): unknown { + if (field.type === "text") return textAnswer(raw); + if (field.type === "date") return dateAnswer(raw); + if (field.type === "number") return numberAnswer(raw); + if (field.type === "list") return listAnswer(raw); + return estimateAnswer(raw); +} + +function answersOf( + fields: Record, + raw: unknown, +): Record | undefined { + const envelope = + isRecord(raw) && isRecord(raw["result"]) ? raw["result"] : raw; + if (!isRecord(envelope) || !isRecord(envelope["answers"])) return undefined; + const source = envelope["answers"]; + const answers: Record = {}; + for (const [name, field] of Object.entries(fields)) { + answers[name] = mapAnswer(field, source[name]); + } + return answers; +} + +function fallback( + reason: FallbackReason, + started: number, + extra?: { httpStatus?: number; detail?: string }, +): FallbackResult { + const result: FallbackResult = { + fallback: true, + reason, + latencyMs: Math.max(0, Date.now() - started), + backendAttempted: "cloudflare-workers-ai", + }; + if (extra?.httpStatus !== undefined) result.httpStatus = extra.httpStatus; + if (extra?.detail !== undefined) result.detail = extra.detail; + return result; +} + +/** + * Keyed extraction against Cloudflare Workers AI Clairic. Missing credentials, + * non-2xx responses, and bodies that are not JSON return `fallback` and do + * not throw. + */ +export async function extract< + const Fields extends Record, +>( + input: ExtractInput, + options?: ExtractOptions, +): Promise> { + const started = Date.now(); + const accountId = + definedString(options?.accountId) ?? + definedString(process.env["CLOUDFLARE_ACCOUNT_ID"]); + const token = + definedString(options?.token) ?? + definedString(process.env["CLOUDFLARE_API_TOKEN"]); + if (accountId === undefined || token === undefined) { + return fallback("no-key", started, { + detail: + "no Cloudflare credentials; set accountId and token or CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN", + }); + } + + const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${CLAIRIC_MODEL}`; + const fetchImpl = options?.fetch ?? fetch; + let response: Response; + try { + response = await fetchImpl(url, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "clairic", + state: input.state, + fields: input.fields, + }), + }); + } catch (cause) { + return fallback("network", started, { + detail: cause instanceof Error ? cause.message : String(cause), + }); + } + + if (!response.ok) { + return fallback("http-error", started, { httpStatus: response.status }); + } + + let body: unknown; + try { + body = await response.json(); + } catch (cause) { + return fallback("parse-error", started, { + detail: cause instanceof Error ? cause.message : String(cause), + }); + } + + const answers = answersOf(input.fields, body); + if (answers === undefined) { + return fallback("parse-error", started, { + detail: "response has no answers", + }); + } + + return { + fallback: false, + answers: answers as ExtractionAnswers, + modelId: "clairic", + latencyMs: Math.max(0, Date.now() - started), + }; +} From 7adcdb66b068788c88c7578d8cd40de07474d2a2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 26 Oct 2026 20:46:03 -0700 Subject: [PATCH 2/2] fix: read Clairic spans, estimate bounds, and list items --- src/extraction.test.ts | 97 +++++++++++++ src/extraction.ts | 312 +++++++++++++++++++++++++++++++---------- 2 files changed, 334 insertions(+), 75 deletions(-) diff --git a/src/extraction.test.ts b/src/extraction.test.ts index 2362e85..97cddf0 100644 --- a/src/extraction.test.ts +++ b/src/extraction.test.ts @@ -96,6 +96,91 @@ describe("extract", () => { expect(result.answers.days_to_pay.interval).toEqual({ p10: 12, p90: 45 }); }); + test("maps documented spans, estimate bounds, and list items", async () => { + const result = await extract( + { + state: "Invoice INV-2291 issued 2026-09-02, net 30.", + fields: { + invoice_number: text(), + days_to_pay: estimate({ min: 0, max: 120 }), + amounts: list("number"), + due_dates: list("date"), + }, + }, + { + accountId: "acct", + token: "tok", + fetch: async () => + new Response( + JSON.stringify({ + result: { + answers: { + invoice_number: { + type: "text", + value: "INV-2291", + span: [8, 16], + confidence: 1, + p_null: 3.8e-10, + }, + days_to_pay: { + type: "estimate", + value: 30, + median: 30, + p10: 21, + p90: 38, + }, + amounts: { type: "list", value: [250, 80] }, + due_dates: { type: "list", value: ["2026-10-02"] }, + }, + }, + }), + { status: 200 }, + ), + }, + ); + + if (result.fallback) throw new Error(result.detail ?? "expected answers"); + expect(result.answers.invoice_number).toEqual({ + value: "INV-2291", + start: 8, + end: 16, + }); + expect(result.answers.days_to_pay).toEqual({ + median: 30, + interval: { p10: 21, p90: 38 }, + }); + const amounts: number[] | null = result.answers.amounts.value; + expect(amounts).toEqual([250, 80]); + expect(result.answers.due_dates.value).toEqual(["2026-10-02"]); + }); + + test("rejects a non-string text value instead of a successful null", async () => { + const result = await extract( + { state: "x", fields: { invoice_number: text() } }, + { + accountId: "acct", + token: "tok", + fetch: async () => + new Response( + JSON.stringify({ + result: { + answers: { + invoice_number: { type: "text", value: 1080 }, + }, + }, + }), + { status: 200 }, + ), + }, + ); + + expect(result).toMatchObject({ fallback: true, reason: "parse-error" }); + if (!result.fallback) { + const value: string | null = result.answers.invoice_number.value; + expect(value).not.toBeNull(); + } + }); + test("falls back without credentials, on non-2xx, and on bad JSON", async () => { const missing = await extract( { state: "x", fields: { invoice_number: text() } }, @@ -151,6 +236,18 @@ describe("extraction and decisions stay disjoint", () => { category: choice("What is this?", { billing: null }), }, }); + + const listed = await extract( + { state: "x", fields: { amounts: list("number") } }, + { fetch: () => Promise.reject(new Error("no fetch")) }, + ); + if (!listed.fallback) { + function stringsOnly(value: string[] | null): string[] | null { + return value; + } + // @ts-expect-error number lists are not string[] + stringsOnly(listed.answers.amounts.value); + } } expect(probe).toBeFunction(); }); diff --git a/src/extraction.ts b/src/extraction.ts index 93c1318..324e605 100644 --- a/src/extraction.ts +++ b/src/extraction.ts @@ -1,3 +1,5 @@ +import { type } from "arktype"; + import type { FallbackReason, FallbackResult } from "./schemas.js"; const CLAIRIC_MODEL = "@cf/cloudflare/clairic"; @@ -10,7 +12,9 @@ export type NumberField = { unit?: string; instructions?: string; }; -export type ListField = { +export type ListItemKind = "text" | "number" | "date"; + +export type ListField = { type: "list"; items: Items; }; @@ -39,7 +43,11 @@ export type DateAnswer = { value: string | null }; export type NumberAnswer = { value: number | null; unit?: string }; -export type ListAnswer = { value: string[] | null }; +type ListValue = Items extends "number" ? number : string; + +export type ListAnswer = { + value: ListValue[] | null; +}; export type EstimateAnswer = { median: number | null; @@ -52,8 +60,8 @@ type AnswerOf = Field extends { type: "text" } ? DateAnswer : Field extends { type: "number" } ? NumberAnswer - : Field extends { type: "list" } - ? ListAnswer + : Field extends { type: "list"; items: infer Items extends string } + ? ListAnswer : Field extends { type: "estimate" } ? EstimateAnswer : never; @@ -121,7 +129,9 @@ function number(options?: { return field; } -function list(items: Items): ListField { +function list( + items: Items, +): ListField { return { type: "list", items }; } @@ -146,110 +156,262 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function numberOrUndefined(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) - ? value - : undefined; +type ParseError = { parseError: string }; + +const finiteNumber = type("number").narrow((value) => Number.isFinite(value)); + +const finiteOrNull = type("number | null").narrow( + (value) => value === null || Number.isFinite(value), +); + +const TextWire = type({ + "type?": "string", + "value?": "string | null", + "text?": "string | null", + "start?": finiteNumber, + "end?": finiteNumber, + "span?": [finiteNumber, finiteNumber], + "offsets?": { + "start?": finiteNumber, + "end?": finiteNumber, + }, +}); + +const DateWire = type("string").or( + type({ + "type?": "string", + "value?": "string | null", + "date?": "string | null", + }), +); + +const NumberWire = finiteNumber.or( + type({ + "type?": "string", + "value?": finiteOrNull, + "number?": finiteOrNull, + "unit?": "string", + }), +); + +const stringItems = type("string[]").or("null"); + +const numberItems = finiteNumber.array().or("null"); + +const StringListWire = type("string[]").or( + type({ + "type?": "string", + "value?": stringItems, + "items?": stringItems, + }), +); + +const NumberListWire = finiteNumber.array().or( + type({ + "type?": "string", + "value?": numberItems, + "items?": numberItems, + }), +); + +const EstimateInterval = type([finiteNumber, finiteNumber]).or( + type({ + "p10?": finiteNumber, + "low?": finiteNumber, + "p90?": finiteNumber, + "high?": finiteNumber, + }), +); + +const EstimateWire = type({ + "type?": "string", + "value?": finiteOrNull, + "median?": finiteOrNull, + "p10?": finiteNumber, + "p90?": finiteNumber, + "interval?": EstimateInterval, +}); + +function isParseError(value: unknown): value is ParseError { + return ( + typeof value === "object" && + value !== null && + "parseError" in value && + typeof value.parseError === "string" + ); } -function textAnswer(raw: unknown): TextAnswer { +function textAnswer(raw: unknown): TextAnswer | ParseError { if (typeof raw === "string") return { value: raw }; if (raw === null || raw === undefined) return { value: null }; - if (!isRecord(raw)) return { value: null }; - const direct = - typeof raw["value"] === "string" - ? raw["value"] - : typeof raw["text"] === "string" - ? raw["text"] + const parsed = TextWire(raw); + if (parsed instanceof type.errors) return { parseError: parsed.summary }; + const value = + typeof parsed.value === "string" + ? parsed.value + : typeof parsed.text === "string" + ? parsed.text : null; - const offsets = isRecord(raw["offsets"]) ? raw["offsets"] : undefined; - const start = - numberOrUndefined(raw["start"]) ?? - (offsets !== undefined ? numberOrUndefined(offsets["start"]) : undefined); - const end = - numberOrUndefined(raw["end"]) ?? - (offsets !== undefined ? numberOrUndefined(offsets["end"]) : undefined); - const answer: TextAnswer = { value: direct }; + const start = parsed.start ?? parsed.span?.[0] ?? parsed.offsets?.start; + const end = parsed.end ?? parsed.span?.[1] ?? parsed.offsets?.end; + const answer: TextAnswer = { value }; if (start !== undefined) answer.start = start; if (end !== undefined) answer.end = end; return answer; } -function dateAnswer(raw: unknown): DateAnswer { - if (typeof raw === "string") return { value: raw }; +function dateAnswer(raw: unknown): DateAnswer | ParseError { if (raw === null || raw === undefined) return { value: null }; - if (!isRecord(raw)) return { value: null }; - const value = raw["value"] ?? raw["date"]; - return { value: typeof value === "string" ? value : null }; + const parsed = DateWire(raw); + if (parsed instanceof type.errors) return { parseError: parsed.summary }; + if (typeof parsed === "string") return { value: parsed }; + const value = + typeof parsed.value === "string" + ? parsed.value + : typeof parsed.date === "string" + ? parsed.date + : null; + return { value }; } -function numberAnswer(raw: unknown): NumberAnswer { - if (typeof raw === "number" && Number.isFinite(raw)) return { value: raw }; - if (raw === null || raw === undefined || !isRecord(raw)) - return { value: null }; - const value = numberOrUndefined(raw["value"] ?? raw["number"]); - const unit = typeof raw["unit"] === "string" ? raw["unit"] : undefined; - const answer: NumberAnswer = { value: value ?? null }; - if (unit !== undefined) answer.unit = unit; +function numberAnswer(raw: unknown): NumberAnswer | ParseError { + if (raw === null || raw === undefined) return { value: null }; + const parsed = NumberWire(raw); + if (parsed instanceof type.errors) return { parseError: parsed.summary }; + if (typeof parsed === "number") return { value: parsed }; + const value = + typeof parsed.value === "number" + ? parsed.value + : typeof parsed.number === "number" + ? parsed.number + : null; + const answer: NumberAnswer = { value }; + if (parsed.unit !== undefined) answer.unit = parsed.unit; return answer; } -function listAnswer(raw: unknown): ListAnswer { - if (Array.isArray(raw)) { - return { - value: raw.filter((item): item is string => typeof item === "string"), - }; +function listValues( + parsed: + | { value?: string[] | number[] | null; items?: string[] | number[] | null } + | string[] + | number[], +): string[] | number[] | null { + if (Array.isArray(parsed)) return parsed; + if (parsed.value !== undefined) return parsed.value; + return parsed.items ?? null; +} + +function listAnswer( + items: ListItemKind, + raw: unknown, +): ListAnswer<"text"> | ListAnswer<"number"> | ParseError { + if (raw === null || raw === undefined) return { value: null }; + const parsed = (items === "number" ? NumberListWire : StringListWire)(raw); + if (parsed instanceof type.errors) return { parseError: parsed.summary }; + const values = listValues(parsed); + if (values === null) return { value: null }; + if (items === "number") { + if (!values.every((item) => typeof item === "number")) { + return { parseError: "list items must be numbers" }; + } + return { value: values }; } - if (raw === null || raw === undefined || !isRecord(raw)) - return { value: null }; - const value = raw["value"] ?? raw["items"]; - if (value === null) return { value: null }; - if (!Array.isArray(value)) return { value: null }; - return { - value: value.filter((item): item is string => typeof item === "string"), - }; + if (!values.every((item) => typeof item === "string")) { + return { parseError: "list items must be strings" }; + } + return { value: values }; } -function estimateAnswer(raw: unknown): EstimateAnswer { - if (raw === null || raw === undefined || !isRecord(raw)) { - return { median: null }; +function isNumberPair( + value: + | readonly [number, number] + | { p10?: number; low?: number; p90?: number; high?: number }, +): value is readonly [number, number] { + return Array.isArray(value); +} + +function estimateInterval(parsed: { + p10?: number; + p90?: number; + interval?: + | readonly [number, number] + | { p10?: number; low?: number; p90?: number; high?: number }; +}): { p10: number; p90: number } | undefined { + if (parsed.p10 !== undefined && parsed.p90 !== undefined) { + return { p10: parsed.p10, p90: parsed.p90 }; } - const median = numberOrUndefined(raw["median"] ?? raw["value"]); - const intervalRaw = raw["interval"]; - let interval: { p10: number; p90: number } | undefined; - if (Array.isArray(intervalRaw) && intervalRaw.length >= 2) { - const p10 = numberOrUndefined(intervalRaw[0]); - const p90 = numberOrUndefined(intervalRaw[1]); - if (p10 !== undefined && p90 !== undefined) interval = { p10, p90 }; - } else if (isRecord(intervalRaw)) { - const p10 = numberOrUndefined(intervalRaw["p10"] ?? intervalRaw["low"]); - const p90 = numberOrUndefined(intervalRaw["p90"] ?? intervalRaw["high"]); - if (p10 !== undefined && p90 !== undefined) interval = { p10, p90 }; + const interval = parsed.interval; + if (interval === undefined) return undefined; + if (isNumberPair(interval)) { + const low = interval[0]; + const high = interval[1]; + if (low === undefined || high === undefined) return undefined; + return { p10: low, p90: high }; } - const answer: EstimateAnswer = { median: median ?? null }; + const low = interval.p10 ?? interval.low; + const high = interval.p90 ?? interval.high; + if (low === undefined || high === undefined) return undefined; + return { p10: low, p90: high }; +} + +function estimateAnswer(raw: unknown): EstimateAnswer | ParseError { + if (raw === null || raw === undefined) return { median: null }; + const parsed = EstimateWire(raw); + if (parsed instanceof type.errors) return { parseError: parsed.summary }; + const median = parsed.median ?? parsed.value ?? null; + const answer: EstimateAnswer = { median }; + const interval = estimateInterval(parsed); if (interval !== undefined) answer.interval = interval; return answer; } -function mapAnswer(field: ExtractionField, raw: unknown): unknown { +function mapAnswer( + field: ExtractionField, + raw: unknown, +): + | TextAnswer + | DateAnswer + | NumberAnswer + | ListAnswer<"text"> + | ListAnswer<"number"> + | EstimateAnswer + | ParseError { if (field.type === "text") return textAnswer(raw); if (field.type === "date") return dateAnswer(raw); if (field.type === "number") return numberAnswer(raw); - if (field.type === "list") return listAnswer(raw); + if (field.type === "list") return listAnswer(field.items, raw); return estimateAnswer(raw); } -function answersOf( - fields: Record, +function isExtractionAnswers>( + fields: Fields, + answers: Record, +): answers is ExtractionAnswers { + return Object.keys(fields).every((key) => Object.hasOwn(answers, key)); +} + +function answersOf>( + fields: Fields, raw: unknown, -): Record | undefined { +): ExtractionAnswers | ParseError { const envelope = isRecord(raw) && isRecord(raw["result"]) ? raw["result"] : raw; - if (!isRecord(envelope) || !isRecord(envelope["answers"])) return undefined; + if (!isRecord(envelope) || !isRecord(envelope["answers"])) { + return { parseError: "response has no answers" }; + } const source = envelope["answers"]; const answers: Record = {}; for (const [name, field] of Object.entries(fields)) { - answers[name] = mapAnswer(field, source[name]); + const parsed = mapAnswer(field, source[name]); + if (isParseError(parsed)) { + return { + parseError: `answer "${name}" failed schema validation: ${parsed.parseError}`, + }; + } + answers[name] = parsed; + } + if (!isExtractionAnswers(fields, answers)) { + return { parseError: "response has no answers" }; } return answers; } @@ -331,15 +493,15 @@ export async function extract< } const answers = answersOf(input.fields, body); - if (answers === undefined) { + if (isParseError(answers)) { return fallback("parse-error", started, { - detail: "response has no answers", + detail: answers.parseError.slice(0, 240), }); } return { fallback: false, - answers: answers as ExtractionAnswers, + answers, modelId: "clairic", latencyMs: Math.max(0, Date.now() - started), };