DEV Community

Cover image for ML vs LLM for Classification: Jev, Decision Models, and the Triage Bot I Overbuilt
Qasim Parray
Qasim Parray

Posted on Originally published at abrarqasim.com

ML vs LLM for Classification: Jev, Decision Models, and the Triage Bot I Overbuilt

Okay, this is going to sound like I'm late to a party, because I am. TypeSafe AI released a model called Jev on 15 September, and I spent the first four days ignoring it because the pitch ("a model that can't hallucinate") read like every other launch page. Then I looked at what it actually returns, and it clicked with a project I'd been quietly annoyed about since spring.

That project was a support ticket triage bot for a client with a small team and a big inbox. The bot read each incoming ticket and decided three things: is this urgent, which of five queues does it belong to, and how angry is the customer on a scale of one to five. I built it the way everyone builds these now. A chat model, a JSON schema, a prompt that begged for valid output. It worked. It also cost more in retries and parsing code than it did in inference, and it took four seconds per ticket to make a decision a human makes in one.

Jev is the first thing I've seen that's shaped like the problem I was solving, rather than shaped like a chatbot I had to bend into it.

The triage bot that cost more to parse than to run

Here's what the chat-model version looked like once it was "done". The prompt asked for a JSON object with three fields. About one call in forty came back with something the parser rejected: a trailing comment, a queue name that wasn't in the list, a priority of "high-ish". Each of those triggered a retry with a sterner prompt. The retry logic grew a fallback. The fallback grew logging. By the end, the file that called the model was 40 lines and the file that cleaned up after it was 180.

Cost was the other half. Chat models bill output tokens at several times the input rate, and a JSON object with three fields is mostly output. I wrote about this problem in more detail when I looked at what happens to LLM costs after the free tier ends, and my conclusion then was to cache aggressively and pick smaller models. That helps. It doesn't change the shape of the thing, which is that I was paying a text generator to generate text I then threw away, keeping only a category.

What a decision model actually returns

TypeSafe's launch post calls Jev a "System One model". Simon Willison, in his write-up, prefers "decision model", and so do I. The idea: text goes in, but no text comes out. You get floating point numbers.

You send a "state" object, which can be a string, a list of strings, or a set of name-value pairs describing a record. Alongside it you send one or more questions, and there are three kinds. Yes/no questions (TypeSafe calls them "Noul", short for Bernoulli) return a number between 0 and 1 for how confident the model is that the statement is true. Choice questions return a probability distribution across options you supplied. Score questions take a set of numeric levels with descriptions and return a value along that range. Questions are evaluated in parallel, so asking twenty costs about the same wall-clock time as asking one.

Two numbers from the launch post that made me sit up. Input is priced at $0.042 per million tokens and output is free, because there are no output tokens. For comparison, OpenAI's GPT-5 Nano lists $0.05 per million input tokens, and that's the cheapest chat model I'd have reached for. TypeSafe also claims end-to-end response times of 70 to 500 milliseconds. I haven't measured that myself yet, so treat it as a vendor number, but even if it's off by a factor of three it beats the four seconds I was living with.

Before and after, in code

The "before" is the pattern I'd bet most of you have in a repo somewhere:

# triage_llm.py (before)
import json
from openai import OpenAI

client = OpenAI()

PROMPT = """Classify this support ticket. Reply with JSON only:
{"urgent": true|false, "queue": "billing|bugs|onboarding|account|other", "anger": 1-5}

Ticket:
"""

def triage(ticket: str, attempt: int = 0) -> dict:
    r = client.chat.completions.create(
        model="gpt-5-nano",
        messages=[{"role": "user", "content": PROMPT + ticket}],
        response_format={"type": "json_object"},
    )
    try:
        out = json.loads(r.choices[0].message.content)
        assert out["queue"] in {"billing", "bugs", "onboarding", "account", "other"}
        assert 1 <= int(out["anger"]) <= 5
        return out
    except (json.JSONDecodeError, KeyError, AssertionError, ValueError):
        if attempt < 2:
            return triage(ticket, attempt + 1)
        return {"urgent": False, "queue": "other", "anger": 3}  # shrug
Enter fullscreen mode Exit fullscreen mode

Look at that last line. When the model failed three times, I silently filed the ticket under "other" with medium anger. That default handled maybe 0.5% of tickets and I never told the client it existed.

The "after" is illustrative. I'm writing it from the shape TypeSafe describes, and their exact field names may differ from mine, so don't copy it verbatim:

