Ctrl K
ComunidadGuías…

packages/ai/src/decision.ts

Rama dev · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { Effect, Schema } from "effect"
import { DecisionClient, Service, type Execute } from "./decision-client.js"
import { Media } from "./media.js"
import { tryRequest } from "./media-model.js"
import {
  AIError,
  type BaseRequest,
  type HttpOptions,
  ModelID,
  ProviderID,
  ProviderMetadata,
  ProviderOptions,
  type OpenString,
} from "./schema/index.js"

// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------

export const DecisionTextPart = Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })
export const DecisionJsonPart = Schema.Struct({ type: Schema.Literal("json"), value: Schema.Json })
export const DecisionMediaPart = Schema.Struct({
  type: Schema.Literal("media"),
  media: Media.AssetSchema,
  filename: Schema.optional(Schema.String),
})
export const DecisionPart = Schema.Union([DecisionTextPart, DecisionJsonPart, DecisionMediaPart]).pipe(
  Schema.toTaggedUnion("type"),
)
export type DecisionPart = Schema.Schema.Type<typeof DecisionPart>

/** A media part as input: `media` may be plain data, with `mediaType` naming its type when the content can't. */
export type DecisionMediaPartInput = Omit<typeof DecisionMediaPart.Type, "media"> & {
  readonly media: Media.Input
  readonly mediaType?: string
}
export type DecisionPartInput = Exclude<DecisionPart, { readonly type: "media" }> | DecisionMediaPartInput

/** A plain object is one `json` part. */
export type DecisionInput = typeof Schema.JsonObject.Type | ReadonlyArray<DecisionPartInput>

// ---------------------------------------------------------------------------
// Questions
// ---------------------------------------------------------------------------

/** Choice values are typed: `true` and `"true"` are different values. */
export const ChoiceValue = Schema.Union([Schema.String, Schema.Boolean])
export type ChoiceValue = Schema.Schema.Type<typeof ChoiceValue>

export const PredicateQuestion = Schema.Struct({
  name: Schema.String,
  type: Schema.Literal("predicate"),
  instructions: Schema.String,
  /** What counts as true and as false, for statements the instructions alone leave ambiguous. */
  criteria: Schema.optional(
    Schema.Struct({ true: Schema.optional(Schema.String), false: Schema.optional(Schema.String) }),
  ),
})
export type PredicateQuestion = Schema.Schema.Type<typeof PredicateQuestion>

export const ChoiceQuestion = Schema.Struct({
  name: Schema.String,
  type: Schema.Literal("choice"),
  instructions: Schema.String,
  choices: Schema.Array(Schema.Struct({ value: ChoiceValue, description: Schema.optional(Schema.String) })),
})
export type ChoiceQuestion = Schema.Schema.Type<typeof ChoiceQuestion>

export const ScoreQuestion = Schema.Struct({
  name: Schema.String,
  type: Schema.Literal("score"),
  instructions: Schema.String,
  levels: Schema.Array(Schema.Struct({ label: Schema.String, description: Schema.optional(Schema.String) })),
})
export type ScoreQuestion = Schema.Schema.Type<typeof ScoreQuestion>

export const DecisionQuestion = Schema.Union([PredicateQuestion, ChoiceQuestion, ScoreQuestion]).pipe(
  Schema.toTaggedUnion("type"),
)
export type DecisionQuestion = Schema.Schema.Type<typeof DecisionQuestion>
export type DecisionQuestions = ReadonlyArray<DecisionQuestion>

// Only what makes a request meaningless is checked here; counts and sizes are the host's to enforce.
const DecisionQuestionList = Schema.Array(DecisionQuestion).pipe(
  Schema.refine((x): x is typeof x => x.length > 0, { message: "Decision questions must not be empty" }),
  Schema.refine((x): x is typeof x => new Set(x.map((question) => question.name)).size === x.length, {
    message: "Decision question names must be unique",
  }),
)

// ---------------------------------------------------------------------------
// Answers
// ---------------------------------------------------------------------------

const Probability = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))

export const PredicateAnswer = Schema.Struct({
  name: Schema.String,
  type: Schema.Literal("predicate"),
  probability: Probability,
})
export type PredicateAnswer = Schema.Schema.Type<typeof PredicateAnswer>

export const ChoiceAnswer = Schema.Struct({
  name: Schema.String,
  type: Schema.Literal("choice"),
  choice: ChoiceValue,
  probabilities: Schema.Array(Schema.Struct({ value: ChoiceValue, probability: Probability })),
  confidence: Probability,
})
export type ChoiceAnswer = Schema.Schema.Type<typeof ChoiceAnswer>

/** `score` is the probability-weighted level index; `probabilities` has one entry per level. */
export const ScoreAnswer = Schema.Struct({
  name: Schema.String,
  type: Schema.Literal("score"),
  score: Schema.Number,
  probabilities: Schema.Array(Schema.Struct({ value: Schema.Int, label: Schema.String, probability: Probability })),
  confidence: Probability,
})
export type ScoreAnswer = Schema.Schema.Type<typeof ScoreAnswer>

/** Why a question has no answer; hosts may report reasons beyond these. */
export type ErrorReason = OpenString<"refusal">
export const ErrorReason: Schema.Codec<ErrorReason> = Schema.Union([Schema.Literal("refusal"), Schema.String])

/** This one question has no answer; the other answers still came back. */
export const ErrorAnswer = Schema.Struct({
  name: Schema.String,
  type: Schema.Literal("error"),
  reason: ErrorReason,
  message: Schema.optional(Schema.String),
})
export type ErrorAnswer = Schema.Schema.Type<typeof ErrorAnswer>

export const DecisionAnswer = Schema.Union([PredicateAnswer, ChoiceAnswer, ScoreAnswer, ErrorAnswer]).pipe(
  Schema.toTaggedUnion("type"),
)
export type DecisionAnswer = Schema.Schema.Type<typeof DecisionAnswer>

type ChoiceValueOf<Question> = Question extends { readonly choices: ReadonlyArray<{ readonly value: infer Value }> }
  ? Value
  : never

export type AnswerFor<Question extends DecisionQuestion> =
  | (Question extends { readonly type: "predicate" }
      ? PredicateAnswer & { readonly name: Question["name"] }
      : Question extends { readonly type: "choice" }
        ? {
            readonly name: Question["name"]
            readonly type: "choice"
            readonly choice: ChoiceValueOf<Question>
            readonly probabilities: ReadonlyArray<{
              readonly value: ChoiceValueOf<Question>
              readonly probability: number
            }>
            readonly confidence: number
          }
        : ScoreAnswer & { readonly name: Question["name"] })
  | (ErrorAnswer & { readonly name: Question["name"] })

/** One answer per question, in question order. */
export type AnswersFor<Questions extends DecisionQuestions> = {
  readonly [Index in keyof Questions]: AnswerFor<Questions[Index]>
}

// ---------------------------------------------------------------------------
// Model
// ---------------------------------------------------------------------------

export type DecisionOptions = Record<string, unknown>

/** What a decision model's route does; `DecisionRoute.make(...).model(...)` builds one from a protocol. */
export interface DecisionModelRoute<Options extends DecisionOptions = DecisionOptions> {
  readonly id: string
  generate(request: DecisionRequest<DecisionModel<Options>>, execute: Execute): Effect.Effect<DecisionResponse, AIError>
}

/** Host quirks a protocol adapts to, like `LanguageModelCompatibility` for language models. */
export class DecisionModelCompatibility extends Schema.Class<DecisionModelCompatibility>("Decision.ModelCompatibility")(
  {
    /**
     * Where a System One host reads media, from models.dev `[api.systemone] compatibility.media_input`, named after
     * where the media goes in the body: `assets` (TypeSafe's assets map, referenced from the state), `state` (items
     * inside a state list) or `root` (one top-level list per kind: `images`, `audio`, `videos`).
     */
    mediaInput: Schema.optional(Schema.Literals(["assets", "state", "root"])),
  },
) {}

export namespace DecisionModelCompatibility {
  export type Input = DecisionModelCompatibility | ConstructorParameters<typeof DecisionModelCompatibility>[0]

  export const make = (input: Input) =>
    input instanceof DecisionModelCompatibility ? input : new DecisionModelCompatibility(input)
}

export class DecisionModel<Options extends DecisionOptions = DecisionOptions> {
  declare protected readonly _Options: Options
  readonly id: ModelID
  readonly provider: ProviderID
  readonly route: DecisionModelRoute<Options>
  readonly http?: HttpOptions
  readonly compatibility?: DecisionModelCompatibility
  /** Defaults under each request's `providerOptions`, such as options a provider package reads from its settings. */
  readonly providerOptions?: ProviderOptions

  constructor(input: DecisionModel.Input<Options>) {
    this.id = input.id
    this.provider = input.provider
    this.route = input.route
    this.http = input.http
    this.compatibility = input.compatibility
    this.providerOptions = input.providerOptions
  }

