Plain vs full LLM answers in Python: the hidden 0.5
d.refund is True whenever P(yes) is at least 0.5. When plain answers are fine, when to read the full answer, and the id and model name clash.
In Python, a plain Curva answer to a yes/no question uses a fixed probability threshold of 0.5: `d.refund` is `True` whenever P(yes) is at least 0.5, so an answer the model barely believes and one it is sure of both come back as the same `True`. That is fine for a script. For anything that acts on the answer, read the full answer instead, `d["refund"].noul`, and compare P(yes) with a threshold you chose, or use `min_confidence` and `abstain` on a Choice. This post covers what the plain answers give you, what they hide, the full answer's fields, and one naming trap.
Plain answers: d.team, d.refund, d.total and d.to_dict()
The three-line quickstart returns plain answers:
import curva
d = curva.decide("I was charged twice, please refund me",
{"team": ["billing", "technical"], "refund": "Asks for a refund?", "total": float})
print(d.team, d.refund, d.total) # billing True NoneEach question key becomes an attribute:
Plain answers are made for scripts, notebooks and quick checks. They read like ordinary Python values, they drop straight into a DataFrame row or a JSON log, and they hide everything you don't need while exploring.
What they hide is the probability. For a Choice, `d.team` doesn't say whether billing won with near certainty or narrowly against two close rivals. For extraction, `d.total` doesn't say how confident the model was in the number. For a Noul, the hiding goes further.
The hidden 0.5: what d.refund means at a coin-flip P(yes)
A Noul's real answer is one number, P(yes). The plain answer turns it into a boolean at a fixed cut-off of 0.5. So:
The first and third cases are the model saying "I can't tell", and the plain answer turns them into opposite, confident-looking booleans. If your code starts a refund flow on `if d.refund:`, it acts on coin flips.
The fix is not a different default. It is reading the number when the answer drives an action.
Full answers: choice, confidence, probabilities, noul, value, calibrated
`d["refund"]` is the full answer, an `Answer` object. Only the fields for the question's type are set; the rest are `None`.
Some features add fields of their own: `agreement` for a council, `answered_by` for a cascade, `explain` with `explain=True`, `stage` with dependencies, `rule` when a rule answered, and `set` and `guaranteed` when the question has `coverage`. The decision also groups answers by type in its `choices`, `scores` and `nouls` attributes.
`calibrated` is worth checking in production code. Until it is `True`, the probabilities are the model's own, debiased across two option orders but not yet checked against your labels.
A yes/no probability threshold in Python: noul, min_confidence and abstain
For a Noul, read the probability and pick the cut-off that matches the cost of a mistake:
p = d["refund"].noul
if p >= 0.9:
start_refund_flow(ticket)
elif p > 0.1:
send_to_human(ticket, decision_id=d.id)For a Choice, let Curva apply the threshold. Set `min_confidence` on the question and read `abstain`:
from curva import Choice
q = {"team": Choice("Which team?", ["billing", "technical", "sales"], min_confidence=0.8)}
d = client.decide(ticket, q, project="support")
answer = d["team"]
if answer.abstain or answer.choice == "none_of_these":
send_to_human(ticket, decision_id=d.id)
else:
route(ticket, answer.choice)`abstain` is only present when the question set `min_confidence`. `none_of_these` is the escape option every Choice gets by default, for an input that fits no option. Both belong with a person. Once the question is calibrated from feedback, both thresholds mean what they say: a 0.9 is right about 90% of the time on your data.
A question called id, model or project: use d["id"]
Plain answers are attributes on the decision, and the decision has attributes of its own: `id`, `model`, `project`, `mode`, `config`, `latency_ms`, `cost_usd`, `cached` and more. The decision's own fields come first. So if you name a question `model`, the attribute of that name is the model that answered, not your question's answer.
Use item access for any question whose key could clash:
d = curva.decide(listing, {"model": ["sedan", "suv", "truck"]})
d.model # the LLM that answered
d["model"].choice # your question's answerBetter still, avoid the clash in the first place: give questions keys that describe the decision, with a prefix where needed, so no key can match a field of the decision itself. Keys are also what feedback, the calibration report and the drift report use, so a clear key pays off more than once.
Score answers: score (expected level) vs level (most likely name)
A Score has two readings, and they answer different questions:
Use `score` when you want the doubt kept in the number, such as sorting a queue by urgency or setting your own cut-off between levels. Use `level` when you need one label to display. `probabilities` holds the full split for when you need both.
Next steps
The docs list every field in the [Python SDK reference](https://itsmohitrohilla.github.io/curva-docs/reference/python-sdk/) and the plain answers in [getting started](https://itsmohitrohilla.github.io/curva-docs/getting-started/). For the full workflow, start with the [Python LLM classification tutorial](/blog/python-llm-classification/), and for the shorthand itself, [Python LLM shorthand questions](/blog/python-llm-shorthand-questions/). To make thresholds meaningful, read [send feedback to an LLM classifier in Python](/blog/send-feedback-llm-classifier-python/) and [LLM classification confidence scores you can act on](/blog/llm-classification-confidence-scores/).