Custom decision models ​
A decision model is any object implementing DecisionModel<RawResponse>. Implement it to add a provider, wrap an internal service, or write a deterministic test double.
interface DecisionModel<RawResponse = unknown> {
readonly provider: string
readonly modelId: string
readonly capabilities: DecisionCapabilities
decision<const Questions extends DecisionQuestions>(
request: DecisionRequest<Questions>,
options?: ModelCallOptions,
): Promise<DecisionResult<Questions, RawResponse>>
}DecisionRequest contains state, questions, and optional providerOptions. ModelCallOptions carries abortSignal separately from the payload.
1. Declare capabilities ​
type DecisionCapabilities = {
questionSupport: Record<'choice' | 'multi-label' | 'score' | 'check', 'native' | 'composed' | 'unsupported'>
mixedQuestions: boolean
limits?: {
maxQuestionsPerRequest?: number
maxChoiceOptions?: number
maxMultiLabelOptions?: number
maxRubricLevels?: number
}
}nativemeans the provider has a matching question type.composedmeans the adapter builds it from other primitives, as Jev does formulti-label.unsupportedmakesdecide()throwDecisionCapabilityErrorbefore calling you.mixedQuestionsdeclares whether different question types can share one request.- Limits are checked before the call and throw
RangeError. Omit any limit you do not know.
2. A complete offline model ​
This model needs no provider SDK. It selects the first option whose label appears in the state and returns a fixed probability for checks.
import {
check, choice, decide, decideBatch,
type DecisionAnswers, type DecisionCapabilities, type DecisionModel, type DecisionQuestions,
type DecisionRequest, type DecisionResult,
} from '@anvia/core/decision'
const capabilities: DecisionCapabilities = {
questionSupport: { choice: 'native', 'multi-label': 'unsupported', score: 'unsupported', check: 'native' },
mixedQuestions: true,
limits: { maxChoiceOptions: 20 },
}
const keywordModel: DecisionModel<{ text: string }> = {
provider: 'demo',
modelId: 'keywords',
capabilities,
async decision<const Questions extends DecisionQuestions>(
request: DecisionRequest<Questions>,
options?: { abortSignal?: AbortSignal | undefined },
): Promise<DecisionResult<Questions, { text: string }>> {
options?.abortSignal?.throwIfAborted()
const text = JSON.stringify(request.state).toLowerCase()
const answers: Record<string, unknown> = {}
for (const [name, question] of Object.entries(request.questions)) {
if (question.type === 'choice') {
const labels = Object.keys(question.options)
const selected = labels.find((label) => text.includes(label)) ?? labels[0]!
answers[name] = {
type: 'choice',
choice: selected,
probabilities: Object.fromEntries(labels.map((label) => [label, label === selected ? 1 : 0])),
}
} else if (question.type === 'check') {
answers[name] = { type: 'check', probability: text.includes('refund') ? 0.9 : 0.1 }
}
}
return { answers: answers as DecisionAnswers<Questions>, rawResponse: { text } }
},
}
const questions = {
department: choice({
instructions: 'Which department should handle this?',
options: { billing: 'Payments and refunds', technical: 'Bugs', general: 'Other' },
}),
refund: check({ instructions: 'Is a refund requested?' }),
}
const { answers } = await decide({
model: keywordModel,
state: { message: 'Please refund my billing error.' },
questions,
})
console.log(answers.department.choice, answers.refund.probability)
const { items } = await decideBatch({
model: keywordModel,
inputs: ['billing issue', 'app crash'].map((message) => ({ state: { message }, questions })),
concurrency: 2,
})
console.log(items.map((item) => item.status))Because decide() validates the answers, an adapter bug such as a missing question, an option that was not requested, or probabilities that do not sum to one fails loudly in tests.
3. Adapter responsibilities ​
- Preserve the model, state, and question contract when applying
providerOptions. - Own composition: a provider without a native multi-label type can ask one yes/no question per label and assemble
labelsandprobabilitiesitself. - Pass
abortSignalto the provider and translate provider timeouts so retries can tell a timeout from a caller cancellation. - Report
usagein Anvia's normalized shape, or omit it. - Return the untouched provider result as
rawResponse.
The low-level model.decision() performs one invocation with no retry or validation of its own. Application code should call decide(). For a production reference, see how Jev maps each question type.