receipts-gate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
receipts/__init__.py ADDED
@@ -0,0 +1,70 @@
1
+ """Receipts — force AI agents to back every claim with evidence, or declare it.
2
+
3
+ Quick start:
4
+
5
+ from receipts import Ledger, Gate, Answer, Claim, Assumption, ClaimKind
6
+
7
+ ledger = Ledger()
8
+ # ... evidence gets recorded as the agent actually does work ...
9
+ ledger.record("file_read", "config.py", "PORT = 8080")
10
+
11
+ gate = Gate(ledger) # hard gate by default
12
+ answer = Answer(
13
+ summary="The service listens on port 8080.",
14
+ claims=[Claim("The port is 8080", evidence_ids=[...], kind=ClaimKind.FACT)],
15
+ )
16
+ print(gate.finalize(answer)) # raises UngroundedAnswerError if not grounded
17
+ """
18
+
19
+ from .errors import ReceiptsError, UngroundedAnswerError
20
+ from .extract import extract_claims
21
+ from .gate import Gate, audit, render
22
+ from .instrument import ingest_trace, track
23
+ from .ledger import Ledger
24
+ from .llm import AnthropicLLM, FakeLLM
25
+ from .models import (
26
+ Answer,
27
+ Assumption,
28
+ Claim,
29
+ ClaimKind,
30
+ ClaimResult,
31
+ Evidence,
32
+ EvidenceKind,
33
+ Verdict,
34
+ )
35
+ from .trace import load_trace
36
+ from .verify import (
37
+ HeuristicVerifier,
38
+ LLMSupportVerifier,
39
+ NullVerifier,
40
+ SupportVerifier,
41
+ )
42
+
43
+ __version__ = "0.1.0"
44
+
45
+ __all__ = [
46
+ "Ledger",
47
+ "Gate",
48
+ "audit",
49
+ "render",
50
+ "track",
51
+ "ingest_trace",
52
+ "load_trace",
53
+ "extract_claims",
54
+ "AnthropicLLM",
55
+ "FakeLLM",
56
+ "Answer",
57
+ "Assumption",
58
+ "Claim",
59
+ "ClaimKind",
60
+ "ClaimResult",
61
+ "Evidence",
62
+ "EvidenceKind",
63
+ "Verdict",
64
+ "HeuristicVerifier",
65
+ "LLMSupportVerifier",
66
+ "NullVerifier",
67
+ "SupportVerifier",
68
+ "ReceiptsError",
69
+ "UngroundedAnswerError",
70
+ ]
@@ -0,0 +1,17 @@
1
+ """Framework adapters that feed real tool-call traces into a Ledger.
2
+
3
+ Capture is framework-agnostic at the core (`@track`, `ingest_trace`); these
4
+ adapters map specific ecosystems' trace formats onto the Ledger so you don't have
5
+ to hand-translate. Each is import-light and optional.
6
+ """
7
+
8
+ from .anthropic_trace import from_anthropic_messages
9
+ from .langchain import langchain_handler, record_tool_end
10
+ from .openai_trace import from_openai_messages
11
+
12
+ __all__ = [
13
+ "from_openai_messages",
14
+ "from_anthropic_messages",
15
+ "langchain_handler",
16
+ "record_tool_end",
17
+ ]
@@ -0,0 +1,84 @@
1
+ """Ingest an Anthropic / Claude Agent SDK conversation into a Ledger.
2
+
3
+ Works with the Anthropic Messages API conversation shape and the Claude Agent
4
+ SDK's message stream — both express tool use as content blocks: an assistant
5
+ `tool_use` block names the tool, and a later user `tool_result` block carries what
6
+ the tool returned (the observation Receipts treats as evidence).
7
+
8
+ Blocks may be plain dicts (raw API / JSON transcripts) or SDK objects (attribute
9
+ access); this adapter handles both. Pure Python, no SDK required to run.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Iterable
15
+
16
+ from ..ledger import Ledger
17
+ from .openai_trace import _infer_kind
18
+
19
+
20
+ def _get(obj, key, default=None):
21
+ if isinstance(obj, dict):
22
+ return obj.get(key, default)
23
+ return getattr(obj, key, default)
24
+
25
+
26
+ def _content_blocks(message) -> list:
27
+ content = _get(message, "content", [])
28
+ if isinstance(content, str):
29
+ return [] # plain-text turn, no tool blocks
30
+ return list(content or [])
31
+
32
+
33
+ def _result_text(content) -> str:
34
+ """tool_result content is a string or a list of {type: text, text: ...} blocks."""
35
+ if isinstance(content, str):
36
+ return content
37
+ if isinstance(content, list):
38
+ parts = []
39
+ for b in content:
40
+ if _get(b, "type") == "text" or _get(b, "text") is not None:
41
+ parts.append(str(_get(b, "text", "")))
42
+ else:
43
+ parts.append(str(b))
44
+ return "\n".join(p for p in parts if p)
45
+ return "" if content is None else str(content)
46
+
47
+
48
+ def from_anthropic_messages(
49
+ ledger: Ledger,
50
+ messages: Iterable,
51
+ *,
52
+ name_to_kind=None,
53
+ ) -> Ledger:
54
+ """Record every `tool_result` block in an Anthropic-style conversation.
55
+
56
+ `messages` is the list of message dicts/objects (Messages API format, or the
57
+ Claude Agent SDK message stream). Returns the same ledger for chaining.
58
+ """
59
+ messages = list(messages)
60
+
61
+ # First pass: map tool_use ids to their tool names (from assistant turns).
62
+ id_to_name: dict[str, str] = {}
63
+ for m in messages:
64
+ for block in _content_blocks(m):
65
+ if _get(block, "type") == "tool_use":
66
+ bid = _get(block, "id")
67
+ name = _get(block, "name")
68
+ if bid and name:
69
+ id_to_name[bid] = name
70
+
71
+ # Second pass: record tool_result blocks as evidence.
72
+ for m in messages:
73
+ for block in _content_blocks(m):
74
+ if _get(block, "type") != "tool_result":
75
+ continue
76
+ name = id_to_name.get(_get(block, "tool_use_id", ""), "tool")
77
+ kind = None
78
+ if name_to_kind is not None:
79
+ kind = name_to_kind(name)
80
+ kind = kind or _infer_kind(name)
81
+ text = _result_text(_get(block, "content", ""))
82
+ err = bool(_get(block, "is_error", False))
83
+ ledger.record(kind, name, text, is_error=err)
84
+ return ledger
@@ -0,0 +1,54 @@
1
+ """LangChain / LangGraph capture: record tool outputs to the Ledger live.
2
+
3
+ A LangChain callback handler that writes each tool result into the Ledger as it
4
+ happens, so an existing LangChain agent gains evidence capture without changing
5
+ its tool code. `langchain_handler(ledger)` returns a ready handler — pass it in
6
+ `config={"callbacks": [...]}` (or to the agent executor).
7
+
8
+ The recording logic lives in the plain `record_tool_end` function so it's
9
+ testable without langchain installed; the handler is a thin wrapper around it and
10
+ lazy-imports `langchain_core` only when you actually build one.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from ..ledger import Ledger
16
+ from .openai_trace import _infer_kind, _stringify
17
+
18
+
19
+ def record_tool_end(ledger: Ledger, name: str, output, *, name_to_kind=None):
20
+ """Record one finished tool call as evidence. Returns the Evidence record."""
21
+ kind = None
22
+ if name_to_kind is not None:
23
+ kind = name_to_kind(name)
24
+ kind = kind or _infer_kind(name or "")
25
+ return ledger.record(kind, name or "tool", _stringify(output))
26
+
27
+
28
+ def langchain_handler(ledger: Ledger, *, name_to_kind=None):
29
+ """Build a LangChain `BaseCallbackHandler` that captures tool results.
30
+
31
+ Usage:
32
+ handler = langchain_handler(ledger)
33
+ agent.invoke(inputs, config={"callbacks": [handler]})
34
+ """
35
+ try:
36
+ from langchain_core.callbacks import BaseCallbackHandler
37
+ except ImportError as e: # pragma: no cover - env-dependent
38
+ raise ImportError(
39
+ "langchain_handler needs langchain-core: pip install langchain-core"
40
+ ) from e
41
+
42
+ class ReceiptsCallbackHandler(BaseCallbackHandler):
43
+ def __init__(self) -> None:
44
+ super().__init__()
45
+ self._names: dict = {}
46
+
47
+ def on_tool_start(self, serialized, input_str, *, run_id=None, **kwargs):
48
+ self._names[run_id] = (serialized or {}).get("name")
49
+
50
+ def on_tool_end(self, output, *, run_id=None, **kwargs):
51
+ name = self._names.pop(run_id, None) or kwargs.get("name") or "tool"
52
+ record_tool_end(ledger, name, output, name_to_kind=name_to_kind)
53
+
54
+ return ReceiptsCallbackHandler()
@@ -0,0 +1,87 @@
1
+ """Ingest an OpenAI-style chat message trace into a Ledger.
2
+
3
+ The OpenAI chat-completions message format (`role`/`content`/`tool_calls`/
4
+ `tool_call_id`) is a de-facto standard many agent frameworks emit or can export.
5
+ This adapter walks such a transcript and records each *tool result* as evidence —
6
+ those are the observations the agent actually made, the ground truth Receipts
7
+ checks claims against. Pure-python; no SDK required.
8
+
9
+ A tool result is a message with `role == "tool"`. We map its tool name to an
10
+ evidence kind via `name_to_kind` (extend as needed); unknown names fall back to
11
+ the generic TOOL_CALL kind.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Iterable
17
+
18
+ from ..ledger import Ledger
19
+ from ..models import EvidenceKind
20
+
21
+ # Map common tool-name substrings to evidence kinds. First match wins.
22
+ _DEFAULT_KIND_MAP: list[tuple[str, EvidenceKind]] = [
23
+ ("search", EvidenceKind.WEB_SEARCH),
24
+ ("fetch", EvidenceKind.WEB_FETCH),
25
+ ("browse", EvidenceKind.WEB_FETCH),
26
+ ("read", EvidenceKind.FILE_READ),
27
+ ("file", EvidenceKind.FILE_READ),
28
+ ("cat", EvidenceKind.FILE_READ),
29
+ ("test", EvidenceKind.TEST_RUN),
30
+ ("pytest", EvidenceKind.TEST_RUN),
31
+ ("bash", EvidenceKind.COMMAND),
32
+ ("shell", EvidenceKind.COMMAND),
33
+ ("exec", EvidenceKind.COMMAND),
34
+ ("run", EvidenceKind.COMMAND),
35
+ ]
36
+
37
+
38
+ def _infer_kind(name: str) -> EvidenceKind:
39
+ low = (name or "").lower()
40
+ for needle, kind in _DEFAULT_KIND_MAP:
41
+ if needle in low:
42
+ return kind
43
+ return EvidenceKind.TOOL_CALL
44
+
45
+
46
+ def from_openai_messages(
47
+ ledger: Ledger,
48
+ messages: Iterable[dict],
49
+ *,
50
+ name_to_kind=None,
51
+ ) -> Ledger:
52
+ """Record every tool-result message in an OpenAI-style trace as evidence.
53
+
54
+ `name_to_kind` optionally overrides the inferred kind: a callable taking the
55
+ tool name and returning an `EvidenceKind` (or None to fall back to inference).
56
+ Returns the same ledger for chaining.
57
+ """
58
+ # Resolve tool names: a tool message carries tool_call_id; the name lives on
59
+ # the assistant message's tool_calls. Build that lookup first.
60
+ id_to_name: dict[str, str] = {}
61
+ for m in messages if isinstance(messages, list) else (messages := list(messages)):
62
+ for call in m.get("tool_calls") or []:
63
+ cid = call.get("id")
64
+ fn = (call.get("function") or {}).get("name") or call.get("name")
65
+ if cid and fn:
66
+ id_to_name[cid] = fn
67
+
68
+ for m in messages:
69
+ if m.get("role") != "tool":
70
+ continue
71
+ name = m.get("name") or id_to_name.get(m.get("tool_call_id", ""), "tool")
72
+ kind = None
73
+ if name_to_kind is not None:
74
+ kind = name_to_kind(name)
75
+ kind = kind or _infer_kind(name)
76
+ ledger.record(kind, name, _stringify(m.get("content", "")))
77
+ return ledger
78
+
79
+
80
+ def _stringify(value) -> str:
81
+ if isinstance(value, str):
82
+ return value
83
+ if isinstance(value, list):
84
+ # OpenAI content parts: join any text parts.
85
+ parts = [p.get("text", "") if isinstance(p, dict) else str(p) for p in value]
86
+ return "\n".join(p for p in parts if p)
87
+ return repr(value)
receipts/cli.py ADDED
@@ -0,0 +1,100 @@
1
+ """Command-line auditor: `receipts audit trace.json`.
2
+
3
+ Exits non-zero when the answer fails the grounding gate, so it drops straight
4
+ into CI as a gate on agent-produced output. Deterministic by default (no network);
5
+ pass --llm to use the LLM support verifier.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+
14
+ from .gate import audit
15
+ from .models import ClaimKind, EvidenceKind
16
+ from .trace import load_trace
17
+ from .verify import HeuristicVerifier
18
+
19
+ _KINDS_HELP = (
20
+ f"valid evidence kinds: {', '.join(k.value for k in EvidenceKind)}; "
21
+ f"valid claim kinds: {', '.join(k.value for k in ClaimKind)}"
22
+ )
23
+
24
+
25
+ def _fail(args, message: str) -> int:
26
+ """Uniform CLI error: a JSON object under --json, plain text on stderr otherwise."""
27
+ if getattr(args, "json", False):
28
+ print(json.dumps({"ok": False, "error": message}, indent=2))
29
+ else:
30
+ print(f"error: {message}", file=sys.stderr)
31
+ return 2
32
+
33
+
34
+ def _cmd_audit(args) -> int:
35
+ try:
36
+ with open(args.trace, encoding="utf-8") as f:
37
+ data = json.load(f)
38
+ except OSError as e:
39
+ return _fail(args, f"cannot read trace file: {e}")
40
+ except json.JSONDecodeError as e:
41
+ return _fail(args, f"{args.trace} is not valid JSON: {e}")
42
+
43
+ if not isinstance(data, dict):
44
+ return _fail(args, "trace root must be a JSON object with 'evidence' and 'answer'")
45
+
46
+ try:
47
+ ledger, answer = load_trace(data)
48
+ except (KeyError, TypeError, ValueError) as e:
49
+ return _fail(args, f"malformed trace: {e} ({_KINDS_HELP})")
50
+
51
+ verifier = HeuristicVerifier()
52
+ if args.llm:
53
+ from .llm import AnthropicLLM
54
+ from .verify import LLMSupportVerifier
55
+
56
+ verifier = LLMSupportVerifier(AnthropicLLM())
57
+
58
+ verdict = audit(answer, ledger, verifier=verifier)
59
+
60
+ if args.json:
61
+ out = {
62
+ "ok": verdict.ok,
63
+ "claimed_effort": verdict.claimed_effort,
64
+ "logged_evidence": verdict.logged_evidence,
65
+ "failures": [
66
+ {"claim": r.claim.text, "reasons": r.failures}
67
+ for r in verdict.failures
68
+ ],
69
+ "assumptions": [a.text for a in verdict.assumptions],
70
+ }
71
+ print(json.dumps(out, indent=2))
72
+ else:
73
+ print(verdict.report())
74
+
75
+ return 0 if verdict.ok else 1
76
+
77
+
78
+ def main(argv=None) -> int:
79
+ parser = argparse.ArgumentParser(
80
+ prog="receipts",
81
+ description="Force AI agents to back every claim with evidence.",
82
+ )
83
+ sub = parser.add_subparsers(dest="command", required=True)
84
+
85
+ p_audit = sub.add_parser("audit", help="Audit a trace file; exit 1 if blocked.")
86
+ p_audit.add_argument("trace", help="Path to a trace JSON file.")
87
+ p_audit.add_argument("--json", action="store_true", help="Emit JSON instead of a report.")
88
+ p_audit.add_argument(
89
+ "--llm",
90
+ action="store_true",
91
+ help="Use the LLM support verifier (needs anthropic + ANTHROPIC_API_KEY).",
92
+ )
93
+ p_audit.set_defaults(func=_cmd_audit)
94
+
95
+ args = parser.parse_args(argv)
96
+ return args.func(args)
97
+
98
+
99
+ if __name__ == "__main__":
100
+ sys.exit(main())
receipts/errors.py ADDED
@@ -0,0 +1,18 @@
1
+ """Exceptions raised by the hard gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ReceiptsError(Exception):
7
+ """Base class."""
8
+
9
+
10
+ class UngroundedAnswerError(ReceiptsError):
11
+ """Raised by Gate.finalize() when an Answer fails verification.
12
+
13
+ Carries the full Verdict so callers can show the agent exactly what to fix.
14
+ """
15
+
16
+ def __init__(self, verdict) -> None: # verdict: Verdict
17
+ self.verdict = verdict
18
+ super().__init__(verdict.report())
receipts/extract.py ADDED
@@ -0,0 +1,100 @@
1
+ """Free-text claim extraction.
2
+
3
+ The adoption unlock: an agent writes a normal prose answer, and Receipts turns it
4
+ into a structured `Answer` (atomic claims, each bound to evidence ids, plus
5
+ declared assumptions) which the gate can then check. The agent doesn't have to
6
+ emit structured claims by hand.
7
+
8
+ Extraction is LLM-driven and therefore advisory — the *checking* of the extracted
9
+ claims stays deterministic in the gate, so a sloppy extraction surfaces as gate
10
+ failures rather than silently passing. The extractor is told the evidence ids and
11
+ their content so it can bind claims; the gate independently re-verifies every
12
+ binding it produces.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .ledger import Ledger
18
+ from .models import Answer, Assumption, Claim, ClaimKind
19
+
20
+ _SCHEMA = {
21
+ "type": "object",
22
+ "properties": {
23
+ "claims": {
24
+ "type": "array",
25
+ "items": {
26
+ "type": "object",
27
+ "properties": {
28
+ "text": {"type": "string"},
29
+ "kind": {"type": "string", "enum": ["fact", "effort", "conclusion"]},
30
+ "evidence_ids": {"type": "array", "items": {"type": "string"}},
31
+ },
32
+ "required": ["text", "kind", "evidence_ids"],
33
+ "additionalProperties": False,
34
+ },
35
+ },
36
+ "assumptions": {
37
+ "type": "array",
38
+ "items": {
39
+ "type": "object",
40
+ "properties": {
41
+ "text": {"type": "string"},
42
+ "reason": {"type": "string"},
43
+ "impact": {"type": "string"},
44
+ },
45
+ "required": ["text", "reason", "impact"],
46
+ "additionalProperties": False,
47
+ },
48
+ },
49
+ },
50
+ "required": ["claims", "assumptions"],
51
+ "additionalProperties": False,
52
+ }
53
+
54
+ _SYSTEM = (
55
+ "You convert an AI agent's free-text answer into structured, checkable claims.\n"
56
+ "Rules:\n"
57
+ "1. Break the answer into atomic claims — one verifiable assertion each.\n"
58
+ "2. Classify each: 'fact' (a stated fact), 'effort' (a claim about work the "
59
+ "agent did, e.g. 'I searched the docs'), or 'conclusion' (an inference).\n"
60
+ "3. For each claim, cite the evidence ids (from the EVIDENCE list) that back "
61
+ "it. If nothing in the evidence supports a claim, cite NOTHING for it — do "
62
+ "not invent ids.\n"
63
+ "4. If the answer relies on something not backed by evidence, record it as an "
64
+ "assumption with a reason and the impact if it's wrong, instead of forcing it "
65
+ "into a claim.\n"
66
+ "Only use evidence ids that appear in the EVIDENCE list verbatim."
67
+ )
68
+
69
+
70
+ def extract_claims(text: str, ledger: Ledger, llm) -> Answer:
71
+ """Turn a free-text answer into a structured `Answer` ready for the gate.
72
+
73
+ `llm` is a `receipts.llm.LLM`. The returned Answer keeps `text` as its summary;
74
+ run it through `Gate.finalize()` / `audit()` to actually check the bindings.
75
+ """
76
+ ev_list = "\n".join(
77
+ f"- {e.id} | {e.kind.value} | {e.source}\n {_snippet(e.content)}"
78
+ for e in ledger.all()
79
+ )
80
+ user = f"EVIDENCE:\n{ev_list or '(none)'}\n\nANSWER:\n{text}"
81
+ result = llm.complete_json(_SYSTEM, user, _SCHEMA)
82
+
83
+ claims = [
84
+ Claim(
85
+ text=c["text"],
86
+ kind=ClaimKind(c.get("kind", "fact")),
87
+ evidence_ids=list(c.get("evidence_ids", [])),
88
+ )
89
+ for c in result.get("claims", [])
90
+ ]
91
+ assumptions = [
92
+ Assumption(text=a["text"], reason=a.get("reason", ""), impact=a.get("impact", ""))
93
+ for a in result.get("assumptions", [])
94
+ ]
95
+ return Answer(claims=claims, assumptions=assumptions, summary=text)
96
+
97
+
98
+ def _snippet(content: str, limit: int = 300) -> str:
99
+ content = " ".join(content.split())
100
+ return content if len(content) <= limit else content[:limit] + "..."