saidso 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.
saidso/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """saidso — a grounding firewall for action-taking AI agents.
2
+
3
+ Sit between an agent and its consequential tools. Refuse to let the agent
4
+ commit any argument that isn't grounded in what the user actually said — and
5
+ keep a transcript-linked audit trail for every action that does run.
6
+
7
+ Quick start::
8
+
9
+ from saidso import grounded, Policy, Transcript, call_context, AttestationLog
10
+
11
+ @grounded(name=Policy.SPOKEN, dob=Policy.SPOKEN)
12
+ def register_patient(name, dob): ...
13
+
14
+ tr = Transcript()
15
+ tr.add_user("Hi, this is Maria Gomez.")
16
+ log = AttestationLog()
17
+
18
+ with call_context(tr, ledger=log):
19
+ result = register_patient(name="John Doe", dob="1990-01-01")
20
+ # -> SteerBack(blocked=True): nothing was said about John Doe / that DOB
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from .attestation import Attestation, AttestationLog
26
+ from .context import CallContext, call_context, get_context, set_context, reset_context
27
+ from .grounding import GroundingBlocked, GroundingConfig, grounded
28
+ from .policy import DEFAULT_THRESHOLDS, Policy
29
+ from .result import ArgFinding, GroundingResult, Span, SteerBack
30
+ from .transcript import AGENT, SYSTEM, USER, Transcript, Turn
31
+
32
+ __version__ = "0.1.0"
33
+
34
+ __all__ = [
35
+ "grounded",
36
+ "Policy",
37
+ "Transcript",
38
+ "Turn",
39
+ "USER",
40
+ "AGENT",
41
+ "SYSTEM",
42
+ "call_context",
43
+ "CallContext",
44
+ "get_context",
45
+ "set_context",
46
+ "reset_context",
47
+ "SteerBack",
48
+ "GroundingResult",
49
+ "GroundingConfig",
50
+ "GroundingBlocked",
51
+ "Span",
52
+ "ArgFinding",
53
+ "Attestation",
54
+ "AttestationLog",
55
+ "DEFAULT_THRESHOLDS",
56
+ "__version__",
57
+ ]
saidso/_fuzz.py ADDED
@@ -0,0 +1,49 @@
1
+ """Fuzzy string matching with a zero-dependency fallback.
2
+
3
+ Uses ``rapidfuzz`` when it is installed (fast, C-backed) and transparently
4
+ falls back to the stdlib ``difflib`` so ``saidso`` works with no required
5
+ third-party dependencies.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ try: # pragma: no cover - exercised indirectly
11
+ from rapidfuzz import fuzz as _rf
12
+
13
+ _HAVE_RAPIDFUZZ = True
14
+ except Exception: # pragma: no cover
15
+ _HAVE_RAPIDFUZZ = False
16
+ import difflib
17
+
18
+
19
+ def ratio(a: str, b: str) -> float:
20
+ """Whole-string similarity in ``[0, 1]``."""
21
+ if not a or not b:
22
+ return 0.0
23
+ if _HAVE_RAPIDFUZZ:
24
+ return _rf.ratio(a, b) / 100.0
25
+ return difflib.SequenceMatcher(None, a, b).ratio()
26
+
27
+
28
+ def partial_ratio(needle: str, haystack: str) -> float:
29
+ """How well ``needle`` appears *inside* ``haystack``, in ``[0, 1]``.
30
+
31
+ This is the workhorse for "did the caller roughly say this?" checks.
32
+ """
33
+ if not needle or not haystack:
34
+ return 0.0
35
+ if _HAVE_RAPIDFUZZ:
36
+ return _rf.partial_ratio(needle, haystack) / 100.0
37
+
38
+ # difflib fallback: slide a window the size of ``needle`` across ``haystack``.
39
+ n = len(needle)
40
+ if n >= len(haystack):
41
+ return difflib.SequenceMatcher(None, needle, haystack).ratio()
42
+ best = 0.0
43
+ step = max(1, n // 4)
44
+ for i in range(0, len(haystack) - n + 1, step):
45
+ window = haystack[i : i + n]
46
+ r = difflib.SequenceMatcher(None, needle, window).ratio()
47
+ if r > best:
48
+ best = r
49
+ return best
saidso/attestation.py ADDED
@@ -0,0 +1,74 @@
1
+ """The provenance ledger: proof that every committed argument was grounded."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import List, Optional
10
+
11
+ from .result import ArgFinding
12
+
13
+
14
+ @dataclass
15
+ class Attestation:
16
+ """A receipt: this action ran, and here is what grounded every argument."""
17
+
18
+ action: str
19
+ ts: float
20
+ call_id: Optional[str]
21
+ args: List[ArgFinding] = field(default_factory=list)
22
+
23
+ def to_dict(self) -> dict:
24
+ return {
25
+ "action": self.action,
26
+ "ts": self.ts,
27
+ "call_id": self.call_id,
28
+ "args": [
29
+ {
30
+ "arg": f.name,
31
+ "policy": f.result.policy,
32
+ "value": f.result.value,
33
+ "confidence": round(f.result.confidence, 4),
34
+ "span": f.result.span.to_dict() if f.result.span else None,
35
+ }
36
+ for f in self.args
37
+ ],
38
+ }
39
+
40
+
41
+ class AttestationLog:
42
+ """Collects attestations in memory and (optionally) appends them as JSONL.
43
+
44
+ Pass ``path=`` to persist an audit trail; otherwise records are kept in
45
+ memory and reachable via :attr:`records`.
46
+ """
47
+
48
+ def __init__(self, path: Optional[str] = None) -> None:
49
+ self.path = path
50
+ self._records: List[Attestation] = []
51
+ self._lock = threading.Lock()
52
+
53
+ def record(self, attestation: Attestation) -> Attestation:
54
+ with self._lock:
55
+ self._records.append(attestation)
56
+ if self.path:
57
+ with open(self.path, "a", encoding="utf-8") as fh:
58
+ fh.write(json.dumps(attestation.to_dict()) + "\n")
59
+ return attestation
60
+
61
+ def build(self, action: str, findings: List[ArgFinding], call_id: Optional[str] = None) -> Attestation:
62
+ return self.record(
63
+ Attestation(action=action, ts=time.time(), call_id=call_id, args=list(findings))
64
+ )
65
+
66
+ @property
67
+ def records(self) -> List[Attestation]:
68
+ return list(self._records)
69
+
70
+ def __len__(self) -> int:
71
+ return len(self._records)
72
+
73
+ def export(self) -> List[dict]:
74
+ return [a.to_dict() for a in self._records]
saidso/context.py ADDED
@@ -0,0 +1,74 @@
1
+ """Per-call context: the transcript, metadata, clock and attestation sink.
2
+
3
+ Adapters set this once per call (via :func:`call_context`); the ``@grounded``
4
+ decorator reads it implicitly so action functions stay clean. Values can also
5
+ be passed explicitly to the decorated call via ``_transcript=`` / ``_context=``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextvars
11
+ from contextlib import contextmanager
12
+ from dataclasses import dataclass, field
13
+ from datetime import date
14
+ from typing import Any, Dict, Optional
15
+
16
+ from .transcript import Transcript
17
+
18
+
19
+ @dataclass
20
+ class CallContext:
21
+ """Everything the firewall needs to judge one call."""
22
+
23
+ transcript: Transcript = field(default_factory=Transcript)
24
+ metadata: Dict[str, Any] = field(default_factory=dict)
25
+ now: Optional[date] = None
26
+ call_id: Optional[str] = None
27
+ ledger: Any = None # AttestationLog | None (avoid import cycle)
28
+
29
+
30
+ _CURRENT: contextvars.ContextVar[Optional[CallContext]] = contextvars.ContextVar(
31
+ "saidso_call_context", default=None
32
+ )
33
+
34
+
35
+ def get_context() -> Optional[CallContext]:
36
+ return _CURRENT.get()
37
+
38
+
39
+ def set_context(ctx: Optional[CallContext]) -> contextvars.Token:
40
+ return _CURRENT.set(ctx)
41
+
42
+
43
+ def reset_context(token: contextvars.Token) -> None:
44
+ _CURRENT.reset(token)
45
+
46
+
47
+ @contextmanager
48
+ def call_context(
49
+ transcript: Optional[Transcript] = None,
50
+ *,
51
+ metadata: Optional[Dict[str, Any]] = None,
52
+ now: Optional[date] = None,
53
+ call_id: Optional[str] = None,
54
+ ledger: Any = None,
55
+ ):
56
+ """Scope a :class:`CallContext` for the duration of a call.
57
+
58
+ Example::
59
+
60
+ with call_context(transcript, metadata={"caller_id": "+1..."}, ledger=log):
61
+ await register_patient(...)
62
+ """
63
+ ctx = CallContext(
64
+ transcript=transcript if transcript is not None else Transcript(),
65
+ metadata=metadata or {},
66
+ now=now,
67
+ call_id=call_id,
68
+ ledger=ledger,
69
+ )
70
+ token = _CURRENT.set(ctx)
71
+ try:
72
+ yield ctx
73
+ finally:
74
+ _CURRENT.reset(token)
saidso/grounding.py ADDED
@@ -0,0 +1,198 @@
1
+ """The ``@grounded`` decorator: the firewall around your action tools.
2
+
3
+ Wrap a consequential function, declare a policy per argument, and the call is
4
+ intercepted: every guarded argument is verified against the transcript before
5
+ the body runs. Ungrounded? The body never executes and a :class:`SteerBack` is
6
+ returned so the agent re-asks the caller. Grounded? An attestation is written.
7
+
8
+ Production guarantees:
9
+
10
+ * **Validated at decoration time** — a policy naming a non-existent parameter
11
+ raises immediately, so a typo can never silently leave a real argument
12
+ unguarded.
13
+ * **Fail-closed** — if a grounding check raises unexpectedly, the argument is
14
+ treated as ungrounded (blocked) and the error is logged; a firewall must
15
+ never let a call through because the check crashed.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import functools
21
+ import inspect
22
+ import logging
23
+ from dataclasses import dataclass
24
+ from typing import Any, Callable, Dict, List, Optional, Union
25
+
26
+ from . import matcher
27
+ from .context import CallContext, get_context
28
+ from .policy import DEFAULT_THRESHOLDS, Policy
29
+ from .result import ArgFinding, GroundingResult, SteerBack
30
+
31
+ logger = logging.getLogger("saidso")
32
+
33
+ _OVERRIDE_KEYS = ("_context", "_transcript")
34
+
35
+
36
+ @dataclass
37
+ class GroundingConfig:
38
+ """Tunables for the firewall."""
39
+
40
+ thresholds: Optional[Dict[Policy, float]] = None
41
+ raise_on_block: bool = False # default: return SteerBack (slots into tool loops)
42
+ warn_on_missing_context: bool = True
43
+
44
+ def threshold_for(self, policy: Policy) -> float:
45
+ if self.thresholds and policy in self.thresholds:
46
+ return self.thresholds[policy]
47
+ return DEFAULT_THRESHOLDS[policy]
48
+
49
+
50
+ class GroundingBlocked(Exception):
51
+ """Raised instead of returning a SteerBack when ``raise_on_block=True``."""
52
+
53
+ def __init__(self, steer: SteerBack) -> None:
54
+ super().__init__(steer.message)
55
+ self.steer = steer
56
+
57
+
58
+ def grounded(
59
+ _config: Optional[GroundingConfig] = None,
60
+ **arg_policies: Union[Policy, str],
61
+ ) -> Callable:
62
+ """Decorator factory. Map argument names to :class:`Policy` values.
63
+
64
+ Example::
65
+
66
+ @grounded(name=Policy.SPOKEN, dob=Policy.SPOKEN, phone=Policy.CALLER_ID)
67
+ async def register_patient(name, dob, phone): ...
68
+ """
69
+ config = _config or GroundingConfig()
70
+ if not arg_policies:
71
+ raise ValueError("@grounded requires at least one argument policy")
72
+ policies: Dict[str, Policy] = {}
73
+ for name, value in arg_policies.items():
74
+ try:
75
+ policies[name] = value if isinstance(value, Policy) else Policy(value)
76
+ except ValueError as exc: # unknown policy string
77
+ raise ValueError(
78
+ f"@grounded: unknown policy {value!r} for argument {name!r}"
79
+ ) from exc
80
+
81
+ def decorate(fn: Callable) -> Callable:
82
+ sig = inspect.signature(fn)
83
+ params = sig.parameters
84
+ var_kw_name = next(
85
+ (n for n, p in params.items() if p.kind is inspect.Parameter.VAR_KEYWORD),
86
+ None,
87
+ )
88
+ accepts_var_kw = var_kw_name is not None
89
+ # Validate at decoration time: every guarded name must be a real param.
90
+ if not accepts_var_kw:
91
+ unknown = [n for n in policies if n not in params]
92
+ if unknown:
93
+ raise ValueError(
94
+ f"@grounded on {fn.__name__}{sig}: these guarded arguments are "
95
+ f"not parameters of the function: {unknown}. Check for typos."
96
+ )
97
+ # An override key only collides if the function genuinely declares it.
98
+ strip_keys = [k for k in _OVERRIDE_KEYS if k not in params]
99
+
100
+ def evaluate(args, kwargs):
101
+ override_ctx = kwargs.pop("_context", None) if "_context" in strip_keys else None
102
+ override_tr = kwargs.pop("_transcript", None) if "_transcript" in strip_keys else None
103
+
104
+ ctx = override_ctx or get_context()
105
+ if ctx is None:
106
+ if config.warn_on_missing_context:
107
+ logger.warning(
108
+ "saidso: no call_context active for %s; treating transcript "
109
+ "as empty (all guarded args will block).", fn.__name__,
110
+ )
111
+ ctx = CallContext()
112
+ if override_tr is not None:
113
+ ctx = CallContext(
114
+ transcript=override_tr, metadata=ctx.metadata, now=ctx.now,
115
+ call_id=ctx.call_id, ledger=ctx.ledger,
116
+ )
117
+
118
+ try:
119
+ bound = sig.bind_partial(*args, **kwargs)
120
+ except TypeError:
121
+ # Let the real function raise its own clear TypeError.
122
+ return _Pass(args, kwargs)
123
+ bound.apply_defaults()
124
+
125
+ def resolve(arg_name):
126
+ if arg_name in bound.arguments and arg_name != var_kw_name:
127
+ return bound.arguments[arg_name]
128
+ if var_kw_name and var_kw_name in bound.arguments:
129
+ return bound.arguments[var_kw_name].get(arg_name)
130
+ return None
131
+
132
+ failed: List[ArgFinding] = []
133
+ passed: List[ArgFinding] = []
134
+ for name, policy in policies.items():
135
+ value = resolve(name)
136
+ try:
137
+ result = matcher.check(
138
+ value, policy, ctx.transcript, ctx, config.threshold_for(policy)
139
+ )
140
+ except Exception as exc: # fail closed: never let a crash open the gate
141
+ logger.exception(
142
+ "saidso: grounding check errored for %s.%s; blocking.",
143
+ fn.__name__, name,
144
+ )
145
+ result = GroundingResult(
146
+ grounded=False, confidence=0.0, policy=policy.value,
147
+ value=value, reason=f"grounding check errored: {exc}",
148
+ )
149
+ finding = ArgFinding(name=name, result=result)
150
+ (passed if result.grounded else failed).append(finding)
151
+
152
+ if failed:
153
+ steer = SteerBack(action=fn.__name__, failed=failed, grounded=passed)
154
+ logger.info(
155
+ "saidso blocked %s: ungrounded args %s",
156
+ fn.__name__, [f.name for f in failed],
157
+ )
158
+ return steer
159
+
160
+ if ctx.ledger is not None:
161
+ ctx.ledger.build(fn.__name__, passed, call_id=ctx.call_id)
162
+ return _Pass(args, kwargs)
163
+
164
+ if inspect.iscoroutinefunction(fn):
165
+
166
+ @functools.wraps(fn)
167
+ async def awrapper(*args, **kwargs):
168
+ outcome = evaluate(args, kwargs)
169
+ if isinstance(outcome, SteerBack):
170
+ if config.raise_on_block:
171
+ raise GroundingBlocked(outcome)
172
+ return outcome
173
+ return await fn(*outcome.args, **outcome.kwargs)
174
+
175
+ awrapper.__grounded_policies__ = policies
176
+ return awrapper
177
+
178
+ @functools.wraps(fn)
179
+ def swrapper(*args, **kwargs):
180
+ outcome = evaluate(args, kwargs)
181
+ if isinstance(outcome, SteerBack):
182
+ if config.raise_on_block:
183
+ raise GroundingBlocked(outcome)
184
+ return outcome
185
+ return fn(*outcome.args, **outcome.kwargs)
186
+
187
+ swrapper.__grounded_policies__ = policies
188
+ return swrapper
189
+
190
+ return decorate
191
+
192
+
193
+ @dataclass
194
+ class _Pass:
195
+ """Internal: the (possibly override-stripped) args to forward to the body."""
196
+
197
+ args: tuple
198
+ kwargs: dict