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 +70 -0
- receipts/adapters/__init__.py +17 -0
- receipts/adapters/anthropic_trace.py +84 -0
- receipts/adapters/langchain.py +54 -0
- receipts/adapters/openai_trace.py +87 -0
- receipts/cli.py +100 -0
- receipts/errors.py +18 -0
- receipts/extract.py +100 -0
- receipts/gate.py +196 -0
- receipts/instrument.py +65 -0
- receipts/ledger.py +65 -0
- receipts/llm.py +73 -0
- receipts/models.py +164 -0
- receipts/py.typed +0 -0
- receipts/trace.py +66 -0
- receipts/verify.py +108 -0
- receipts_gate-0.1.0.dist-info/METADATA +220 -0
- receipts_gate-0.1.0.dist-info/RECORD +21 -0
- receipts_gate-0.1.0.dist-info/WHEEL +4 -0
- receipts_gate-0.1.0.dist-info/entry_points.txt +2 -0
- receipts_gate-0.1.0.dist-info/licenses/LICENSE +21 -0
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] + "..."
|