mirror of
https://github.com/saymrwulf/crisis.git
synced 2026-09-06 19:30:35 +00:00
`crisis-agents demo --live` swaps the three honest MockAgents for
LiveClaudeAgent instances that issue one Anthropic Messages API call
per turn. The byzantine joiner stays mocked: making the byzantine
deterministic with an LLM would require multiple API calls per turn
(one per peer subset) for unreliable yields. Better demo legibility
to keep the equivocator scripted.
Prompt shape: the honest agent receives the reference doc, a list of
statements still to adjudicate, and the last 12 claims observed from
peers; it responds with a JSON array of {statement_id, verdict,
confidence, evidence} objects. The parser tolerates markdown fences
and per-item validation failures; malformed responses produce no
emissions rather than crashing the demo.
Default model: claude-haiku-4-5-20251001 — fast enough and cheap
enough for short-form structured-output adjudication. Override with
`--model <id>`.
Dependency: anthropic SDK as an optional install — `pip install -e
".[live]"`. Lazy-imported so the mocked path never needs it.
Tests: 6 new tests in test_live_agent.py using a fake Anthropic client
(no real API calls in CI):
- clean JSON response parsing
- markdown-fence tolerance
- malformed-response graceful degradation
- per-item validation skipping
- already-adjudicated statement filtering (the agent doesn't keep
re-asking about statements it has already answered)
- evidence-length truncation to Claim.EVIDENCE_MAX_LEN
Suite: 145 -> 150 tests, all green in 0.77s.
Manual test (not in CI; requires API credits):
pip install -e ".[live]"
export ANTHROPIC_API_KEY=...
crisis-agents demo --live
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
156 lines
6 KiB
Python
156 lines
6 KiB
Python
"""Tests for LiveClaudeAgent — uses a fake Anthropic client (no real API calls)."""
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from crisis_agents.claim import Claim
|
|
from crisis_agents.live_agent import LiveClaudeAgent
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fakes — we never hit the real Anthropic API in CI.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class _FakeContentBlock:
|
|
type: str
|
|
text: str
|
|
|
|
|
|
@dataclass
|
|
class _FakeResponse:
|
|
content: list[_FakeContentBlock]
|
|
|
|
|
|
class _FakeAnthropicClient:
|
|
"""Stand-in for anthropic.Anthropic that returns whatever JSON we hand it."""
|
|
|
|
def __init__(self, scripted_responses: list[str]):
|
|
self._responses = list(scripted_responses)
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
# The real SDK exposes .messages.create; mirror that.
|
|
outer = self
|
|
|
|
class _MessagesProxy:
|
|
def create(self_inner, **kwargs):
|
|
outer.calls.append(kwargs)
|
|
text = outer._responses.pop(0) if outer._responses else "[]"
|
|
return _FakeResponse(content=[_FakeContentBlock("text", text)])
|
|
|
|
self.messages = _MessagesProxy()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The statements + reference doc fixture
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_STATEMENTS = [
|
|
{"id": "s01", "text": "Water boils at 100C at standard pressure."},
|
|
{"id": "s02", "text": "Pluto is still classified as a planet by the IAU."},
|
|
]
|
|
_REF = "Water boils at 100C. Pluto was reclassified to a dwarf planet in 2006."
|
|
|
|
|
|
class TestLiveClaudeAgent:
|
|
|
|
def test_parses_clean_json_response(self):
|
|
response = (
|
|
'[{"statement_id":"s01","verdict":"true","confidence":0.95,"evidence":"per ref"},'
|
|
' {"statement_id":"s02","verdict":"false","confidence":0.9,"evidence":"per ref"}]'
|
|
)
|
|
client = _FakeAnthropicClient([response])
|
|
agent = LiveClaudeAgent(
|
|
"agent_alpha", reference_doc=_REF,
|
|
statements=_STATEMENTS, client=client,
|
|
)
|
|
turns = agent.next_turn(turn=0, received_claims=[])
|
|
assert len(turns) == 2
|
|
assert {t.claim.statement_id for t in turns} == {"s01", "s02"}
|
|
verdicts = {t.claim.statement_id: t.claim.verdict for t in turns}
|
|
assert verdicts == {"s01": "true", "s02": "false"}
|
|
|
|
def test_strips_markdown_fences(self):
|
|
"""Claude sometimes wraps JSON in ```json fences despite instructions."""
|
|
response = (
|
|
"```json\n"
|
|
'[{"statement_id":"s01","verdict":"true","confidence":0.9,"evidence":"ok"}]\n'
|
|
"```\n"
|
|
)
|
|
client = _FakeAnthropicClient([response])
|
|
agent = LiveClaudeAgent(
|
|
"agent_alpha", reference_doc=_REF,
|
|
statements=_STATEMENTS, client=client,
|
|
)
|
|
turns = agent.next_turn(turn=0, received_claims=[])
|
|
assert len(turns) == 1
|
|
assert turns[0].claim.statement_id == "s01"
|
|
|
|
def test_returns_empty_on_malformed_response(self):
|
|
client = _FakeAnthropicClient(["not json at all"])
|
|
agent = LiveClaudeAgent(
|
|
"agent_alpha", reference_doc=_REF,
|
|
statements=_STATEMENTS, client=client,
|
|
)
|
|
turns = agent.next_turn(turn=0, received_claims=[])
|
|
assert turns == []
|
|
|
|
def test_skips_invalid_claim_objects_in_response(self):
|
|
response = (
|
|
'[{"statement_id":"s01","verdict":"true","confidence":0.9,"evidence":"ok"},'
|
|
' "not a dict",'
|
|
' {"statement_id":"s02","verdict":"bogus","confidence":0.5,"evidence":"x"}]'
|
|
)
|
|
client = _FakeAnthropicClient([response])
|
|
agent = LiveClaudeAgent(
|
|
"agent_alpha", reference_doc=_REF,
|
|
statements=_STATEMENTS, client=client,
|
|
)
|
|
turns = agent.next_turn(turn=0, received_claims=[])
|
|
# Only the first item passes validation: bogus verdict and non-dict get skipped.
|
|
assert len(turns) == 1
|
|
assert turns[0].claim.statement_id == "s01"
|
|
|
|
def test_already_adjudicated_statements_are_skipped(self):
|
|
response_1 = '[{"statement_id":"s01","verdict":"true","confidence":0.9,"evidence":"ok"}]'
|
|
response_2 = '[{"statement_id":"s02","verdict":"false","confidence":0.9,"evidence":"ok"}]'
|
|
client = _FakeAnthropicClient([response_1, response_2])
|
|
agent = LiveClaudeAgent(
|
|
"agent_alpha", reference_doc=_REF,
|
|
statements=_STATEMENTS, client=client,
|
|
)
|
|
# First call adjudicates s01
|
|
first = agent.next_turn(turn=0, received_claims=[])
|
|
assert {t.claim.statement_id for t in first} == {"s01"}
|
|
|
|
# Second call should only ask about s02 (s01 is already done)
|
|
second = agent.next_turn(turn=1, received_claims=[])
|
|
assert {t.claim.statement_id for t in second} == {"s02"}
|
|
|
|
# The prompt sent for the second call should NOT mention s01
|
|
second_call = client.calls[1]
|
|
user_msg = second_call["messages"][0]["content"]
|
|
assert "s02:" in user_msg
|
|
# s01 was previously adjudicated; it should not appear in the
|
|
# "STATEMENTS TO ADJUDICATE" block of the second prompt.
|
|
statements_section = user_msg.split("=== STATEMENTS TO ADJUDICATE ===")[1]
|
|
next_section_start = statements_section.find("===")
|
|
statements_only = statements_section[:next_section_start]
|
|
assert "s01:" not in statements_only
|
|
|
|
def test_evidence_length_is_truncated(self):
|
|
long_evidence = "x" * 500
|
|
response = (
|
|
f'[{{"statement_id":"s01","verdict":"true","confidence":0.9,'
|
|
f'"evidence":"{long_evidence}"}}]'
|
|
)
|
|
client = _FakeAnthropicClient([response])
|
|
agent = LiveClaudeAgent(
|
|
"agent_alpha", reference_doc=_REF,
|
|
statements=_STATEMENTS, client=client,
|
|
)
|
|
turns = agent.next_turn(turn=0, received_claims=[])
|
|
assert len(turns) == 1
|
|
assert len(turns[0].claim.evidence) == Claim.EVIDENCE_MAX_LEN
|