| from __future__ import annotations
|
|
|
| import json
|
| import re
|
| import time
|
| from collections import Counter
|
| from collections.abc import Callable, Iterable
|
|
|
| from pydantic import ValidationError
|
|
|
| from .cases import get_case
|
| from .llm import ModelCall, ModelResult, call_small_model
|
| from .models import AgentTurn, CasePacket, JurorVote, TrialEvent, TrialRequest, Verdict
|
| from .retrieval import build_live_case
|
|
|
| GPT_OSS_MODEL = "openai/gpt-oss-20b"
|
| OPENBMB_MODEL = "openbmb/AgentCPM-Explore"
|
| NEMOTRON_MODEL = "nvidia/Nemotron-Orchestrator-8B"
|
| OPENAI_PROVIDER = "auto"
|
| OPENBMB_PROVIDER = "featherless-ai"
|
| NEMOTRON_PROVIDER = "featherless-ai"
|
|
|
| MODEL_BUDGET = [
|
| ("Presiding Advocate", GPT_OSS_MODEL, 20.0),
|
| ("Clerk of Style", OPENBMB_MODEL, 4.0),
|
| ("Juror/Auditor Ring", NEMOTRON_MODEL, 8.0),
|
| ]
|
| TOTAL_PARAMS_B = sum(item[2] for item in MODEL_BUDGET) |
|
|
| JUDGE_NAME = "Marcus Aurelius" |
| JUDGE_PERSONA = "Stoic duty, restraint, public reason, and disciplined judgment" |
|
|
| JUROR_PERSONAS = { |
| "Karl Marx": "class power, material conditions, exploitation, institutional incentives", |
| "John Stuart Mill": "liberty, harm principle, utility, individual rights", |
| "Confucius": "social harmony, role duty, ritual order, moral cultivation", |
| "Cleopatra VII": "sovereign pragmatism, diplomacy, survival, legitimacy under pressure", |
| "Niccolo Machiavelli": "political realism, stability, power, consequences over ideals", |
| "Jensen Huang": "technological optimism, operator mindset, systems thinking, innovation tradeoffs", |
| } |
| JUROR_NAMES = list(JUROR_PERSONAS) |
|
|
|
|
| class RequiredModelError(RuntimeError):
|
| """Raised when a required courtroom model call cannot produce usable output."""
|
|
|
|
|
| ModelRunner = Callable[..., ModelResult]
|
|
|
|
|
| def _turn(agent: str, role: str, result: ModelResult, model: str, confidence: float) -> AgentTurn:
|
| return AgentTurn(
|
| agent=agent,
|
| role=role,
|
| content=result.text,
|
| model=model,
|
| confidence=confidence,
|
| input=getattr(result, "input_text", ""),
|
| )
|
|
|
|
|
| def _case_summary(packet: CasePacket) -> str:
|
| return (
|
| f"{packet.title}. Charge: {packet.charge}\n"
|
| f"Claimant: {packet.claimant_claim}\n"
|
| f"Respondent: {packet.respondent_claim}"
|
| )
|
|
|
|
|
| def _evidence_summary(packet: CasePacket) -> str:
|
| return "\n".join(
|
| f"{item.id}: {item.title}; direction={item.supports}; reliability={item.reliability:.2f}; note={item.note}"
|
| for item in packet.evidence
|
| )
|
|
|
|
|
| def _call_trace(calls: list[ModelCall]) -> list[dict]:
|
| return [call.__dict__ for call in calls]
|
|
|
|
|
| def resolve_case(request: TrialRequest) -> tuple[CasePacket, dict]:
|
| if request.case_id == "live":
|
| packet = build_live_case(request.search_query, request.hypothetical) |
| if packet: |
| return packet, {"mode": "live"} |
| raise RuntimeError("Live retrieval produced too little usable evidence; no fallback case will be substituted.") |
| return get_case(request.case_id), {"mode": "cached"} |
|
|
|
|
| def _generate_role(model_runner: ModelRunner | None = None, **kwargs) -> ModelResult:
|
| if model_runner is not None:
|
| return model_runner(**kwargs)
|
| return call_small_model(**kwargs)
|
|
|
|
|
| def _required_role(model_runner: ModelRunner | None, model_calls: list[ModelCall], **kwargs) -> ModelResult: |
| try: |
| result = _generate_role(model_runner, **kwargs) |
| except Exception as exc: |
| raise RequiredModelError(f"{kwargs.get('agent', 'Model')} unavailable: {exc}") from exc |
| model_calls.append(result.call) |
| if not result.call.ok:
|
| error = result.call.error or "model call did not complete"
|
| raise RequiredModelError(f"{kwargs.get('agent', 'Model')} unavailable: {error}")
|
| if not result.text.strip():
|
| raise RequiredModelError(f"{kwargs.get('agent', 'Model')} returned an empty response.")
|
| return result
|
|
|
|
|
| def _trace(packet: CasePacket, source_trace: dict, model_calls: list[ModelCall]) -> dict:
|
| return {
|
| "case_id": packet.id,
|
| "model_budget_b": TOTAL_PARAMS_B,
|
| "models": [{"role": role, "model": model, "params_b": params} for role, model, params in MODEL_BUDGET],
|
| "model_calls": _call_trace(model_calls),
|
| "live_model_call_count": sum(1 for call in model_calls if call.ok),
|
| "attempted_model_call_count": len(model_calls),
|
| **source_trace,
|
| }
|
|
|
|
|
| def _emit(
|
| packet: CasePacket,
|
| source_trace: dict,
|
| model_calls: list[ModelCall],
|
| event: TrialEvent,
|
| delay: float,
|
| ) -> TrialEvent:
|
| event.trace = _trace(packet, source_trace, model_calls)
|
| if delay > 0:
|
| time.sleep(delay)
|
| return event
|
|
|
|
|
| def _extract_json(text: str) -> object:
|
| stripped = text.strip()
|
| if stripped.startswith("```"):
|
| stripped = re.sub(r"^```(?:json)?\s*", "", stripped, flags=re.I)
|
| stripped = re.sub(r"\s*```$", "", stripped)
|
| try:
|
| return json.loads(stripped)
|
| except json.JSONDecodeError:
|
| match = re.search(r"(\{.*\}|\[.*\])", stripped, flags=re.S)
|
| if not match:
|
| raise
|
| return json.loads(match.group(1))
|
|
|
|
|
| def _parse_jury_votes(result: ModelResult, packet: CasePacket) -> list[JurorVote]:
|
| try:
|
| data = _extract_json(result.text)
|
| except json.JSONDecodeError as exc:
|
| raise RequiredModelError(f"Nemotron Jury returned invalid JSON: {exc.msg}") from exc
|
|
|
| raw_votes = data.get("votes") if isinstance(data, dict) else data
|
| if not isinstance(raw_votes, list):
|
| raise RequiredModelError("Nemotron Jury output must contain a votes list.")
|
| if len(raw_votes) != len(JUROR_NAMES):
|
| raise RequiredModelError("Nemotron Jury must return exactly six juror votes.")
|
|
|
| known_evidence = {item.id for item in packet.evidence}
|
| votes: list[JurorVote] = []
|
| try:
|
| for item in raw_votes:
|
| vote = JurorVote.model_validate(item)
|
| votes.append(vote)
|
| except ValidationError as exc:
|
| raise RequiredModelError(f"Nemotron Jury vote schema is invalid: {exc.errors()[0]['msg']}") from exc
|
|
|
| if [vote.juror for vote in votes] != JUROR_NAMES:
|
| raise RequiredModelError("Nemotron Jury must return votes in the fixed juror order.")
|
| for vote in votes:
|
| expected_persona = JUROR_PERSONAS[vote.juror]
|
| if vote.persona.strip().lower() != expected_persona:
|
| raise RequiredModelError(f"{vote.juror} persona must be '{expected_persona}'.")
|
| if not vote.reason.strip():
|
| raise RequiredModelError(f"{vote.juror} must include a rationale.")
|
| if not vote.evidence_ids or any(evidence_id not in known_evidence for evidence_id in vote.evidence_ids):
|
| raise RequiredModelError(f"{vote.juror} must cite known evidence IDs.")
|
| return votes
|
|
|
|
|
| def _majority_finding(votes: list[JurorVote]) -> str:
|
| counts = Counter(vote.vote for vote in votes)
|
| top = counts.most_common()
|
| if not top:
|
| return "uncertain"
|
| if len(top) > 1 and top[0][1] == top[1][1]:
|
| return "mixed"
|
| if top[0][0] == "uncertain":
|
| return "uncertain"
|
| return top[0][0]
|
|
|
|
|
| def _verdict_from_votes(votes: list[JurorVote]) -> Verdict:
|
| finding = _majority_finding(votes)
|
| evidence_ids = []
|
| for vote in votes:
|
| for evidence_id in vote.evidence_ids:
|
| if evidence_id not in evidence_ids:
|
| evidence_ids.append(evidence_id)
|
| cited = evidence_ids[:4]
|
| counts = Counter(vote.vote for vote in votes)
|
| vote_line = ", ".join(f"{name}: {counts.get(name, 0)}" for name in ("liable", "not_liable", "uncertain"))
|
| decree_by_finding = {
|
| "liable": "The jury majority finds liability on the miniature record.",
|
| "not_liable": "The jury majority does not find liability on the miniature record.",
|
| "mixed": "The jury divides too closely for a clean finding.",
|
| "uncertain": "The jury leaves the court with unresolved uncertainty.",
|
| }
|
| remedy_by_finding = {
|
| "liable": "Enter symbolic censure and proportional repair.",
|
| "not_liable": "Dismiss without prejudice to stronger proof.",
|
| "mixed": "Record a divided result and preserve the exhibits for later review.",
|
| "uncertain": "Withhold sanction and identify the proof gaps before any retrial.",
|
| }
|
| return Verdict(
|
| finding=finding,
|
| decree=decree_by_finding[finding],
|
| rationale=f"Jury vote: {vote_line}. Cited evidence IDs: {', '.join(cited)}.",
|
| evidence_ids=cited,
|
| uncertainty=(
|
| "Uncertainty remains visible: this is an AI-native miniature trial. Retrieved facts, cached "
|
| "packets, and model inferences are separated in the trace and should not be treated as legal advice."
|
| ),
|
| remedy=remedy_by_finding[finding],
|
| )
|
|
|
|
|
| def _jury_task() -> str: |
| personas = "\n".join(f"- {name}: {persona}" for name, persona in JUROR_PERSONAS.items()) |
| return ( |
| "Return JSON only with a top-level 'votes' array. Create exactly one vote for each juror, in this order: " |
| f"{', '.join(JUROR_NAMES)}. Valid vote values are liable, not_liable, uncertain. Each item must contain " |
| "juror, persona, vote, reason, and evidence_ids. The persona value must exactly match the profile below. " |
| "Each reason should be one concise sentence and each evidence_ids list must cite evidence IDs from the record. " |
| "Vote through the named public-history worldview, not a generic juror role.\n" |
| f"{personas}" |
| ) |
|
|
|
|
| def run_trial(request: TrialRequest, model_runner: ModelRunner | None = None) -> list[TrialEvent]:
|
| return list(stream_trial(request, delay=0.0, model_runner=model_runner))
|
|
|
|
|
| def stream_trial(
|
| request: TrialRequest,
|
| delay: float = 0.0,
|
| model_runner: ModelRunner | None = None,
|
| ) -> Iterable[TrialEvent]:
|
| packet, source_trace = resolve_case(request)
|
| case_summary = _case_summary(packet)
|
| evidence_summary = _evidence_summary(packet)
|
| model_calls: list[ModelCall] = []
|
| hypo = request.hypothetical.strip()
|
| hypo_line = f"\n\nUser hypothetical admitted as a blue-ribbon sidebar: {hypo}" if hypo else ""
|
|
|
| clerk = _required_role(
|
| model_runner,
|
| model_calls,
|
| agent="Clerk Meridian",
|
| role="clerk",
|
| model=OPENBMB_MODEL,
|
| case_summary=case_summary,
|
| evidence_summary=evidence_summary,
|
| task="Announce the case by name, identify the parties, and read the charge.",
|
| provider=OPENBMB_PROVIDER,
|
| max_tokens=110,
|
| )
|
| yield _emit(
|
| packet,
|
| source_trace,
|
| model_calls,
|
| TrialEvent(
|
| phase="intake",
|
| title="The Court Convenes",
|
| body=f"{packet.title}\n{packet.subtitle}\n\nCharge: {packet.charge}{hypo_line}",
|
| turns=[_turn("Clerk Meridian", "clerk", clerk, OPENBMB_MODEL, 0.88)],
|
| evidence=packet.evidence,
|
| ),
|
| delay,
|
| )
|
|
|
| judge_open = _required_role( |
| model_runner, |
| model_calls, |
| agent=JUDGE_NAME, |
| role="judge", |
| model=GPT_OSS_MODEL, |
| case_summary=case_summary, |
| evidence_summary=evidence_summary, |
| task=( |
| f"As {JUDGE_NAME}, a Stoic courtroom judge guided by {JUDGE_PERSONA}, explain the proceeding " |
| "and the burden of proof in one or two disciplined sentences." |
| ), |
| provider=OPENAI_PROVIDER, |
| max_tokens=110, |
| ) |
| yield _emit(
|
| packet,
|
| source_trace,
|
| model_calls,
|
| TrialEvent(
|
| phase="intake", |
| title="The Burden Is Set", |
| body="The bench defines how the miniature court will weigh the record.", |
| turns=[_turn(JUDGE_NAME, "judge", judge_open, GPT_OSS_MODEL, 0.88)], |
| evidence=packet.evidence, |
| ), |
| delay, |
| )
|
|
|
| claimant_opening = _required_role(
|
| model_runner,
|
| model_calls,
|
| agent="Advocate Auric",
|
| role="claimant advocate",
|
| model=GPT_OSS_MODEL,
|
| case_summary=case_summary,
|
| evidence_summary=evidence_summary,
|
| task="Make the claimant's opening statement alone. Cite the strongest claimant-side exhibit.",
|
| provider=OPENAI_PROVIDER,
|
| max_tokens=130,
|
| )
|
| yield _emit(
|
| packet,
|
| source_trace,
|
| model_calls,
|
| TrialEvent(
|
| phase="claims",
|
| title="Claimant Opening",
|
| body=packet.claimant_claim,
|
| turns=[_turn("Advocate Auric", "claimant advocate", claimant_opening, GPT_OSS_MODEL, 0.88)],
|
| evidence=packet.evidence,
|
| ),
|
| delay,
|
| )
|
|
|
| respondent_opening = _required_role(
|
| model_runner,
|
| model_calls,
|
| agent="Counsel Sable",
|
| role="respondent advocate",
|
| model=GPT_OSS_MODEL,
|
| case_summary=case_summary,
|
| evidence_summary=evidence_summary,
|
| task="Make the respondent's opening statement alone. Emphasize uncertainty and cite a helpful exhibit.",
|
| provider=OPENAI_PROVIDER,
|
| max_tokens=130,
|
| )
|
| yield _emit(
|
| packet,
|
| source_trace,
|
| model_calls,
|
| TrialEvent(
|
| phase="opening",
|
| title="Respondent Opening",
|
| body=packet.respondent_claim,
|
| turns=[_turn("Counsel Sable", "respondent advocate", respondent_opening, GPT_OSS_MODEL, 0.88)],
|
| evidence=packet.evidence,
|
| ),
|
| delay,
|
| )
|
|
|
| auditor = _required_role(
|
| model_runner,
|
| model_calls,
|
| agent="Auditor Prism",
|
| role="evidence auditor",
|
| model=NEMOTRON_MODEL,
|
| case_summary=case_summary,
|
| evidence_summary=evidence_summary,
|
| task="Present the evidence record. Identify the strongest exhibit and the weakest inference.",
|
| provider=NEMOTRON_PROVIDER,
|
| max_tokens=150,
|
| )
|
| yield _emit(
|
| packet,
|
| source_trace,
|
| model_calls,
|
| TrialEvent(
|
| phase="evidence",
|
| title="The Record Is Audited",
|
| body="\n".join(f"{item.id}: {item.title} | reliability {item.reliability:.2f} | {item.note}" for item in packet.evidence),
|
| turns=[_turn("Auditor Prism", "evidence auditor", auditor, NEMOTRON_MODEL, 0.86)],
|
| evidence=packet.evidence,
|
| ),
|
| delay,
|
| )
|
|
|
| judge_question = _required_role( |
| model_runner, |
| model_calls, |
| agent=JUDGE_NAME, |
| role="judge", |
| model=GPT_OSS_MODEL, |
| case_summary=case_summary, |
| evidence_summary=evidence_summary, |
| task=( |
| f"As {JUDGE_NAME}, ask one sharp hinge question that would change the outcome if answered. " |
| "Use Stoic restraint and public reason." |
| ), |
| provider=OPENAI_PROVIDER, |
| max_tokens=100, |
| ) |
| yield _emit(
|
| packet,
|
| source_trace,
|
| model_calls,
|
| TrialEvent(
|
| phase="questions", |
| title="The Hinge Question", |
| body="The bench asks the single question that could turn the record.", |
| turns=[_turn(JUDGE_NAME, "judge", judge_question, GPT_OSS_MODEL, 0.88)], |
| evidence=packet.evidence, |
| ), |
| delay, |
| )
|
|
|
| claimant_answer = _required_role(
|
| model_runner,
|
| model_calls,
|
| agent="Advocate Auric",
|
| role="claimant advocate",
|
| model=GPT_OSS_MODEL, |
| case_summary=case_summary, |
| evidence_summary=evidence_summary, |
| task=f"Answer {JUDGE_NAME}'s hinge question for the claimant: {judge_question.text}", |
| provider=OPENAI_PROVIDER, |
| max_tokens=130, |
| ) |
| yield _emit(
|
| packet,
|
| source_trace,
|
| model_calls,
|
| TrialEvent(
|
| phase="questions",
|
| title="Claimant Answers the Bench",
|
| body="The claimant answers the hinge question.",
|
| turns=[_turn("Advocate Auric", "claimant advocate", claimant_answer, GPT_OSS_MODEL, 0.88)],
|
| evidence=packet.evidence,
|
| ),
|
| delay,
|
| )
|
|
|
| respondent_answer = _required_role(
|
| model_runner,
|
| model_calls,
|
| agent="Counsel Sable",
|
| role="respondent advocate",
|
| model=GPT_OSS_MODEL, |
| case_summary=case_summary, |
| evidence_summary=evidence_summary, |
| task=f"Answer {JUDGE_NAME}'s hinge question for the respondent: {judge_question.text}", |
| provider=OPENAI_PROVIDER, |
| max_tokens=130, |
| ) |
| yield _emit(
|
| packet,
|
| source_trace,
|
| model_calls,
|
| TrialEvent(
|
| phase="questions",
|
| title="Respondent Answers the Bench",
|
| body="The respondent answers the hinge question.",
|
| turns=[_turn("Counsel Sable", "respondent advocate", respondent_answer, GPT_OSS_MODEL, 0.88)],
|
| evidence=packet.evidence,
|
| ),
|
| delay,
|
| )
|
|
|
| jury_panel = _required_role(
|
| model_runner,
|
| model_calls,
|
| agent="Nemotron Jury",
|
| role="juror panel",
|
| model=NEMOTRON_MODEL,
|
| case_summary=case_summary,
|
| evidence_summary=evidence_summary,
|
| task="Announce that the six named jurors retire to vote. Do not reveal the votes yet.",
|
| provider=NEMOTRON_PROVIDER,
|
| max_tokens=100,
|
| )
|
| yield _emit(
|
| packet,
|
| source_trace,
|
| model_calls,
|
| TrialEvent(
|
| phase="deliberation",
|
| title="The Jury Retires",
|
| body="Six fixed-perspective jurors leave the public floor to vote from the record.",
|
| turns=[_turn("Nemotron Jury", "juror panel", jury_panel, NEMOTRON_MODEL, 0.86)],
|
| evidence=packet.evidence,
|
| ),
|
| delay,
|
| )
|
|
|
| jury_votes_result = _required_role(
|
| model_runner,
|
| model_calls,
|
| agent="Nemotron Jury",
|
| role="juror vote generator",
|
| model=NEMOTRON_MODEL,
|
| case_summary=case_summary,
|
| evidence_summary=evidence_summary,
|
| task=_jury_task(),
|
| provider=NEMOTRON_PROVIDER,
|
| max_tokens=650,
|
| )
|
| votes = _parse_jury_votes(jury_votes_result, packet)
|
| for vote in votes:
|
| juror_result = ModelResult(
|
| text=f"{vote.vote.replace('_', ' ').title()}. {vote.reason}",
|
| call=jury_votes_result.call,
|
| input_text=jury_votes_result.input_text,
|
| )
|
| yield _emit(
|
| packet,
|
| source_trace,
|
| model_calls,
|
| TrialEvent(
|
| phase="deliberation",
|
| title=f"Juror {vote.juror} Votes",
|
| body=f"{vote.persona}. Evidence: {', '.join(vote.evidence_ids)}.",
|
| turns=[_turn(vote.juror, "juror", juror_result, NEMOTRON_MODEL, 0.86)],
|
| votes=[vote],
|
| evidence=packet.evidence,
|
| ),
|
| delay,
|
| )
|
|
|
| verdict = _verdict_from_votes(votes)
|
| verdict_voice = _required_role( |
| model_runner, |
| model_calls, |
| agent=JUDGE_NAME, |
| role="verdict writer", |
| model=GPT_OSS_MODEL, |
| case_summary=case_summary, |
| evidence_summary=evidence_summary, |
| task=( |
| f"As {JUDGE_NAME}, announce the final legal finding after the jury vote with Stoic restraint. " |
| f"Finding: {verdict.finding}. " |
| f"Jury rationale: {verdict.rationale} Remedy: {verdict.remedy}. Include uncertainty without disclaiming the role." |
| ), |
| provider=OPENAI_PROVIDER,
|
| max_tokens=160,
|
| )
|
| yield _emit(
|
| packet,
|
| source_trace,
|
| model_calls,
|
| TrialEvent(
|
| phase="verdict",
|
| title="The Court Announces Judgment",
|
| body=f"{verdict_voice.text}\n\n{verdict.rationale}\n\nRemedy: {verdict.remedy}",
|
| verdict=verdict, |
| votes=votes, |
| evidence=packet.evidence, |
| turns=[_turn(JUDGE_NAME, "verdict writer", verdict_voice, GPT_OSS_MODEL, 0.88)], |
| ), |
| delay, |
| ) |
|
|
|
|
| def stream_trial_jsonl(
|
| request: TrialRequest,
|
| delay: float = 0.0,
|
| model_runner: ModelRunner | None = None,
|
| ) -> Iterable[str]:
|
| for event in stream_trial(request, delay, model_runner=model_runner):
|
| yield json.dumps(event.model_dump(), ensure_ascii=True) + "\n"
|
|
|