  static make<Options extends DecisionOptions = DecisionOptions>(input: DecisionModel.MakeInput<Options>) {
    return new DecisionModel<Options>({
      ...input,
      id: ModelID.make(input.id),
      provider: ProviderID.make(input.provider),
    })
  }
}

export namespace DecisionModel {
  export interface Input<Options extends DecisionOptions = DecisionOptions> {
    readonly id: ModelID
    readonly provider: ProviderID
    readonly route: DecisionModelRoute<Options>
    readonly http?: HttpOptions
    readonly compatibility?: DecisionModelCompatibility
    readonly providerOptions?: ProviderOptions
  }

  export interface MakeInput<Options extends DecisionOptions = DecisionOptions>
    extends Omit<Input<Options>, "id" | "provider"> {
    readonly id: string | ModelID
    readonly provider: string | ProviderID
  }
}

export const DecisionModelSchema = Schema.declare((value): value is DecisionModel => value instanceof DecisionModel, {
  expected: "Decision.Model",
})

export type DecisionModelOptions<Model> = Model extends DecisionModel<infer Options> ? Options : never

// ---------------------------------------------------------------------------
// Request and response
// ---------------------------------------------------------------------------

const DecisionRequestSchema = Schema.Struct({
  model: DecisionModelSchema,
  input: Schema.Array(DecisionPart),
  questions: DecisionQuestionList,
  providerOptions: Schema.optional(ProviderOptions),
})

/** A normalized decision request, the plain object `ai.decision.request(...)` returns. */
export interface DecisionRequest<
  Model extends DecisionModel = DecisionModel,
  Questions extends DecisionQuestions = DecisionQuestions,
> extends BaseRequest<Model, DecisionModelOptions<Model>> {
  readonly input: ReadonlyArray<DecisionPart>
  readonly questions: Questions
}

/** Input accepted by `ai.decision.request` and `generate`. A `DecisionRequest` is also a valid input. */
export interface DecisionRequestInput<
  Model extends DecisionModel = DecisionModel,
  Questions extends DecisionQuestions = DecisionQuestions,
> extends BaseRequest<Model, NoInfer<DecisionModelOptions<Model>>> {
  readonly input: DecisionInput
  readonly questions: Questions
}

/** What decision hosts report; their raw usage stays in the response's `providerMetadata`. */
export class DecisionUsage extends Schema.Class<DecisionUsage>("Decision.Usage")({
  inputTokens: Schema.optional(Schema.Number),
  outputTokens: Schema.optional(Schema.Number),
  totalTokens: Schema.optional(Schema.Number),
  /** USD the host says it charged for this request. */
  cost: Schema.optional(Schema.Number),
}) {}

export class DecisionResponse extends Schema.Class<DecisionResponse>("Decision.Response")({
  answers: Schema.Array(DecisionAnswer),
  usage: Schema.optional(DecisionUsage),
  providerMetadata: Schema.optional(ProviderMetadata),
}) {}

export type DecisionResponseFor<Questions extends DecisionQuestions> = Omit<DecisionResponse, "answers"> & {
  readonly answers: AnswersFor<Questions>
}

// ---------------------------------------------------------------------------
// Call API
// ---------------------------------------------------------------------------

/** Normalize decision request input into a plain `DecisionRequest`; a plain object `input` becomes one `json` part. */
export const request = <const Model extends DecisionModel, const Questions extends DecisionQuestions>(
  input: DecisionRequestInput<Model, Questions>,
): DecisionRequest<Model, Questions> => ({
  ...input,
  input: isParts(input.input) ? input.input.map(decisionPart) : [{ type: "json", value: input.input }],
})

const validate = Schema.decodeUnknownSync(DecisionRequestSchema)

// `Array.isArray` doesn't narrow a readonly array union, so the object branch would keep the parts type.
const isParts = (input: DecisionInput): input is ReadonlyArray<DecisionPartInput> => Array.isArray(input)

const decisionPart = (part: DecisionPartInput): DecisionPart => {
  if (part.type !== "media") return part
  const { mediaType, ...rest } = part
  return { ...rest, media: Media.make(part.media, mediaType) }
}

export function generate<const Model extends DecisionModel, const Questions extends DecisionQuestions>(
  input: DecisionRequestInput<Model, Questions>,
): Effect.Effect<DecisionResponseFor<Questions>, AIError, Service>
export function generate(input: DecisionRequestInput) {
  return tryRequest(() => {
    const normalized = request(input)
    validate(normalized)
    return normalized
  }).pipe(Effect.flatMap(DecisionClient.generate))
}

export const Decision = {
  request,
  generate,
} as const