@molecule/api-ai-decisions

Core interface · ai-decisions · API (Node) · v1.0.0 · Apache-2.0

Typed AI decisions for molecule.dev — answer choice, score and yes/no questions about text or JSON with calibrated probabilities, behind swappable bonds (Laya, Jev, any LLM)

npm install @molecule/api-ai-decisions

npm · Source on GitHub

How it works

@molecule/api-ai-decisions is the ai-decisions core interface on the API (Node) side: the API your app calls, with no vendor inside.

Choose the implementation by bonding one of its 3 providers: @molecule/api-ai-decisions-jev, @molecule/api-ai-decisions-laya, @molecule/api-ai-decisions-llm.

import { setProvider, requireProvider } from '@molecule/api-ai-decisions'
import { provider as laya } from '@molecule/api-ai-decisions-laya'

setProvider(laya) // at startup — reads LAYA_URL / LAYA_API_KEY on first use

const { answers } = await requireProvider().decide({
  state: { subject: 'Charged twice', body: 'I was billed twice this month, I want my money back' },
  questions: {
    queue: {
      type: 'choice',
      instructions: 'Which team should handle this?',
      criteria: {
        billing: 'invoices, refunds, charges',
        tech: 'bugs, login, outages',
        other: 'anything else',
      },
    },
    urgency: {
      type: 'score',
      instructions: 'How upset is the customer?',
      criteria: ['calm', 'firm', 'angry', 'furious'],
    },
    refund: { type: 'yesNo', instructions: 'The customer is asking for a refund.' },
  },
  minConfidence: 0.7,
})

answers.queue.choice // 'billing'
answers.urgency.level // 2  (answers.urgency.score is the expected level, e.g. 2.64)
answers.refund.probability // 0.97
if (answers.queue.lowConfidence) {
  // send to a human instead of auto-routing
}

Providers (3): @molecule/api-ai-decisions-jev, @molecule/api-ai-decisions-laya, @molecule/api-ai-decisions-llm

Works with: @molecule/api-bond, @molecule/api-i18n

Reference

Typed AI decisions for molecule.dev.

Ask typed questions about a piece of text or JSON and get probabilities back, not generated text: pick one option (choice), rate on an ordered scale (score), or test a statement (yesNo). Use it for routing and triage (which queue, how urgent), guardrails and moderation (is this spam, a jailbreak, a refund request), and any branch in your code that needs a judgment call about language. Many questions share one call.

This core defines the AIDecisionsProvider contract and its bond accessor only. Bond one provider:

BondWhat answersWhen
@molecule/api-ai-decisions-layathe open-weights Laya model (Apache-2.0) on a laya-serve host you run — or any other self-hosted /v1/systemone server, such as Kev (Qwen-based, GPU/MLX)self-hosted, ~30–40 ms/question on a GPU (Laya), data stays with you
@molecule/api-ai-decisions-jevTypeSafe's hosted Jev APIno model to host; English-first
@molecule/api-ai-decisions-llmwhatever ai chat bond is bondedno extra service; slower and costlier per call

Quick Start

import { setProvider, requireProvider } from '@molecule/api-ai-decisions'
import { provider as laya } from '@molecule/api-ai-decisions-laya'

setProvider(laya) // at startup — reads LAYA_URL / LAYA_API_KEY on first use

const { answers } = await requireProvider().decide({
  state: { subject: 'Charged twice', body: 'I was billed twice this month, I want my money back' },
  questions: {
    queue: {
      type: 'choice',
      instructions: 'Which team should handle this?',
      criteria: {
        billing: 'invoices, refunds, charges',
        tech: 'bugs, login, outages',
        other: 'anything else',
      },
    },
    urgency: {
      type: 'score',
      instructions: 'How upset is the customer?',
      criteria: ['calm', 'firm', 'angry', 'furious'],
    },
    refund: { type: 'yesNo', instructions: 'The customer is asking for a refund.' },
  },
  minConfidence: 0.7,
})

answers.queue.choice // 'billing'
answers.urgency.level // 2  (answers.urgency.score is the expected level, e.g. 2.64)
answers.refund.probability // 0.97
if (answers.queue.lowConfidence) {
  // send to a human instead of auto-routing
}

Type

core

Installation

npm install @molecule/api-ai-decisions @molecule/api-bond @molecule/api-i18n

API

Interfaces

AIDecisionsProvider

AI decisions provider interface. Implemented by the Laya, Jev and LLM bonds.

