Welcome back. In the previous lesson, you decomposed a compound support decision into atomic judgments and kept policy composition in deterministic code. Now we make that design operational: one Jev call will ask a mixed batch of Choice, Score, and Noul questions over the same state, then your application will turn the heterogeneous response into a stable domain object.
The key implementation idea is simple: batch questions by shared evidence, but map answers by stable question ID and type. A response is not a generic “AI result”; it is a typed bundle of distinct signals that your code can inspect, test, log, and later use in routing or policy.
One state, several bounded judgments
A single support ticket can support several independent classifications:
- Choice: Which support team owns the ticket?
- Score: How severe or frustrating is the issue?
- Noul: Does the customer explicitly request a refund?
These are good candidates for one request because they all inspect the same evidence: the incoming ticket. Batching avoids repeatedly sending the same state and returns a single, coherent set of judgments for that snapshot.
The important boundary is that questions should be parallel, not dependent. Do not batch a second question whose wording requires the answer to the first question. For example:
- Good: “Which team owns this request?” and “Does the message explicitly request a refund?”
- Not good: “Given the team selected above, should we auto-refund?”
The second example needs deterministic policy logic after you receive the first answer. Jev classifies the state; your application controls the workflow.

The bug-triage example illustrates an important production principle: a batch can contain different question types and can cover several records, but every question must retain a clear identifier such as r3_owner or r3_severity. Never rely on the position of an answer in an object or array.
The request is a typed decision contract
Here is a small support-triage contract expressed in TypeScript-shaped JSON. It follows the API’s structure: a state, a model name, and a dictionary of named questions.
const state = {
ticket: {
id: "T-1042",
message:
"My card was charged twice for order A-104. Please refund the duplicate charge today.",
channel: "email",
},
};
const questions = {
owner: {
type: "choice",
instructions:
"Which team should own the ticket's primary customer request?",
criteria: {
billing: "Duplicate charges, invoices, subscriptions, payments, or refunds.",
technical: "Product bugs, login failures, integration failures, or broken features.",
account: "Account access, profile changes, or account administration.",
unclear: "The primary request cannot be determined from the ticket.",
},
},
severity: {
type: "score",
instructions:
"How severe is the issue described in `ticket.message` for the customer?",
criteria: [
"Cosmetic: no meaningful loss of functionality or money.",
"Workaround exists: functionality or billing is affected, but the customer can reasonably continue.",
"Blocking: the customer cannot reasonably continue, is losing money, or needs immediate intervention.",
],
},
refund_requested: {
type: "noul",
instructions:
"Does `ticket.message` explicitly request a refund, reimbursement, or account credit?",
},
};
const request = {
model: "jev-latest",
state,
questions,
};
This is deliberately not a “decide what to do” request. It asks for three narrow pieces of evidence:
| Question ID | Type | Meaning in application code |
|---|---|---|
owner | Choice | Candidate support queue |
severity | Score | A bounded severity signal |
refund_requested | Noul | Probability that the explicit-refund proposition is true |
The owner question includes unclear on purpose. Without it, the model must force every ambiguous ticket into a real team, which gives downstream code an apparently definite but potentially misleading route.
The Score criteria are ordered and anchored in observable consequences. The result need not be an integer: Jev can return an in-between value, which is useful when the evidence lies between “workaround exists” and “blocking.”
Read the TypeSafe AI Quick start’s API example to see the exact wire shape of a request containing Choice, Score, and Noul questions together, followed by its mixed response.
In “Call it: the API,” inspect the state beginning with the support message, then trace the three entries in the questions object: department, frustration, and is_urgent. Notice that each has a stable key and a different result contract. Then continue to “Response body.” Inspect the Choice result, the Score result, and the Noul result below it. Focus on which fields are present for each type rather than treating all answers as a single generic shape.
A batch response is heterogeneous
The API response groups results under answers, using the same keys you supplied in questions. Conceptually, our ticket might return something like this:
const response = {
model: "jev-latest",
answers: {
owner: {
type: "choice",
choice: "billing",
probabilities: {
billing: 0.93,
technical: 0.04,
account: 0.01,
unclear: 0.02,
},
confidence: 0.88,
},
severity: {
type: "score",
score: 1.74,
legend: {
0: "Cosmetic: no meaningful loss of functionality or money.",
1: "Workaround exists: functionality or billing is affected, but the customer can reasonably continue.",
2: "Blocking: the customer cannot reasonably continue, is losing money, or needs immediate intervention.",
},
confidence: 0.81,
},
refund_requested: {
type: "noul",
noul: 0.97,
},
},
usage: {
input_tokens: 312,
output_tokens: 48,
},
};
Each answer type has a different semantic payload:
| Type | Primary application value | Additional useful information |
|---|---|---|
| Choice | choice, such as "billing" | Probability for every allowed label; confidence |
| Score | score, such as | The level legend; confidence |
| Noul | noul, the probability that the proposition is true | Treat it as , not as a boolean |
For the refund_requested question, the proposition is:
“The message explicitly requests a refund, reimbursement, or account credit.”
So:
A Noul value of is not the string "yes" and should not be silently coerced to a boolean at the API boundary. Preserve the probability so that later policy can distinguish strong evidence from a borderline case.
Likewise, Choice probabilities are more informative than the chosen label alone. The application may route billing automatically when it is strongly supported, while treating a close split between billing and technical as a review case.
One detail worth keeping explicit: the documented response shown above includes confidence on Choice and Score answers, while the Noul example supplies the probability itself. Do not write one generic mapper that assumes every answer has identical fields. Narrow on type first.
Map raw answers into application data
An API response is transport data. Your application should map it immediately into a domain-oriented object whose names make sense to the rest of the codebase.
For a front-end or service boundary, this mapping layer is also a useful runtime guard. Even when TypeScript knows the expected SDK types, external responses deserve validation before they influence routing, user-facing state, or automation.
type SupportTeam = "billing" | "technical" | "account" | "unclear";
type ChoiceAnswer = {
type: "choice";
choice: string;
probabilities: Record<string, number>;
confidence: number;
};
type ScoreAnswer = {
type: "score";
score: number;
confidence: number;
};
type NoulAnswer = {
type: "noul";
noul: number;
};
type RawAnswers = Record<string, ChoiceAnswer | ScoreAnswer | NoulAnswer>;
type TriageSignals = {
assignedTeam: SupportTeam;
teamProbabilities: Record<SupportTeam, number>;
teamConfidence: number;
severity: {
rawScore: number;
normalizedScore: number;
confidence: number;
};
refund: {
requestedProbability: number;
};
};
The domain object does not expose raw API labels such as choice or noul outside the mapping boundary. Instead, it gives each signal a product-level meaning: assignedTeam, severity, and refund.requestedProbability.
Here is a mapper for the three-question batch:
const SUPPORT_TEAMS = [
"billing",
"technical",
"account",
"unclear",
] as const;
function isSupportTeam(value: string): value is SupportTeam {
return SUPPORT_TEAMS.includes(value as SupportTeam);
}
function assertProbability(value: number, field: string): number {
if (!Number.isFinite(value) || value < 0 || value > 1) {
throw new Error(`${field} must be a finite probability from 0 to 1`);
}
return value;
}
function mapTeamProbabilities(
probabilities: Record<string, number>,
): Record<SupportTeam, number> {
const mapped = {} as Record<SupportTeam, number>;
for (const team of SUPPORT_TEAMS) {
mapped[team] = assertProbability(
probabilities[team],
`owner.probabilities.${team}`,
);
}
return mapped;
}
function getChoiceAnswer(
answers: RawAnswers,
key: string,
): ChoiceAnswer {
const answer = answers[key];
if (!answer || answer.type !== "choice") {
throw new Error(`Expected Choice answer at "${key}"`);
}
return answer;
}
function getScoreAnswer(
answers: RawAnswers,
key: string,
): ScoreAnswer {
const answer = answers[key];
if (!answer || answer.type !== "score") {
throw new Error(`Expected Score answer at "${key}"`);
}
return answer;
}
function getNoulAnswer(
answers: RawAnswers,
key: string,
): NoulAnswer {
const answer = answers[key];
if (!answer || answer.type !== "noul") {
throw new Error(`Expected Noul answer at "${key}"`);
}
return answer;
}
Now compose those guards into one application mapper:
function mapTriageSignals(answers: RawAnswers): TriageSignals {
const owner = getChoiceAnswer(answers, "owner");
const severity = getScoreAnswer(answers, "severity");
const refundRequested = getNoulAnswer(answers, "refund_requested");
if (!isSupportTeam(owner.choice)) {
throw new Error(`Unexpected owner label: "${owner.choice}"`);
}
const teamProbabilities = mapTeamProbabilities(owner.probabilities);
const severityLevelCount = 3;
const maximumSeverityScore = severityLevelCount - 1;
if (
!Number.isFinite(severity.score) ||
severity.score < 0 ||
severity.score > maximumSeverityScore
) {
throw new Error("severity.score is outside the configured score range");
}
return {
assignedTeam: owner.choice,
teamProbabilities,
teamConfidence: assertProbability(owner.confidence, "owner.confidence"),
severity: {
rawScore: severity.score,
normalizedScore: severity.score / maximumSeverityScore,
confidence: assertProbability(
severity.confidence,
"severity.confidence",
),
},
refund: {
requestedProbability: assertProbability(
refundRequested.noul,
"refund_requested.noul",
),
},
};
}
The score is retained in two forms:
rawScorekeeps the scale tied to the question’s criteria, from to .normalizedScoreputs it on a -to- scale:
Normalization is useful if you later compare several separately defined scores. But do not normalize merely because you can. The raw score and its legend are often easier for product and support teams to interpret.
Keep question IDs stable and map by meaning
A common implementation mistake is to map by object order:
// Avoid this pattern.
const values = Object.values(response.answers);
const team = values[0];
const severity = values[1];
That code is fragile. A future edit that inserts a security_risk question can silently shift every index and produce a valid-looking but wrong application object.
Use descriptive IDs as a contract instead:
const questions = {
owner: /* Choice */,
severity: /* Score */,
refund_requested: /* Noul */,
};
Then map those IDs explicitly:
const signals = mapTriageSignals(response.answers);
Stable IDs help in several ways:
- They connect a returned result to its exact question definition.
- They make logs and inspector screens readable.
- They support regression fixtures later in the course.
- They make it safe to add or remove unrelated questions.
- They let backend and frontend teams agree on one typed boundary.
For multiple reports in one batch, carry the record identity in the key:
const reportQuestions = {
"report_101_owner": /* Choice */,
"report_101_severity": /* Score */,
"report_101_reproducible": /* Noul */,
"report_102_owner": /* Choice */,
"report_102_severity": /* Score */,
"report_102_reproducible": /* Noul */,
};
In a larger application, generate these keys from a report ID, but preserve the same three-question template. This is safer than generating ad hoc question wording for every incoming item.
Batching is not only about fewer requests
A single request can be convenient and efficient, but the primary design goal is still a clean decision contract.
Batch when the questions:
- Inspect the same state or a tightly related state snapshot.
- Are independent atomic judgments.
- Are likely to be needed together by the same workflow stage.
- Can be interpreted separately if one signal is uncertain.
Avoid adding a question simply because it is inexpensive. For example, if customer_sentiment has no defined consumer in your system, it creates logging, evaluation, and future maintenance work without improving the current workflow.
The following video segment gives a compact view of this pattern: multiple classifications run against one support message, while each answer retains its own operational purpose.
Jev - The Ultimate Classification Model?
Watch Sam Witteveen’s “Jev - The Ultimate Classification Model?” for a concrete mixed-question support example. It shows why one input can support several distinct decisions without turning them into one vague prompt.
Watch the support batch. Focus on the separate routing, refund-request, and urgency judgments. Notice how changing the message wording can alter one signal, such as urgency, without changing the fundamental structure of the batch.
A practical integration checklist
Before sending a mixed batch to Jev, verify the following:
- State: Does it contain only the evidence required by the questions?
- Question IDs: Are they stable, descriptive, and unique?
- Choice labels: Are they an allowlist with an
unclearoption when ambiguity is possible? - Score criteria: Are they ordered, anchored, and meaningful at every level?
- Noul proposition: Could someone clearly state what “yes” means?
- Mapping: Does code narrow each answer by
typebefore reading type-specific fields? - Probabilities: Are probabilities preserved rather than converted prematurely to booleans?
- Score scale: Does the mapper know the configured number of criteria levels?
- Observability: Can you log the question ID, selected value or score, probability distribution where relevant, confidence where available, and request usage?
For this course’s support-automation capstone, the mapper you have built is the boundary between AI classification and ordinary application behavior. The next layers should consume TriageSignals, not the raw response.
Key takeaways
A Jev batch is a dictionary of independent typed questions evaluated against one state. Choice returns an allowed label plus its probability distribution, Score returns a value on your defined scale, and Noul returns the probability of one precise proposition.
Treat the response as heterogeneous transport data. Map it by stable question ID, narrow by type, validate labels and numeric ranges, and expose domain-oriented application data such as assignedTeam, severity, and refund.requestedProbability.
Next, you will begin the reusable application-pattern module by using typed classification signals to implement an intent router in TypeScript, with explicit paths for deterministic code, an LLM, and human review.
Can't find a good explanation? Sign up and we'll make it for you
Sign up