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
{
"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:
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:
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`:
"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.
"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](https://itsmohitrohilla.github.io/curva-docs/guides/conditional/). To branch on earlier answers, read [an LLM decision tree in one request](/blog/llm-decision-tree-one-request/), and to chain questions in stages, [chain LLM questions with depends_on](/blog/chain-llm-questions-depends-on/). For answers you already know, see [LLM rules with no model call](/blog/llm-rules-no-model-call/), and for the product overview, [what is Curva](/blog/what-is-curva/).