interface AIDecisionsProvider {
  /** Provider identifier. */
  readonly name: string

  /**
   * Answer every question about `state`.
   *
   * @param input - The state, the questions and options.
   * @returns One typed answer per question id.
   */
  decide<Q extends Record<string, DecisionQuestion>>(
    input: DecideInput<Q>,
  ): Promise<DecideResult<Q>>
}

AnswerBase

Fields every answer carries.

interface AnswerBase {
  /**
   * Probability mass on the reported answer (the highest option probability;
   * `max(p, 1 - p)` for yes/no), in `0..1`. Every bond computes it this same
   * way from the probabilities, so a threshold means the same thing whichever
   * provider is bonded — it is NOT the vendor's own `confidence` field.
   */
  confidence: number
  /** Set only when `minConfidence` was passed: `true` when `confidence` fell below it. */
  lowConfidence?: boolean
}

ChoiceAnswer

Answer to a ChoiceQuestion.

interface ChoiceAnswer extends AnswerBase {
  type: 'choice'
  /** The most likely option — always one of the question's `criteria` keys. */
  choice: string
  /** Probability per option (every `criteria` key present), summing to ~1. */
  probabilities: Record<string, number>
}

ChoiceQuestion

Pick exactly one option.

interface ChoiceQuestion {
  type: 'choice'
  /** What is being decided, e.g. `'Which team should handle this ticket?'`. */
  instructions: string
  /**
   * The options, keyed by the label you want back, each with a short
   * description of when it applies. `{ billing: 'invoices, refunds', tech: 'bugs, outages' }`.
   */
  criteria: Record<string, string>
}

DecideInput

Input to one decision request.

interface DecideInput<
  Q extends Record<string, DecisionQuestion> = Record<string, DecisionQuestion>,
> {
  /** What the questions are about. */
  state: DecisionState
  /** The questions, keyed by an id you choose; answers come back under the same ids. */
  questions: Q
  /** Provider-specific model / checkpoint id (e.g. `'jev-latest'`, `'multilingual'`). */
  model?: string
  /** Mark answers whose `confidence` is below this (`0..1`) with `lowConfidence: true`. */
  minConfidence?: number
  /** Abort signal to cancel the in-flight request. */
  signal?: AbortSignal
}

DecideResult

Result of one decision request.

interface DecideResult<
  Q extends Record<string, DecisionQuestion> = Record<string, DecisionQuestion>,
> {
  /** One answer per question id. */
  answers: AnswersFor<Q>
  /** The model or checkpoint that answered, when the provider says. */
  model?: string
  /** Token usage, when reported. */
  usage?: DecisionUsage
}

DecisionUsage

Token usage, when the provider reports it.

interface DecisionUsage {
  inputTokens: number
  outputTokens: number
}

ScoreAnswer

Answer to a ScoreQuestion.

interface ScoreAnswer extends AnswerBase {
  type: 'score'
  /** Expected level index — may fall between levels (e.g. `2.64`). */
  score: number
  /** The most likely level index (`0..criteria.length - 1`). */
  level: number
  /** Probability per level, indexed like `criteria`. */
  probabilities: number[]
}

ScoreQuestion

Place the state on an ordered scale.

interface ScoreQuestion {
  type: 'score'
  /** What is being rated, e.g. `'How urgent is this?'`. */
  instructions: string
  /**
   * The levels, lowest first. Level `i` is described by `criteria[i]`:
   * `['calm', 'firm', 'angry', 'furious']`.
   */
  criteria: string[]
}

YesNoAnswer

Answer to a YesNoQuestion.

interface YesNoAnswer extends AnswerBase {
  type: 'yesNo'
  /** Probability that the statement is true, in `0..1`. */
  probability: number
  /** `probability >= 0.5`. Prefer thresholding `probability` yourself when the cost of each mistake differs. */
  answer: boolean
}

YesNoQuestion

How likely is a statement true. (Called noul on the Jev/Laya wire.)

interface YesNoQuestion {
  type: 'yesNo'
  /** The statement to test, e.g. `'The customer is asking for a refund.'`. */
  instructions: string
  /** Optional descriptions of what counts as yes and as no. */
  criteria?: { yes?: string; no?: string }
}

Types

AnswersFor

Maps a questions object to its answers object, so result.answers.department.choice is typed when the questions are literal.

type AnswersFor<Q extends Record<string, DecisionQuestion>> = {
  [K in keyof Q]: Q[K] extends ChoiceQuestion
    ? ChoiceAnswer
    : Q[K] extends ScoreQuestion
      ? ScoreAnswer
      : YesNoAnswer
}

