llm-jury
When your classifier is uncertain, let a configurable jury of LLM personas debate and return an auditable verdict.
Overview
llm-jury is an SDK, not a hosted API. Your app imports it directly:
from llm_jury import Jury, PersonaRegistry
It wraps a classifier returning (label, confidence) and adds confidence-based escalation:
- Run primary classifier (fast path)
- Return directly when confidence is high
- Escalate low-confidence cases to persona debate
- Consolidate with a judge strategy
- Return verdict + audit trail
Research Inspiration
llm-jury is inspired by the CEJ (Collaborative Expert Judgment) module described in arXiv:2512.23732. This package generalizes that pattern into a domain-agnostic SDK with pluggable classifiers, multiple debate modes, multiple judge strategies, threshold calibration, and Python + TypeScript distributions.
Install
pip install llm-jury-classifier
Optional extras:
pip install "llm-jury-classifier[sklearn]"
pip install "llm-jury-classifier[huggingface]"
pip install "llm-jury-classifier[all]"
Prerequisites
- Python
>=3.10 - For real LLM calls:
OPENAI_API_KEY(or provider key through your LiteLLM/OpenAI setup)
Quick Start
import asyncio
from llm_jury import Jury, PersonaRegistry
from llm_jury.classifiers import FunctionClassifier
from llm_jury.judges import MajorityVoteJudge
classifier = FunctionClassifier(
fn=lambda text: ("safe", 0.62),
labels=["safe", "unsafe"],
)
jury = Jury(
classifier=classifier,
personas=PersonaRegistry.content_moderation(),
confidence_threshold=0.7,
judge=MajorityVoteJudge(),
)
async def main():
verdict = await jury.classify("borderline message")
print(verdict.label, verdict.confidence, verdict.was_escalated)
asyncio.run(main())
With LLM Classifier
import asyncio
from llm_jury import Jury, PersonaRegistry
from llm_jury.classifiers import LLMClassifier
from llm_jury.judges import MajorityVoteJudge
classifier = LLMClassifier(labels=["safe", "unsafe"])
jury = Jury(
classifier=classifier,
personas=PersonaRegistry.content_moderation(),
confidence_threshold=0.85,
judge=MajorityVoteJudge(),
)
async def main():
verdict = await jury.classify("That group always causes problems")
print(f"Label: {verdict.label}")
print(f"Confidence: {verdict.confidence}")
print(f"Escalated: {verdict.was_escalated}")
if verdict.debate_transcript:
for resp in verdict.debate_transcript.rounds[-1]:
print(f" {resp.persona_name}: {resp.label} ({resp.confidence})")
asyncio.run(main())
SDK Response
jury.classify(text) returns a Verdict. There are two shapes depending on whether the input was escalated.
Fast path (confidence above threshold)
When the primary classifier is confident enough, the verdict is returned directly with no debate.
{
"label": "safe",
"confidence": 0.95,
"reasoning": "Classified by primary classifier with sufficient confidence.",
"was_escalated": false,
"primary_result": {
"label": "safe",
"confidence": 0.95,
"raw_output": { "label": "safe", "confidence": 0.95 }
},
"debate_transcript": null,
"judge_strategy": "primary_classifier",
"total_duration_ms": 312,
"total_cost_usd": 0.0001
}
Escalated (confidence below threshold)
When confidence is too low, the input goes through persona debate and a judge produces the final verdict.
{
"label": "unsafe",
"confidence": 1.0,
"reasoning": "The statement is a sweeping negative generalization about an entire group of people.",
"was_escalated": true,
"primary_result": {
"label": "unsafe",
"confidence": 0.62,
"raw_output": { "label": "unsafe", "confidence": 0.62 }
},
"debate_transcript": {
"input_text": "Those people always cause problems wherever they go",
"primary_result": { "label": "unsafe", "confidence": 0.62 },
"rounds": [
[
{
"persona_name": "Policy Analyst",
"label": "unsafe",
"confidence": 0.90,
"reasoning": "The statement is a blanket negative generalization targeting a group.",
"key_factors": ["group-targeting language", "sweeping generalization"],
"dissent_notes": null,
"tokens_used": 185,
"cost_usd": 0.0003
},
{
"persona_name": "Cultural Context Expert",
"label": "unsafe",
"confidence": 0.85,
"reasoning": "While context could soften interpretation, the phrasing is unambiguously negative.",
"key_factors": ["no mitigating context", "derogatory framing"],
"dissent_notes": null,
"tokens_used": 192,
"cost_usd": 0.0003
},
{
"persona_name": "Harm Assessment Specialist",
"label": "unsafe",
"confidence": 0.92,
"reasoning": "Broad negative generalization risks normalizing prejudice against the targeted group.",
"key_factors": ["potential for real-world harm", "targets unspecified group"],
"dissent_notes": null,
"tokens_used": 178,
"cost_usd": 0.0003
}
]
],
"summary": "The experts unanimously agreed the statement constitutes an unsafe sweeping generalization targeting a group.",
"duration_ms": 2450,
"total_tokens": 555,
"total_cost_usd": 0.0009
},
"judge_strategy": "majority_vote",
"total_duration_ms": 2780,
"total_cost_usd": 0.001
}
Verdict field reference
| Field | Type | Description |
|---|---|---|
label |
str |
Final classification |
confidence |
float |
Final confidence (0.0-1.0) |
reasoning |
str |
Human-readable explanation |
was_escalated |
bool |
Whether debate was triggered |
primary_result |
ClassificationResult |
Fast-path classifier output |
debate_transcript |
DebateTranscript | None |
Full debate audit trail incl. rounds, summary, token/cost totals (null if not escalated) |
judge_strategy |
str |
Strategy that produced the verdict |
total_duration_ms |
int |
Wall-clock time (ms) |
total_cost_usd |
float | None |
API cost in USD |
Persona response fields
| Field | Type | Description |
|---|---|---|
persona_name |
str |
Which persona |
label |
str |
This persona's classification |
confidence |
float |
This persona's confidence |
reasoning |
str |
Full reasoning chain |
key_factors |
list[str] |
Key decision factors |
dissent_notes |
str | None |
Rebuttal in deliberation/adversarial modes |
tokens_used |
int |
Tokens consumed |
cost_usd |
float | None |
API cost for this call |
DebateTranscript also includes summary (str | None) — a structured summary produced during the Summarisation stage of the deliberation pipeline (null in non-deliberation modes).
Choosing What To Use
Classifiers
| Classifier | When to use | Example |
|---|---|---|
FunctionClassifier |
Wrap an existing model or function | FunctionClassifier(fn=my_model, labels=["a","b"]) |
LLMClassifier |
Primary classifier is an LLM | LLMClassifier(labels=["safe","unsafe"]) |
HuggingFaceClassifier |
Local HuggingFace model | HuggingFaceClassifier("unitary/toxic-bert") |
SklearnClassifier |
Wrap a scikit-learn model | SklearnClassifier(model, labels, vectorizer) |
Built-in Persona Sets
| Method | Domain | Personas |
|---|---|---|
PersonaRegistry.content_moderation() |
Trust & Safety | Policy Analyst, Cultural Context Expert, Harm Assessment Specialist |
PersonaRegistry.legal_compliance() |
Legal/Regulatory | Regulatory Attorney, Business Risk Analyst, Industry Standards Expert |
PersonaRegistry.medical_triage() |
Healthcare | Clinical Safety Reviewer, Contextual Historian, Resource Allocation Analyst |
PersonaRegistry.financial_compliance() |
AML/KYC | AML Investigator, Risk Quant, Business Controls Reviewer |
PersonaRegistry.custom([...]) |
Any domain | Provide your own persona dicts |
Judge Strategies
| Strategy | How it decides | Best for |
|---|---|---|
MajorityVoteJudge() |
Counts persona votes. Confidence = fraction agreeing. | Fast, no extra LLM call |
WeightedVoteJudge() |
Weights votes by persona confidence. | When confidence scores vary significantly |
LLMJudge() |
LLM reads full transcript and synthesises verdict. | Maximum quality, auditable reasoning |
BayesianJudge() |
Bayesian aggregation with optional persona priors. | When you have reliability data on personas |
Debate Modes
| Mode | Behaviour | Best for |
|---|---|---|
independent |
All personas assess in parallel | Fast, low cost |
sequential |
Each persona sees previous responses | Building on earlier assessments |
deliberation (default) |
Full 4-stage CEJ pipeline: Initial Opinions, Structured Debate, Summarisation, Final Judgment | Maximum value; complex edge cases |
adversarial |
Assigns prosecution/defense stances | Stress-testing a classification |
Important Notes
- Temperature is handled automatically. The SDK omits the temperature parameter for reasoning models (
gpt-5*,o1*,o3*). No configuration needed. - Escalation is strictly
< threshold— confidence exactly equal to the threshold does NOT escalate. - Automatic retry: All LLM calls retry up to 3 times with exponential backoff (via tenacity).
- Default debate mode is deliberation for maximum value — it runs the full 4-stage CEJ pipeline. For cheaper/faster operation, use
DebateConfig(mode=DebateMode.INDEPENDENT). - Cost tracking —
total_cost_usdon verdicts is estimated from token usage via litellm's model pricing table, not from provider billing. Accurate for known models; may beNonefor unrecognised ones. - Empty personas disables escalation: If you pass
personas=[], the jury always returns the primary classifier result.
API Reference
Public Exports
from llm_jury import (
Jury,
JuryStats,
Persona,
PersonaResponse,
PersonaRegistry,
DebateConfig,
DebateMode,
DebateTranscript,
Verdict,
)
Jury Options
| Option | Default | Description |
|---|---|---|
classifier |
(required) | Primary classifier |
personas |
(required) | List of personas |
confidence_threshold |
0.7 |
Escalation threshold |
judge |
None (defaults to LLMJudge) |
Judge strategy |
debate_config |
None |
Debate configuration |
escalation_override |
None |
Force escalation |
max_debate_cost_usd |
None |
Cost cap for debate |
estimated_cost_per_persona_usd |
0.01 |
Heuristic per-call cost used for the pre-flight estimate |
debate_concurrency |
5 |
Max concurrent persona calls |
on_escalation |
None |
Fires when input is escalated to debate. (text, primary_result) -> None |
on_cost_estimate |
None |
Fires with (estimated_max_debate_cost_usd, text) immediately before a debate would run. Return False to skip the debate (verdict marked cost_guard_user_override); return True / None to proceed. |
on_verdict |
None |
Verdict callback |
llm_client |
None |
LLM transport override |
logger |
None |
Logger override |
Methods:
await classify(text)— classify a single inputawait classify_batch(texts, concurrency=10, return_exceptions=False)— classify multiple inputs. Withreturn_exceptions=True, a failing text yields its exception in-slot instead of rejecting the whole batch.
Behavior notes:
- Escalation condition is strictly
< threshold(exactly equal does not escalate). - If
personasis empty, jury escalation is effectively disabled. - Failed persona calls (LLM error or unparseable output) are kept in the transcript as placeholders with
failed=Truebut carry no vote. If the whole final round failed, judges return the primary classifier result.Verdict.persona_failurescounts them andVerdict.debate_degradedis True when any persona failed — use it to route degraded verdicts to human review. - If
max_debate_cost_usdis exceeded, result falls back to primary classifier withjudge_strategyset tocost_guard_primary_fallback. Jury.estimated_max_debate_cost_usd(property) returns the heuristic upper-bound estimateN_personas × max_rounds × estimated_cost_per_persona_usd. Useful for budgeting before any call.on_cost_estimateruns after the escalation decision but before any LLM call for the debate, and before themax_debate_cost_usdguard. Lets you layer per-tenant budgets, time-of-day gates, etc. on top of the hard cap.
Stats: jury.stats.total, fast_path, escalated, escalation_rate, cost_savings_vs_always_escalate.
DebateConfig Options
| Option | Default | Meaning |
|---|---|---|
mode |
deliberation |
Debate mode |
max_rounds |
2 |
Max deliberation rounds |
include_primary_result |
true |
Include primary result in prompts |
include_confidence |
true |
Include confidence in prompt context |
early_stop_min_confidence |
None |
F7: opt-in high-confidence early stop for DELIBERATION mode. When set, the deliberation loop exits early after any round whose minimum persona confidence is >= this value, even if personas disagree on label. Unanimous-label consensus still triggers early exit regardless. None = original behaviour (unanimous-label only). |
Personas
Persona fields: name, role, system_prompt, model="gpt-5-mini", temperature=0.3, known_bias=None.
Classifiers (API)
All classifiers implement classify(text) and expose labels.
- FunctionClassifier:
FunctionClassifier(fn, labels)wherefnmay be sync or async - LLMClassifier:
LLMClassifier(model="gpt-5-mini", labels=None, system_prompt=None, llm_client=None, temperature=0.0) - SklearnClassifier:
SklearnClassifier(model, labels, vectorizer=None)usespredict_proba - HuggingFaceClassifier:
HuggingFaceClassifier(model_name, device="cpu")(requirestransformers)
Judge Strategies (API)
- MajorityVoteJudge:
MajorityVoteJudge()— confidence = fraction of personas voting winning label - WeightedVoteJudge:
WeightedVoteJudge()— confidence based on confidence-weighted label scores - LLMJudge:
LLMJudge(model="gpt-5-mini", system_prompt=None, temperature=0.0, llm_client=None)— falls back to primary result withllm_judge_fallback_invalid_jsonif JSON parse fails - BayesianJudge:
BayesianJudge(persona_priors=None)— uses persona priors/reliability maps if provided
Threshold Calibration
ThresholdCalibrator(jury) then await calibrate(texts, labels, error_cost=10.0, escalation_cost=0.05, thresholds=None).
Report: calibration_report() returns rows with threshold, accuracy, escalation rate, and total cost. calibrate(...) mutates jury.threshold to the best threshold.
LLM Transport (LiteLLMClient)
LiteLLMClient.complete(model, system_prompt, prompt, temperature)- Uses
litellm.acompletion - Returns:
{content, tokens, cost_usd}(cost_usdmay beNone) - Raises a runtime error if
litellmis not installed and no customllm_clientis injected.
Response Cache (CachingLLMClient)
Opt-in LRU wrapper around any LLMClient. Keyed on
(model, system_prompt, prompt, temperature, response_format).
Successful responses only — exceptions propagate without being cached.
from llm_jury import CachingLLMClient, Jury
from llm_jury.llm import LiteLLMClient
jury = Jury(
# ...
llm_client=CachingLLMClient(
LiteLLMClient(),
max_size=1000, # LRU cap
ttl_seconds=3600, # optional; omit for no expiry
),
)
hits, misses, and size are exposed for introspection. Call
clear() to drop everything. The cache is in-process and per-instance;
share the CachingLLMClient object across Jury instances if you want
a shared cache. Caches at any temperature — if you need fresh stochastic
samples, don't wrap.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Authentication/401 error on first LLM call |
OPENAI_API_KEY not set, or wrong provider key for the model |
export OPENAI_API_KEY=...; or pass llm_client=LiteLLMClient(api_key=...) |
ImportError: No module named 'litellm' |
litellm not installed and no custom llm_client injected |
pip install llm-jury-classifier (litellm is a hard dep); or inject your own llm_client |
ImportError: transformers / numpy |
Using HuggingFaceClassifier / SklearnClassifier without the extras |
pip install "llm-jury-classifier[huggingface]" or [sklearn] |
ValueError: labels cannot be empty |
LLMClassifier(labels=[]) |
Pass at least one label |
Verdict.label is the first label with confidence=0.0 |
LLMClassifier couldn't parse the model's JSON response |
Use a model that supports response_format; persona responses are schema-constrained (F2), but LLMClassifier's own parse path is not yet — see audit S3 |
| Repeated 429s and the whole call fails | Rate-limit budget exhausted; SDK retries 3× with exponential backoff but doesn't honour Retry-After (R7) |
Lower debate_concurrency; lower batch concurrency; use a higher-tier key |
verdict.judge_strategy == "cost_guard_pre_flight" (no debate ran) |
Pre-flight estimate (N personas × max_rounds × estimated_cost_per_persona_usd) exceeded max_debate_cost_usd |
Raise the cap, lower max_rounds, or accept the primary classifier verdict |
verdict.judge_strategy == "cost_guard_primary_fallback" (debate ran partially) |
Actual mid-debate spend hit the cap | Same as above; spend can still overshoot by up to one concurrency-batch (in-flight calls aren't cancellable) |
verdict.judge_strategy == "cost_guard_user_override" |
Your on_cost_estimate callback returned False |
Working as intended — the debate was skipped per your policy |
verdict.total_cost_usd is None |
Model not in litellm's pricing table | Check litellm.model_cost; pin to a known-priced model; or compute cost yourself in a custom llm_client |
| Verdict is never escalated even at very low confidence | personas=[] silently disables escalation (by design) |
Pass at least one persona |
verdict.debate_degraded is True |
One or more persona calls failed (auth, rate-limit exhaustion, unparseable output). Failed personas carry no vote; if the whole final round failed, judges return the primary classifier result | Inspect verdict.persona_failures and the transcript's failed responses; consider routing degraded verdicts to human review |
One persona always missing from verdict.debate_transcript.rounds |
That persona's model is invalid / not available to your key. Single-persona failure no longer crashes the verdict (B2 fix) — it's dropped |
Inspect logs; fix the persona's model or remove the persona |
Debate summary is None even in deliberation mode |
Summariser LLM call failed; persona rounds are still load-bearing (post-D6 fix) | Inspect logs for the warning; verify the persona-0 model is reachable |
verdict.total_duration_ms is 0 from a custom judge |
Custom judge didn't set the field; Jury only backfills when at default | Set total_duration_ms in your judge if you want a custom value |
For deeper context on known open issues, see AUDIT.md §3 (Reliability) and §1 (Bugs).
Examples
Runnable examples in examples/ (require OPENAI_API_KEY):
python examples/content_moderation.py # Content moderation with LLM classifier
python examples/custom_personas.py # Custom persona definitions + deliberation mode
python examples/legal_compliance.py # Legal compliance with sequential debate + weighted vote
python examples/threshold_calibration.py # Threshold calibration (no API key needed)
Testing
cd packages/python
pip install -e ".[dev]"
python -m pytest tests/ -v
CLI
The CLI is for batch workflows. The primary interface is the Python API above.
# Classify a JSONL file
llm-jury classify \
--input input.jsonl \
--output verdicts.jsonl \
--classifier function \
--personas content_moderation \
--judge majority \
--threshold 0.7 \
--labels safe,unsafe
# Calibrate threshold from labelled data
llm-jury calibrate \
--input calibration.jsonl \
--classifier function \
--personas content_moderation \
--judge majority \
--labels safe,unsafe
Input JSONL format for classify:
{"text": "some text", "predicted_label": "safe", "predicted_confidence": 0.95}
Input JSONL format for calibrate (requires ground-truth label):
{"text": "some text", "label": "safe", "predicted_label": "safe", "predicted_confidence": 0.95}
Supported classifier specs: function, llm:<model>, huggingface:<model>.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file llm_jury_classifier-0.2.0.tar.gz.
File metadata
- Download URL: llm_jury_classifier-0.2.0.tar.gz
- Upload date:
- Size: 41.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
77ac5c2b681efccfea5523e2c1fdb2088625b99a4e4b3b6188b4dcc59687b95a
|
|
| MD5 |
3ced06d89861881049d2b57a7cbcac37
|
|
| BLAKE2b-256 |
0d113ec9469d12d8110e1514ddff3ec88cf04716a15d7df0feea74ad9fff7a75
|
Provenance
The following attestation bundles were made for llm_jury_classifier-0.2.0.tar.gz:
Publisher:
release.yml on mokhld/llm-jury
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
llm_jury_classifier-0.2.0.tar.gz -
Subject digest:
77ac5c2b681efccfea5523e2c1fdb2088625b99a4e4b3b6188b4dcc59687b95a - Sigstore transparency entry: 2435304128
- Sigstore integration time:
-
Permalink:
mokhld/llm-jury@4e2ddac6f0b90d09b871dfb75d272b2f89c48728 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/mokhld
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4e2ddac6f0b90d09b871dfb75d272b2f89c48728 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file llm_jury_classifier-0.2.0-py3-none-any.whl.
File metadata
- Download URL: llm_jury_classifier-0.2.0-py3-none-any.whl
- Upload date:
- Size: 41.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c545077a07d5703d987edf2084c08722f646034d87b32e1f2bc57e39a9fcd51a
|
|
| MD5 |
ea41c34f836b6ca1498aa7f8a0d04433
|
|
| BLAKE2b-256 |
3e235f39129c4bc9916d3922d00244eff82f5c56e2b11b57630b430effbf7ae3
|
Provenance
The following attestation bundles were made for llm_jury_classifier-0.2.0-py3-none-any.whl:
Publisher:
release.yml on mokhld/llm-jury
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
llm_jury_classifier-0.2.0-py3-none-any.whl -
Subject digest:
c545077a07d5703d987edf2084c08722f646034d87b32e1f2bc57e39a9fcd51a - Sigstore transparency entry: 2435304222
- Sigstore integration time:
-
Permalink:
mokhld/llm-jury@4e2ddac6f0b90d09b871dfb75d272b2f89c48728 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/mokhld
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4e2ddac6f0b90d09b871dfb75d272b2f89c48728 -
Trigger Event:
workflow_dispatch
-
Statement type: