BOOTH
A lightweight checkpoint library for LLM outputs.
Should my application trust this LLM output?
Every app built on an LLM call eventually has to answer that question, usually the hard way, after a confidently wrong answer has already reached a user. BOOTH is the checkpoint that answers it first: it sits between your application and an LLM call and gives you a structured decision, pass the output through, ask the model to reconsider, flag it as ambiguous, or check it against your own custom rule or your own retrieved evidence.
The name comes from a ticket booth, toll booth, or parking/payment booth: it doesn't need to know everything about what's happening beyond, it just checks whether the required condition has been met before letting something through.
pip install boothpy
See it in 10 seconds
import booth
result = booth.check(call_llm, "What is the capital of France?")
if result.ok:
print(result.answer) # "Paris" — confident, unambiguous
else:
print(f"BOOTH returned {result.status}") # AMBIGUOUS / UNCERTAIN — don't ship it blind
Your App BOOTH Your App
┌──────────┐ prompt ┌──────────────────────┐ BoothResult ┌───────────┐
│ │ ───────────▶ │ LLM │ │ │
│ ask() │ │ │ │ │ branch │
│ │ │ ▼ │ │ on: │
│ │ │ Checkpoint │ ───────────▶ │ .ok │
│ │ │ ambiguity detection│ │ .status │
│ │ │ confidence + retry │ │ .method │
│ │ │ custom validator │ │ .answer │
│ │ │ evidence agreement │ │ │
└──────────┘ └──────────────────────┘ └───────────┘
That's the entire mental model: wrap the LLM call you already have, and get back a BoothResult with a real .status — instead of a raw string your code has no reason to trust.
Current Status
v0.4.7
BOOTH currently provides:
- ambiguity detection, so a model doesn't confidently guess at a multi-interpretation question
- self-reported confidence checking, with genuine reconsideration retries — not blind resampling
- an optional caller-supplied
validatorfor custom pass/fail rules, with its own dedicated retry prompt - separate, correctly-targeted retry handling for responses that failed to parse at all
- synchronous and asynchronous APIs (
check()/acheck()), including callable-object support for both — a class instance with a sync__call__forcheck(), or anasync def __call__foracheck() check_with_evidence()for checking an answer against evidence your own RAG/retrieval pipeline already pulled- structured results, including
result.method(which mechanism actually produced this outcome) andresult.parsed(the model's raw, uncoerced JSON) result.to_dict()for a fully JSON-serializable snapshot of a result — including computed properties likeokandmethod, not just the raw dataclass fields- full attempt history for every retry
- five explicit statuses:
VERIFIED,REPAIRED,AMBIGUOUS,UNCERTAIN,BLOCKED
BOOTH is provider-agnostic and zero-dependency — it works with any LLM client you already have, and doesn't require a particular retrieval system or framework.
What BOOTH Does
A normal LLM call might look like:
answer = call_llm(prompt)
BOOTH adds a checkpoint around the model call:
import booth
result = booth.check(
call_llm,
"What is the capital of France?"
)
if result.ok:
print(result.answer)
else:
print(f"BOOTH returned {result.status}")
Under the hood, BOOTH asks the model for structured self-report alongside its answer:
{
"ambiguous": false,
"interpretations": [],
"chosen_interpretation": null,
"answer": "Paris",
"confidence": 0.95
}
and applies your configured acceptance rules to it, in order:
- If the question is ambiguous, return
AMBIGUOUSimmediately. - If a
validatorwas supplied and the answer fails it, ask the model to reconsider — showing it the specific failure. - If confidence is below threshold, ask the model to reconsider its previous answer.
A reconsidered answer that meets the bar comes back REPAIRED. If nothing acceptable is ever produced, you get UNCERTAIN — with result.method telling you exactly why (parse failure, failed validation, or persistent low confidence).
Every field BOOTH extracts from the model's JSON is validated against its expected type before you ever see it — a model returning "false" (a string) where it should return false (a boolean), or true where it should return a number, gets rejected and retried rather than silently misread. Full detail on this and the check_with_evidence() equivalent is in the Tutorial and Changelog.
Features
Ambiguity detection
BOOTH asks the model to identify whether a question has multiple valid interpretations before accepting an answer.
What is the capital of Georgia?
could mean:
Georgia (the country) -> Tbilisi
Georgia (the US state) -> Atlanta
BOOTH returns AMBIGUOUS with the detected readings in result.interpretations, and this takes priority over everything else BOOTH checks — a confident, validator-passing answer is still returned as AMBIGUOUS if the model spots multiple valid readings.
Confidence checking
BOOTH uses the model's self-reported confidence as an acceptance signal, with a configurable threshold (default 0.7):
result = booth.check(call_llm, prompt, threshold=0.8)
Reconsideration retries
When an answer is unambiguous but under-confident, BOOTH shows the model its own previous answer and asks it to genuinely reconsider — not resample blindly:
Previous answer: "Lyon"
Previous confidence: 0.3
Reconsider carefully. If that answer is correct, restate it.
If it is wrong, give the corrected answer.
A reconsidered answer that clears the threshold comes back REPAIRED. Control the retry budget with max_retries (default 1; max_retries=0 disables retries entirely).
Parse-failure handling
A response that fails to parse gets a distinct retry prompt — one that tells the model its previous output didn't meet the required format — instead of silently repeating the original prompt and hoping. result.all_parse_failed tells you if every single attempt failed to produce a valid response.
Custom validation with validator
Plug your own pass/fail rule directly into BOOTH's retry loop:
def is_valid_order_id(answer: str) -> bool:
return answer.strip().upper().startswith("ORD-")
result = booth.check(
call_llm,
"What is the order ID for this request?",
validator=is_valid_order_id,
)
validator can return True/False, (bool, str) with a specific failure reason shown to the model verbatim on retry, or the (bool, None) / list-shaped equivalents. numpy.bool_ and similar duck-typed booleans are recognized natively — BOOTH stays zero-dependency, and this works across numpy versions. validator must always be synchronous — an accidentally-async def validator is rejected cleanly with a specific message, not silently mishandled.
Validation runs after the ambiguity check and before the confidence check — a highly confident answer still gets rejected and retried if it fails your rule.
result.method
Tells you which mechanism actually produced a result:
result.method
# "ambiguity" — status is AMBIGUOUS
# "evidence" — result came from check_with_evidence()
# "parse_failure" — UNCERTAIN, every attempt failed to parse
# "validation" — UNCERTAIN, parsed fine and was confident, but failed your validator
# "confidence" — the ordinary case
This is what makes an UNCERTAIN result actionable instead of a shrug:
if result.status == booth.UNCERTAIN:
if result.method == "parse_failure":
print("Check call_fn / prompt formatting.")
elif result.method == "validation":
print("Model was confident, but never satisfied the validator.")
else:
print("Model tried, but confidence never reached threshold.")
result.parsed
The model's raw JSON response, exactly as returned — before any of BOOTH's coercion (str(answer), float(confidence), and so on). Useful for logging, debugging, or reading extra fields you asked the model to include that BOOTH itself doesn't use:
result = booth.check(call_llm, prompt)
result.confidence # 0.95 — coerced float
result.parsed["confidence"] # "0.95" — raw value, untouched
result.parsed["source_document"] # any extra field you asked for, unread by BOOTH
Synchronous and asynchronous APIs
booth.check() # Callable[[str], str]
await booth.acheck() # Callable[[str], Awaitable[str]]
Both share the exact same decision logic — ambiguity, validator, confidence, parsed — the only difference is how your LLM function gets called:
import asyncio
import booth
async def call_llm(prompt: str) -> str:
response = await async_client(...)
return response
async def main():
result = await booth.acheck(call_llm, "What is the capital of France?")
if result.ok:
print(result.answer)
asyncio.run(main())
call_fn doesn't have to be a plain function — check() accepts any synchronous callable, including a class instance with a __call__ method, and acheck() accepts a plain async def function, a functools.partial wrapping one, or an object whose __call__ is itself async def. on_attempt follows the exact same detection rules as call_fn on both entry points (fixed in 0.4.7 — see the Changelog). See the Tutorial for the one case that's intentionally not supported.
Evidence agreement checking
For applications that already have evidence from their own RAG, search, or tool pipeline, check_with_evidence() checks the model's answer against the evidence you already retrieved — instead of just trusting that the model read it correctly:
evidence = [
"Either party may terminate this Agreement upon ninety (90) "
"days written notice to the other party."
]
result = booth.check_with_evidence(
answer="You need to give 45 days' notice to cancel.",
evidence=evidence,
compare_fn=compare_answer_to_evidence,
)
result.status # BLOCKED — the answer contradicts the evidence
result.evidence_agreement # the score/bool your compare_fn returned
Without this check, "45 days" is just a string your app has no particular reason to doubt — it reads like a normal, confident answer, not a hallucination. check_with_evidence() is what turns "the model said 45 days" into "the model said 45 days, and that disagrees with the 90-day clause our own retrieval actually pulled" — a materially different, and far more actionable, thing to know before it ever reaches a user.
You supply compare_fn — BOOTH doesn't choose a retrieval system or comparison algorithm for you. It can return True/False (strict pass/fail, numpy.bool_ included) or a float 0.0–1.0 compared against evidence_threshold:
result = booth.check_with_evidence(
answer=answer,
evidence=evidence,
compare_fn=compare_answer_to_evidence,
evidence_threshold=0.8,
)
check_with_evidence() makes no LLM calls, no network calls, and performs no retrieval — that pipeline is entirely yours. It's a standalone comparison gate with no relationship to a prior check()/acheck() result unless your application explicitly composes the two; see the Tutorial for the recommended composition pattern and what it does and doesn't guarantee.
API
booth.check()
booth.check(
call_fn,
prompt,
threshold=0.7,
max_retries=1,
on_attempt=None,
*,
validator=None,
)
call_fn—Callable[[str], str], receives a prompt, returns the model's raw response. Any synchronous callable works, including a callable class instance.prompt— the original application or user prompt.threshold— minimum confidence to accept an unambiguous, validator-passing answer. Default0.7.max_retries— retries after the initial attempt. Default1.on_attempt— optional callback invoked after each attempt. Must be synchronous oncheck().validator(keyword-only) — optionalValidatorFn. See Custom validation above. Must be synchronous. DefaultNone.
booth.acheck()
await booth.acheck(
call_fn, # Callable[[str], Awaitable[str]]
prompt,
threshold=0.7,
max_retries=1,
on_attempt=None,
*,
validator=None,
)
Async equivalent of check(). call_fn may be a plain async def function, a functools.partial wrapping one, or an object with an async def __call__ (0.4.6+). on_attempt accepts the same shapes and is correctly awaited (0.4.7+). validator itself is always required to be synchronous.
booth.check_with_evidence()
booth.check_with_evidence(
answer,
evidence,
compare_fn,
evidence_threshold=0.7,
)
answer— the answer being checked.evidence— a sequence of evidence strings your application already retrieved.compare_fn—Callable[[str, Sequence[str]], bool | float].evidence_threshold— minimum score whencompare_fnreturns a float. Default0.7.
Makes no LLM/network calls, performs no retrieval or retries, has no validator of its own, and always returns parsed=None.
Result Object
result.answer # str | None
result.status # VERIFIED / REPAIRED / AMBIGUOUS / UNCERTAIN / BLOCKED
result.confidence # self-reported (check/acheck) or comparison score (evidence)
result.evidence_agreement # comparison score from check_with_evidence(), else None
result.attempts # full retry history
result.n_attempts # len(result.attempts)
result.ok # True only for VERIFIED / REPAIRED
result.ambiguous # bool
result.interpretations # list[str]
result.all_parse_failed # True if every attempt failed to parse
result.method # which mechanism produced this result
result.parsed # raw, uncoerced model JSON
result.to_dict() # full result as a plain, JSON-serializable dict (0.4.7)
Result Statuses
VERIFIED— passed BOOTH's acceptance condition on the initial attempt (or the evidence comparison passed).REPAIRED— a reconsideration attempt produced an acceptable result after the first attempt fell short.AMBIGUOUS— the model identified multiple valid interpretations. Returned immediately, never retried.UNCERTAIN— no acceptable result was obtained. Checkresult.methodfor why.BLOCKED— the evidence comparison explicitly failed. Only reachable fromcheck_with_evidence().
Installation
pip install boothpy
Or directly from GitHub:
pip install git+https://github.com/Vedantgitbot/booth.git
Development
pip install -e ".[dev]"
pytest
CI runs the full suite on push/PR across Python 3.9–3.12. See CHANGELOG.md for what each version's regression tests specifically verify.
Design Principles
- Keep the checkpoint small. A reusable decision layer, not another full LLM framework.
- Make uncertainty explicit. Return a structured status instead of silently passing an unacceptable output through.
- Treat ambiguity, validation, and confidence as separate, ordered checks. A confident, validator-passing answer can still be ambiguous.
- Reconsider instead of blindly resampling. The reason an attempt failed determines what the model is actually shown on retry.
- Expose, don't reinterpret.
result.parsedshows the model's raw response rather than deciding what it should mean. - Reject invalid data, don't silently coerce it. An out-of-range confidence or an unrecognized boolean-ish value is a reason to reject the attempt, never guess at.
- Keep evidence retrieval outside BOOTH. Applications own their own RAG, search, database, or tool infrastructure.
- Do not pretend agreement is truth. Agreement with a validator, confidence value, or retrieved evidence is not the same as proving a claim.
- Stay provider-agnostic. BOOTH works with any LLM provider because your application supplies the model-calling function.
For a full account of what BOOTH intentionally does not do, its current limitations, and the reasoning behind specific design tradeoffs, see the Tutorial.
License
This is the official BOOTH repository — Vedant Brahmbhatt
BOOTH is released under the MIT License. See LICENSE for the full text.
Release files for boothpy 0.4.7
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| boothpy-0.4.7.tar.gz | 39.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| boothpy-0.4.7-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 53.7 kB
Release files / boothpy-0.4.7.tar.gz
| Download URL | boothpy-0.4.7.tar.gz |
|---|---|
| Size | 39.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c9265a1c1383d9854a1e892f44bcf0b68eb29787f2fbf6fa00bcdded72fccb9b
|
|
BLAKE2b-256 checksum How to use checksums |
59a7d11b14d0b352b1a421371a1e2ef3656ec6d906f0a5643f56bd09bf316b12
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.7
|
Release files / boothpy-0.4.7-py3-none-any.whl
| Download URL | boothpy-0.4.7-py3-none-any.whl |
|---|---|
| Size | 14.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f3de6b3ead070ead7088a28b22679955871e02a8f394e0aa33276d27deac16a1
|
|
BLAKE2b-256 checksum How to use checksums |
e7db2d6faf3583fa97f41c70c563efdb22ae5e50d835ca846366e577bdb04f79
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.7
|