ai
Version:
AI SDK by Vercel - build apps like ChatGPT, Claude, Gemini, and more with a single interface for any model using the Vercel AI Gateway or go direct to OpenAI, Anthropic, Google, or any other model provider.
284 lines (264 loc) • 7.6 kB
text/typescript
import {
InvalidResponseDataError,
type Experimental_DecisionModelV4State as DecisionModelV4State,
type Experimental_ExtractionModelV4Field as ExtractionField,
} from '@ai-sdk/provider';
import type { DecisionState } from '../decide/decision-state';
import {
hasExactKeys,
invalidInput,
isProbability,
isRecord,
isSerializableJSON,
validateDecisionState,
} from '../decide/validate-decision';
type ValueField = Exclude<ExtractionField, { type: 'list' }>;
const durationUnits = [
'seconds',
'minutes',
'hours',
'days',
'weeks',
'months',
'years',
];
const listItems = ['text', 'number', 'date'];
const time = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/;
const datetime =
/^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d{1,9})?)?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)?$/;
function isDate(value: unknown): value is string {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return false;
}
const date = new Date(`${value}T00:00:00Z`);
return (
!Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
);
}
function isNumber(
value: unknown,
{ integer, decimals }: { integer?: boolean; decimals?: number },
): value is number {
return (
typeof value === 'number' &&
Number.isFinite(value) &&
(!integer || Number.isInteger(value)) &&
(decimals === undefined || Number(value.toFixed(decimals)) === value)
);
}
function isDenseArray(value: unknown): value is unknown[] {
if (!Array.isArray(value) || Object.keys(value).length !== value.length) {
return false;
}
for (let index = 0; index < value.length; index++) {
if (!Object.hasOwn(value, index)) return false;
}
return true;
}
function isValue(field: ValueField, value: unknown): boolean {
switch (field.type) {
case 'text':
return typeof value === 'string';
case 'number':
return isNumber(value, field);
case 'duration':
return isNumber(value, field) && value >= 0;
case 'date':
return isDate(value);
case 'time':
return typeof value === 'string' && time.test(value);
case 'datetime':
return (
typeof value === 'string' &&
datetime.test(value) &&
isDate(value.slice(0, 10))
);
}
}
function isSource(source: unknown, state: DecisionModelV4State): boolean {
if (
!isRecord(source) ||
typeof source.part !== 'number' ||
!Number.isInteger(source.part) ||
source.part < 0 ||
source.part >= state.length
) {
return false;
}
const part = state[source.part];
switch (source.type) {
case 'text':
return (
part.type === 'text' &&
typeof source.start === 'number' &&
typeof source.end === 'number' &&
Number.isInteger(source.start) &&
Number.isInteger(source.end) &&
source.start >= 0 &&
source.start < source.end &&
source.end <= part.text.length
);
case 'file':
return part.type === 'file';
default:
return false;
}
}
export function validateExtractionInput({
state,
fields,
referenceDate,
}: {
state: DecisionState;
fields: Readonly<Record<string, ExtractionField>>;
referenceDate?: string;
}) {
validateDecisionState(state);
if (referenceDate !== undefined && !isDate(referenceDate)) {
invalidInput(
'referenceDate',
referenceDate,
'must be a YYYY-MM-DD calendar date',
);
}
if (Object.keys(fields).length === 0) {
invalidInput('fields', fields, 'must be a nonempty field map');
}
for (const [id, field] of Object.entries(fields)) {
const parameter = `fields.${id}`;
if (!isSerializableJSON(field.instructions)) {
invalidInput(
parameter,
field,
'instructions must not contain non-finite numbers, cycles, or array holes',
);
}
if (
'decimals' in field &&
field.decimals !== undefined &&
!(
Number.isInteger(field.decimals) &&
field.decimals >= 0 &&
field.decimals <= 6
)
) {
invalidInput(parameter, field, 'decimals must be an integer from 0 to 6');
}
if (
field.type === 'duration' &&
field.unit !== undefined &&
!durationUnits.includes(field.unit)
) {
invalidInput(
parameter,
field,
`duration unit must be one of ${durationUnits.join(', ')}`,
);
}
if (field.type === 'list' && !listItems.includes(field.items)) {
invalidInput(
parameter,
field,
`list items must be one of ${listItems.join(', ')}`,
);
}
}
}
function invalidAnswer(answers: unknown, message: string): never {
throw new InvalidResponseDataError({ data: answers, message });
}
export function validateExtractionAnswers({
fields,
state,
answers,
}: {
fields: Readonly<Record<string, ExtractionField>>;
state: DecisionModelV4State;
answers: unknown;
}) {
if (!isRecord(answers) || !hasExactKeys(answers, Object.keys(fields))) {
invalidAnswer(
answers,
'Extraction must return exactly one answer for every field.',
);
}
for (const [id, field] of Object.entries(fields)) {
const answer = answers[id];
if (isRecord(answer) && answer.type === 'refusal') continue;
if (!isRecord(answer) || answer.type !== field.type) {
invalidAnswer(
answers,
`Field "${id}" returned an answer with the wrong type.`,
);
}
if (
(answer.pNull !== undefined && !isProbability(answer.pNull)) ||
(answer.confidence !== undefined && !isProbability(answer.confidence))
) {
invalidAnswer(
answers,
`Field "${id}" must return pNull and confidence as finite probabilities in [0, 1].`,
);
}
if (field.type === 'list') {
const item: ValueField =
field.items === 'number'
? {
type: 'number',
integer: field.integer,
decimals: field.decimals,
}
: { type: field.items };
const value = answer.value;
if (
value !== null &&
!(isDenseArray(value) && value.every(entry => isValue(item, entry)))
) {
invalidAnswer(
answers,
`Field "${id}" must return null or a list of ${field.items} values.`,
);
}
const items = answer.items;
if (
items !== undefined &&
!(
isDenseArray(items) &&
Array.isArray(value) &&
items.length === value.length &&
items.every(
entry =>
isRecord(entry) &&
(entry.confidence === undefined ||
isProbability(entry.confidence)) &&
(entry.source === undefined || isSource(entry.source, state)),
)
)
) {
invalidAnswer(
answers,
`Field "${id}" must return one item entry per list value, with confidences in [0, 1] and sources inside the state.`,
);
}
continue;
}
if (answer.value !== null && !isValue(field, answer.value)) {
invalidAnswer(
answers,
`Field "${id}" must return null or a valid ${field.type} value.`,
);
}
if (answer.guess !== undefined && !isValue(field, answer.guess)) {
invalidAnswer(
answers,
`Field "${id}" must return a valid ${field.type} guess.`,
);
}
if (answer.source !== undefined && !isSource(answer.source, state)) {
invalidAnswer(
answers,
`Field "${id}" returned a source outside the state.`,
);
}
}
}