DecisionAnswer

Any answer. answers[id].type matches questions[id].type.

type DecisionAnswer = ChoiceAnswer | ScoreAnswer | YesNoAnswer

DecisionQuestion

Any question a decision provider answers.

type DecisionQuestion = ChoiceQuestion | ScoreQuestion | YesNoQuestion

DecisionState

What the questions are about: plain text, or a JSON object / array (an email with headers, a ticket with metadata, a chat log).

type DecisionState = string | Record<string, unknown> | unknown[]

Functions

getAllProviders()

Retrieves all named AI decisions providers as a Map keyed by name.

function getAllProviders(): Map<string, AIDecisionsProvider>

Returns: Map of provider name → AIDecisionsProvider.

getProvider()

Retrieves the singleton AI decisions provider, or null if none is bonded.

Falls back to a single named provider when no singleton is bonded. When multiple named providers are bonded the fallback declines (returns null) because the choice is ambiguous — use getProviderByName(name) instead.

function getProvider(): AIDecisionsProvider | null

Returns: The bonded AI decisions provider, or null.

getProviderByName(name)

Retrieves a named AI decisions provider, or null if not bonded.

function getProviderByName(name: string): AIDecisionsProvider | null
  • name — The provider name.

Returns: The named AI decisions provider, or null.

hasProvider(name)

Checks whether an AI decisions provider is currently bonded.

function hasProvider(name?: string): boolean
  • name — Optional provider name. If omitted, checks the singleton.

Returns: true if the provider is bonded.

requireProvider()

Retrieves the bonded AI decisions provider, throwing if none is bonded.

function requireProvider(): AIDecisionsProvider

Returns: The bonded AI decisions provider.

setProvider(provider)

Registers an AI decisions provider in singleton mode.

function setProvider(provider: AIDecisionsProvider): void
  • provider — The default provider implementation for this process.

Available Providers

ProviderPackage
Jev@molecule/api-ai-decisions-jev
Laya@molecule/api-ai-decisions-laya
LLM@molecule/api-ai-decisions-llm

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-bond ^1.0.1
  • @molecule/api-i18n ^1.0.1

Runtime Dependencies

  • @molecule/api-bond

  • @molecule/api-i18n

  • Interface + accessor only. Use the core's setProvider(provider) / setProvider('name', provider), then requireProvider() or getProviderByName('name').

  • confidence is the probability of the reported answer (max of the distribution), computed the same way by every bond. Vendors define their own confidence differently (Jev: (n·pmax − 1)/(n − 1); Laya: normalized entropy), so a threshold copied from a vendor's docs does not transfer — pick thresholds on your own data.

  • Base models are not a finished classifier for your domain. Laya's own benchmarks put its base checkpoints near chance (0.36) on the typed-decisions set zero-shot; its fine-tuned checkpoint reaches 0.77. Measure accuracy on a labelled sample of YOUR inputs before letting an answer act unattended, and gate on minConfidence → a human or an LLM fallback for the rest.

  • Probabilities ship over-confident until calibrated on your traffic (Laya reports ECE 0.47 → 0.08 after temperature fitting). A 0.95 is not "right 95% of the time" until you have checked.

  • Keep option lists short. Accuracy drops past ~20 choice options on Laya (the option texts share a ~192-token window); Jev accepts up to 255, laya-serve refuses >100. score takes 2–10 levels on Jev.

  • Keep state short. Laya's English checkpoint reads 512 tokens (the multilingual one 1,024); longer state is truncated, not refused. Put the decisive text first.

  • Never use it to generate text — there is no text output. For a free-text label set that changes per request, use @molecule/api-ai-classification.

  • Server-side only. The provider key and the model host never belong in browser code.

E2E Tests

Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:

  • Each flow that makes a decision (routing, triage, moderation, a guardrail) runs it from the real UI, and the answer DRIVES what happens next (the item lands in the chosen queue, the badge shows, the action is blocked) — not just printed.
  • Both directions: a clearly-billing input routes to billing AND a clearly-technical one routes elsewhere. One label for every input is a broken integration.
  • A low-confidence answer takes the app's fallback path (human review, "unsure" state) instead of being acted on.
  • Provider errors (service down, bad key) show a visible, recoverable state — never a blank screen or an unhandled rejection.
  • The call runs server-side: no provider request or key in the browser's Network tab.