Conditional LLM questions: skip what does not apply

Add when to a question and it is asked only when the state matches. Skipped questions are never sent, stored or paid for, and calibration survives.

Conditional LLM questions are questions that are only asked when the data calls for them. In Curva you add when to a question, naming one or more top-level fields of the state and the values they must have, and the question is asked only when they match. A question that doesn't apply comes back as {"skipped": true}: it is never sent to the model, never stored and never paid for. Two details make when safer than an if in your own code: changing a condition doesn't reset the question's calibration, and one question file can serve several moments of a workflow. This guide shows how matching works and what skipping costs.

Conditional LLM questions: when matches a value, a list or an operator

json
{
  "state": {"department": "billing", "ticket": "I was charged twice"},
  "questions": {
    "refund": {"type": "noul", "instructions": "The customer asks for a refund",
               "when": {"department": "billing"}},
    "bug_area": {"type": "choice", "instructions": "Which part of the product is broken?",
                 "options": {"app": "", "api": "", "website": ""},
                 "when": {"department": ["technical", "security"]}}
  }
}

when is an object of {"<state field>": value}. The state must be a JSON object, otherwise the request gets 422, and the named fields are its top-level fields. Each field can be matched three ways:

ConditionMatches when the field
a value, such as "billing"equals the value
a list, such as ["technical", "security"]equals any of the values
an operator objectpasses the operator: exists, gt, gte, lt, lte, contains, starts_with

The operators are the same as in rules. {"contains": "refund"} matches a string containing the text, ignoring case. {"gte": 3} compares a number. {"exists": false} matches a field that is missing or null. An unknown operator gets 422. Any question type can have when.

From Python, .when(**fields) adds the condition:

python
from curva import Curva, Noul

d = Curva().decide(
    {"department": "billing", "amount": 250, "ticket": "I was charged twice"},
    {
        "refund": Noul("The customer asks for a refund").when(department="billing"),
        "review": Noul("A person should check this refund").when(amount={"gte": 100}),
    },
)

Several fields must all match

With several fields in one when, every one must match. Several operators in one object must all hold too, so {"gte": 1, "lt": 5} is a range. A missing field, or a field of the wrong type, never matches, except {"exists": false}.

That gives you an AND across fields and an OR within a list. For anything more complex, compute a field in code and put it in the state. A top-level field written by your code, such as a customer segment or a VIP flag, is easier to read in a condition than a chain of operators, and easier to test.

What skipped looks like, and what it costs: nothing

A skipped question keeps its place in answers:

json
"bug_area": {"skipped": true}

It costs nothing. It is not sent to the model, so it adds no tokens to the call. It is not stored for calibration. If every question in a request is skipped, no model is called at all and the decision costs $0.

Because a skipped question has no answer to correct, feedback for it gets 404. Code that sends feedback should check for skipped first.

Skipping is also how you keep a long question file cheap. Instead of one file per department, keep one file and gate the department-specific questions. Each ticket pays only for the questions that apply to it.

One question file for two moments: rag-check and exists

The built-in rag-check recipe shows the most useful when pattern. Its state is {question, passages, answer?}, and the same file is used twice: once before an answer exists, to decide whether the passages are enough, and once after, to check the answer against them.

json
  "answerable": {
    "type": "noul",
    "instructions": "The passages together contain enough information to answer the question fully and correctly.",
    "when": {"answer": {"exists": false}}
  },
  "grounded": {
    "type": "noul",
    "instructions": "Every factual claim in the answer is supported by the passages.",
    "when": {"answer": {"exists": true}}
  },

Before generation, the state has no answer, so answerable is asked and grounded is skipped. After generation, you send the same state with answer filled in, and the opposite happens. needs_retrieval and next_step have no condition, so they run both times. One file, one set of calibrators, two checkpoints, and no question is paid for at the wrong moment.

curva recipe show rag-check > questions.json prints it. From Python, the recipe loads straight from JSON, when included.

Changing a condition keeps the calibration

Calibration in Curva belongs to a fingerprint of the question's type, wording and options. Rewording a question starts its calibration over. But when is not part of the fingerprint: calibration is keyed by the question without its condition. So you can widen a condition from "billing" to ["billing", "payments"], or add a threshold, without losing the labels the question has collected.

There is one thing to watch. A calibrator learns from the inputs it was fitted on. If a new condition sends a very different kind of input to the question, the old labels describe it less well. Keep sending feedback after a change, and check the calibration report.

when runs before rules

when and rules combine, and when is checked first. A question whose when fails is {"skipped": true}, even if one of its rules would match. Think of when as "does this question apply?" and rules as "do we already know the answer?". Both read top-level state fields, both avoid model calls, and both are written to the audit log as returned.

when can also read an earlier answer in the same request: a field written @<key> reads the answer to question <key> instead of the state. That turns a flat question file into a decision tree, covered in the next post in this series.

Since a fix after 0.1.0, the batch commands (curva map, curva shadow, curva bench and curva tune) run when exactly like the API, so a conditional question file behaves the same on a backfill as it does live.

Next steps

The docs cover this in conditional questions. To branch on earlier answers, read an LLM decision tree in one request, and to chain questions in stages, chain LLM questions with depends_on. For answers you already know, see LLM rules with no model call, and for the product overview, what is Curva.