# triage_decision.py (after, shape per TypeSafe's docs)
def triage(ticket: str) -> dict:
    result = jev.decide(
        state={"ticket": ticket},
        questions=[
            {"id": "urgent", "type": "noul",
             "statement": "This ticket needs a response within one hour."},
            {"id": "queue", "type": "choice",
             "options": ["billing", "bugs", "onboarding", "account", "other"]},
            {"id": "anger", "type": "score",
             "levels": {1: "calm", 3: "frustrated", 5: "threatening to leave"}},
        ],
    )
    return {
        "urgent": result["urgent"].p > 0.8,
        "queue": result["queue"].argmax,
        "queue_confidence": result["queue"].p,
        "anger": round(result["anger"].value),
        "needs_human": result["queue"].p < 0.6,
    }
Enter fullscreen mode Exit fullscreen mode

No JSON parsing. No retry. No silent default, because there's no failure mode where the model returns a queue that isn't in the list. And I get a needs_human flag for free, from the confidence score, which is the thing I'd wanted the whole time and had been faking with a second prompt.

This is the ML vs LLM argument, settled sideways

For about three years the honest advice on classification tasks went like this. If you have a few thousand labelled examples, train a small classifier. It's fast and cheap, and it gives you probabilities. If you don't have labels, use an LLM zero-shot and accept that it's slow and expensive and that it returns prose you have to parse.

A decision model is the second option with the first option's output shape. Zero-shot, reads the ticket the way a large model reads it, but hands back what a classifier hands back: a number per class. TypeSafe's table in the launch post makes this comparison directly, and I think they're right that it's a different category and not merely a cheaper chat model.

What you give up is string generation. Jev can't summarise the ticket, can't draft a reply, can't extract a free-text field. For the triage step that's fine. For the step after it, I still need a chat model. The mistake would be assuming one replaces the other.

The floating point number can't tell you why

This is where I slow down, and it's where Willison slows down too. A chat model is a black box, but you can at least ask it to explain a decision and get something, even if the explanation is post-hoc. A decision model gives you 0.83 and nothing else. If it marks a ticket as billing, which words tipped it? You don't know, and there's no way to ask.

Willison ran a small experiment I keep thinking about: he asked Jev a yes/no "good city?" question for every city in the San Francisco Bay Area. It rated Cupertino top and East Palo Alto bottom. That's a bias with a zip code, and it came out of a model that can only answer in numbers. He explicitly hopes nobody uses this to rank job applicants. I'd extend that to loan decisions, tenant screening, insurance pricing, and anything else where "the model said 0.31" would be an unacceptable answer to a regulator.

TypeSafe's counter is calibration. Their training method (they call it RLCD) is supposed to produce confidence scores where higher confidence means higher accuracy. I want that to be true. I also want to see it on my own data before I believe it, and the good news is that it's cheap enough to check. At $0.042 per million input tokens, running a few thousand labelled tickets through it costs cents.

Where I'd use it, and where I wouldn't

Use it: routing, spam, priority, "does this record match this rule" checks, and reranking. Willison's reranking idea is the one I want to try first: pull 100 candidates with BM25, have the decision model score each against the query, and take the top ten. That's a job I currently do with a cross-encoder that I have to host myself.

Don't use it: anything where a wrong answer has a person on the other end and no appeal. I wrote last month about a refund bot that cost me a client, and the lesson there wasn't about the model. It was about wiring a probabilistic decision straight into an action with money attached. A better probability doesn't fix that wiring. A confidence threshold and a human queue do.

One more thing worth watching. There's already an open-weight recreation called Kev built on Qwen 3.5, in 0.8B, 4B and 9B sizes, and a benchmark for "Jev-class" models appeared within a week. If that line of work holds up, the self-hosted version of this is a small model you run on the same VPS as the app. That's the version I'd actually deploy for client work, because "your triage runs on a third-party API in early access" is a hard sentence to say in a kickoff call.

What I'm doing this week, and what you could

I'm on TypeSafe's early access list and haven't got a key yet, so my numbers above are theirs and Willison's, not mine. What I can do now, and what you can do too, is prepare the eval.

Take the last 200 tickets (or leads, or comments, or whatever you're classifying) that a human already labelled. Write each label as a yes/no statement. Store them in a CSV with the human answer. When you get access to Jev, or when you spin up Kev locally, run the 200 through, bucket the results by confidence (0.5 to 0.7, 0.7 to 0.9, above 0.9), and check accuracy per bucket. If accuracy climbs with confidence, the calibration claim holds on your data and you can set a threshold. If it doesn't, you've spent a few cents and an hour finding that out before it touched a customer.


Originally published at abrarqasim.com. I write there about React, PHP, Rust, Go and the AI tooling around them.

Top comments (0)