letscomplai 0.1.0__tar.gz
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.
- letscomplai-0.1.0/LICENSE +21 -0
- letscomplai-0.1.0/PKG-INFO +107 -0
- letscomplai-0.1.0/README.md +89 -0
- letscomplai-0.1.0/letscomplai/__init__.py +22 -0
- letscomplai-0.1.0/letscomplai/async_client.py +130 -0
- letscomplai-0.1.0/letscomplai/client.py +131 -0
- letscomplai-0.1.0/letscomplai/errors.py +20 -0
- letscomplai-0.1.0/letscomplai/guard.py +10 -0
- letscomplai-0.1.0/letscomplai/redactor.py +101 -0
- letscomplai-0.1.0/letscomplai/validation.py +7 -0
- letscomplai-0.1.0/letscomplai.egg-info/PKG-INFO +107 -0
- letscomplai-0.1.0/letscomplai.egg-info/SOURCES.txt +19 -0
- letscomplai-0.1.0/letscomplai.egg-info/dependency_links.txt +1 -0
- letscomplai-0.1.0/letscomplai.egg-info/requires.txt +9 -0
- letscomplai-0.1.0/letscomplai.egg-info/top_level.txt +1 -0
- letscomplai-0.1.0/pyproject.toml +28 -0
- letscomplai-0.1.0/setup.cfg +4 -0
- letscomplai-0.1.0/tests/test_async_client.py +240 -0
- letscomplai-0.1.0/tests/test_client.py +261 -0
- letscomplai-0.1.0/tests/test_guard.py +74 -0
- letscomplai-0.1.0/tests/test_redactor.py +140 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AKVentures.ai, LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: letscomplai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for LetsComplai PII/PHI redaction and compliance gateway
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: requests>=2.25.0
|
|
10
|
+
Requires-Dist: httpx>=0.27.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
14
|
+
Requires-Dist: responses>=0.18.0; extra == "dev"
|
|
15
|
+
Requires-Dist: build>=1.2.0; extra == "dev"
|
|
16
|
+
Requires-Dist: twine>=5.0.0; extra == "dev"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# LetsComplai Python SDK
|
|
20
|
+
|
|
21
|
+
Python SDK for the LetsCompl.ai PII/PHI redaction engine and compliance API gateway.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install letscomplai
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
### Local Redaction
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from letscomplai import redact
|
|
35
|
+
|
|
36
|
+
payload = {
|
|
37
|
+
"name": "The patient John Doe was diagnosed.",
|
|
38
|
+
"email": "test@example.com",
|
|
39
|
+
"ssn": "000-12-3456",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
result = redact(payload)
|
|
43
|
+
print(result["redacted"])
|
|
44
|
+
# {
|
|
45
|
+
# "name": "patient [REDACTED_NAME] was diagnosed.",
|
|
46
|
+
# "email": "[REDACTED_EMAIL]",
|
|
47
|
+
# "ssn": "[REDACTED_SSN]"
|
|
48
|
+
# }
|
|
49
|
+
print(result["hits"]) # ['SSN', 'EMAIL', 'NAME']
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Remote Evaluation
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from letscomplai import LetsComplaiClient
|
|
56
|
+
|
|
57
|
+
client = LetsComplaiClient(api_key="your-api-key")
|
|
58
|
+
|
|
59
|
+
result = client.evaluate({
|
|
60
|
+
"patient_notes": "Mr. John Smith has a history of asthma.",
|
|
61
|
+
})
|
|
62
|
+
print(result)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
By default, `evaluate()` redacts PII/PHI **locally, before the payload is sent to the gateway** — raw sensitive data never leaves your process. Pass `redact=False` as an explicit opt-out to send the raw, unredacted payload:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
client.evaluate(payload, redact=False) # explicit opt-out — sends the raw payload
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`evaluate()` also accepts `rules` (a list of rule keys to restrict evaluation to a subset instead of the full workspace policy) and `pinned_versions` (a dict mapping ruleset key to a historical version number, for audit replay):
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
client.evaluate(payload, rules=["ftc_budget_cap"], pinned_versions={"ftc_budget_cap": 2})
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Passing `rules` makes the response's `policyScope` `"filtered"` instead of `"full"`, with `evaluatedRules` listing exactly which rule keys ran. **A `"filtered"` response is not a complete compliance decision** — it only reflects the requested subset, not the workspace's full active policy. Omit `rules` to evaluate everything.
|
|
78
|
+
|
|
79
|
+
### Guarding an action
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from letscomplai import LetsComplaiClient
|
|
83
|
+
|
|
84
|
+
client = LetsComplaiClient(api_key="your-api-key")
|
|
85
|
+
result = client.guard_action(
|
|
86
|
+
{"action": "payout", "amount": 1200},
|
|
87
|
+
lambda: payments.create_payout(amount=1200),
|
|
88
|
+
)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`guard_action` raises `ComplianceBlockedError` if the verdict is `blocked`, and (by default) `ComplianceUnavailableError` if the gateway is unreachable, before the action callable runs.
|
|
92
|
+
|
|
93
|
+
### Async client
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from letscomplai import AsyncLetsComplaiClient
|
|
97
|
+
|
|
98
|
+
client = AsyncLetsComplaiClient(api_key="your-api-key")
|
|
99
|
+
result = await client.evaluate({"action": "payout", "amount": 1200})
|
|
100
|
+
await client.aclose()
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`AsyncLetsComplaiClient` mirrors `LetsComplaiClient`'s `evaluate`/`guard_action`/`redact_local` methods for asyncio applications.
|
|
104
|
+
|
|
105
|
+
> LetsCompl.ai is a technical enforcement tool, not a legal compliance service. Verdicts do not constitute legal advice.
|
|
106
|
+
|
|
107
|
+
For timeout, fail-open/fail-closed, retry, SLA, and local-enforcement behavior, see [docs/operations/client-availability.md](https://github.com/akventures-ai/letscomplai/blob/main/docs/operations/client-availability.md).
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# LetsComplai Python SDK
|
|
2
|
+
|
|
3
|
+
Python SDK for the LetsCompl.ai PII/PHI redaction engine and compliance API gateway.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install letscomplai
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Local Redaction
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from letscomplai import redact
|
|
17
|
+
|
|
18
|
+
payload = {
|
|
19
|
+
"name": "The patient John Doe was diagnosed.",
|
|
20
|
+
"email": "test@example.com",
|
|
21
|
+
"ssn": "000-12-3456",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
result = redact(payload)
|
|
25
|
+
print(result["redacted"])
|
|
26
|
+
# {
|
|
27
|
+
# "name": "patient [REDACTED_NAME] was diagnosed.",
|
|
28
|
+
# "email": "[REDACTED_EMAIL]",
|
|
29
|
+
# "ssn": "[REDACTED_SSN]"
|
|
30
|
+
# }
|
|
31
|
+
print(result["hits"]) # ['SSN', 'EMAIL', 'NAME']
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Remote Evaluation
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from letscomplai import LetsComplaiClient
|
|
38
|
+
|
|
39
|
+
client = LetsComplaiClient(api_key="your-api-key")
|
|
40
|
+
|
|
41
|
+
result = client.evaluate({
|
|
42
|
+
"patient_notes": "Mr. John Smith has a history of asthma.",
|
|
43
|
+
})
|
|
44
|
+
print(result)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
By default, `evaluate()` redacts PII/PHI **locally, before the payload is sent to the gateway** — raw sensitive data never leaves your process. Pass `redact=False` as an explicit opt-out to send the raw, unredacted payload:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
client.evaluate(payload, redact=False) # explicit opt-out — sends the raw payload
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`evaluate()` also accepts `rules` (a list of rule keys to restrict evaluation to a subset instead of the full workspace policy) and `pinned_versions` (a dict mapping ruleset key to a historical version number, for audit replay):
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
client.evaluate(payload, rules=["ftc_budget_cap"], pinned_versions={"ftc_budget_cap": 2})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Passing `rules` makes the response's `policyScope` `"filtered"` instead of `"full"`, with `evaluatedRules` listing exactly which rule keys ran. **A `"filtered"` response is not a complete compliance decision** — it only reflects the requested subset, not the workspace's full active policy. Omit `rules` to evaluate everything.
|
|
60
|
+
|
|
61
|
+
### Guarding an action
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from letscomplai import LetsComplaiClient
|
|
65
|
+
|
|
66
|
+
client = LetsComplaiClient(api_key="your-api-key")
|
|
67
|
+
result = client.guard_action(
|
|
68
|
+
{"action": "payout", "amount": 1200},
|
|
69
|
+
lambda: payments.create_payout(amount=1200),
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`guard_action` raises `ComplianceBlockedError` if the verdict is `blocked`, and (by default) `ComplianceUnavailableError` if the gateway is unreachable, before the action callable runs.
|
|
74
|
+
|
|
75
|
+
### Async client
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from letscomplai import AsyncLetsComplaiClient
|
|
79
|
+
|
|
80
|
+
client = AsyncLetsComplaiClient(api_key="your-api-key")
|
|
81
|
+
result = await client.evaluate({"action": "payout", "amount": 1200})
|
|
82
|
+
await client.aclose()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`AsyncLetsComplaiClient` mirrors `LetsComplaiClient`'s `evaluate`/`guard_action`/`redact_local` methods for asyncio applications.
|
|
86
|
+
|
|
87
|
+
> LetsCompl.ai is a technical enforcement tool, not a legal compliance service. Verdicts do not constitute legal advice.
|
|
88
|
+
|
|
89
|
+
For timeout, fail-open/fail-closed, retry, SLA, and local-enforcement behavior, see [docs/operations/client-availability.md](https://github.com/akventures-ai/letscomplai/blob/main/docs/operations/client-availability.md).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from letscomplai.client import LetsComplaiClient
|
|
2
|
+
from letscomplai.async_client import AsyncLetsComplaiClient
|
|
3
|
+
from letscomplai.errors import (
|
|
4
|
+
ComplianceBlockedError,
|
|
5
|
+
ComplianceUnavailableError,
|
|
6
|
+
LetsComplaiTransportError,
|
|
7
|
+
)
|
|
8
|
+
from letscomplai.guard import GuardedActionResult
|
|
9
|
+
from letscomplai.redactor import redact, RedactResult
|
|
10
|
+
from letscomplai.validation import require_json_object
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"LetsComplaiClient",
|
|
14
|
+
"AsyncLetsComplaiClient",
|
|
15
|
+
"ComplianceBlockedError",
|
|
16
|
+
"ComplianceUnavailableError",
|
|
17
|
+
"LetsComplaiTransportError",
|
|
18
|
+
"GuardedActionResult",
|
|
19
|
+
"redact",
|
|
20
|
+
"RedactResult",
|
|
21
|
+
"require_json_object",
|
|
22
|
+
]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from letscomplai.errors import (
|
|
6
|
+
ComplianceBlockedError,
|
|
7
|
+
ComplianceUnavailableError,
|
|
8
|
+
LetsComplaiTransportError,
|
|
9
|
+
)
|
|
10
|
+
from letscomplai.guard import GuardedActionResult
|
|
11
|
+
from letscomplai.redactor import redact as redact_fn, RedactResult
|
|
12
|
+
from letscomplai.validation import require_json_object
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AsyncLetsComplaiClient:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
api_key: str,
|
|
19
|
+
base_url: str = "https://api.letscompl.ai",
|
|
20
|
+
timeout_seconds: float = 10.0,
|
|
21
|
+
transport_errors: str = "return",
|
|
22
|
+
):
|
|
23
|
+
if not api_key:
|
|
24
|
+
raise ValueError("LetsComplai API key is required.")
|
|
25
|
+
if timeout_seconds <= 0:
|
|
26
|
+
raise ValueError("timeout_seconds must be positive")
|
|
27
|
+
if transport_errors not in ("return", "raise"):
|
|
28
|
+
raise ValueError('transport_errors must be "return" or "raise"')
|
|
29
|
+
self.api_key = api_key
|
|
30
|
+
self.base_url = base_url.rstrip("/")
|
|
31
|
+
self.timeout_seconds = timeout_seconds
|
|
32
|
+
self.transport_errors = transport_errors
|
|
33
|
+
self._client = httpx.AsyncClient()
|
|
34
|
+
|
|
35
|
+
def redact_local(self, payload: Dict[str, Any]) -> RedactResult:
|
|
36
|
+
return redact_fn(payload)
|
|
37
|
+
|
|
38
|
+
async def aclose(self):
|
|
39
|
+
await self._client.aclose()
|
|
40
|
+
|
|
41
|
+
def _error_result(
|
|
42
|
+
self,
|
|
43
|
+
code: str,
|
|
44
|
+
outgoing_payload: Dict[str, Any],
|
|
45
|
+
redact: bool,
|
|
46
|
+
) -> Dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"verdict": "error",
|
|
49
|
+
"citation": None,
|
|
50
|
+
"redactedPayload": outgoing_payload,
|
|
51
|
+
"latencyMs": 0,
|
|
52
|
+
"llmUsed": False,
|
|
53
|
+
"piiRedacted": redact,
|
|
54
|
+
"evaluations": [],
|
|
55
|
+
"unsupportedRules": [],
|
|
56
|
+
"policyScope": "full",
|
|
57
|
+
"evaluatedRules": [],
|
|
58
|
+
"disclaimer": "",
|
|
59
|
+
"error": code,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async def evaluate(
|
|
63
|
+
self,
|
|
64
|
+
payload: Dict[str, Any],
|
|
65
|
+
rules: Optional[List[str]] = None,
|
|
66
|
+
pinned_versions: Optional[Dict[str, int]] = None,
|
|
67
|
+
redact: bool = True,
|
|
68
|
+
timeout_seconds: Optional[float] = None,
|
|
69
|
+
) -> Dict[str, Any]:
|
|
70
|
+
url = f"{self.base_url}/api/v1/evaluate"
|
|
71
|
+
headers = {
|
|
72
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
73
|
+
"Content-Type": "application/json",
|
|
74
|
+
}
|
|
75
|
+
payload = require_json_object(payload)
|
|
76
|
+
outgoing_payload = redact_fn(payload)["redacted"] if redact else payload
|
|
77
|
+
body: Dict[str, Any] = {"payload": outgoing_payload}
|
|
78
|
+
if rules is not None:
|
|
79
|
+
body["rules"] = rules
|
|
80
|
+
if pinned_versions is not None:
|
|
81
|
+
body["pinnedVersions"] = pinned_versions
|
|
82
|
+
|
|
83
|
+
timeout = timeout_seconds if timeout_seconds is not None else self.timeout_seconds
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
response = await self._client.post(
|
|
87
|
+
url, json=body, headers=headers, timeout=timeout
|
|
88
|
+
)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
if self.transport_errors == "raise":
|
|
91
|
+
raise LetsComplaiTransportError("network_error", str(e))
|
|
92
|
+
return self._error_result("network_error", outgoing_payload, redact)
|
|
93
|
+
|
|
94
|
+
if not response.is_success:
|
|
95
|
+
if self.transport_errors == "raise":
|
|
96
|
+
raise LetsComplaiTransportError(
|
|
97
|
+
"gateway_error", "Gateway request failed.", response.status_code
|
|
98
|
+
)
|
|
99
|
+
return self._error_result("gateway_error", outgoing_payload, redact)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
return response.json()
|
|
103
|
+
except Exception as e:
|
|
104
|
+
if self.transport_errors == "raise":
|
|
105
|
+
raise LetsComplaiTransportError("network_error", str(e))
|
|
106
|
+
return self._error_result("network_error", outgoing_payload, redact)
|
|
107
|
+
|
|
108
|
+
async def guard_action(
|
|
109
|
+
self,
|
|
110
|
+
payload: Dict[str, Any],
|
|
111
|
+
action: Callable[[], Union[Any, Awaitable[Any]]],
|
|
112
|
+
unavailable: str = "deny",
|
|
113
|
+
**evaluate_options: Any,
|
|
114
|
+
) -> GuardedActionResult:
|
|
115
|
+
if unavailable not in ("deny", "allow"):
|
|
116
|
+
raise ValueError('unavailable must be "deny" or "allow"')
|
|
117
|
+
redact = evaluate_options.get("redact", True)
|
|
118
|
+
try:
|
|
119
|
+
evaluation = await self.evaluate(payload, **evaluate_options)
|
|
120
|
+
except LetsComplaiTransportError as exc:
|
|
121
|
+
outgoing_payload = redact_fn(payload)["redacted"] if redact else payload
|
|
122
|
+
evaluation = self._error_result(exc.code, outgoing_payload, redact)
|
|
123
|
+
if evaluation.get("verdict") == "blocked":
|
|
124
|
+
raise ComplianceBlockedError(evaluation)
|
|
125
|
+
if evaluation.get("verdict") == "error" and unavailable == "deny":
|
|
126
|
+
raise ComplianceUnavailableError(evaluation)
|
|
127
|
+
result = action()
|
|
128
|
+
if inspect.isawaitable(result):
|
|
129
|
+
result = await result
|
|
130
|
+
return GuardedActionResult(evaluation=evaluation, value=result)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
2
|
+
import requests
|
|
3
|
+
|
|
4
|
+
from letscomplai.errors import (
|
|
5
|
+
ComplianceBlockedError,
|
|
6
|
+
ComplianceUnavailableError,
|
|
7
|
+
LetsComplaiTransportError,
|
|
8
|
+
)
|
|
9
|
+
from letscomplai.guard import GuardedActionResult
|
|
10
|
+
from letscomplai.redactor import redact as redact_fn, RedactResult
|
|
11
|
+
from letscomplai.validation import require_json_object
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LetsComplaiClient:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
api_key: str,
|
|
18
|
+
base_url: str = "https://api.letscompl.ai",
|
|
19
|
+
timeout_seconds: float = 10.0,
|
|
20
|
+
transport_errors: str = "return",
|
|
21
|
+
):
|
|
22
|
+
if not api_key:
|
|
23
|
+
raise ValueError("LetsComplai API key is required.")
|
|
24
|
+
if timeout_seconds <= 0:
|
|
25
|
+
raise ValueError("timeout_seconds must be positive")
|
|
26
|
+
if transport_errors not in ("return", "raise"):
|
|
27
|
+
raise ValueError('transport_errors must be "return" or "raise"')
|
|
28
|
+
self.api_key = api_key
|
|
29
|
+
self.base_url = base_url.rstrip("/")
|
|
30
|
+
self.timeout_seconds = timeout_seconds
|
|
31
|
+
self.transport_errors = transport_errors
|
|
32
|
+
|
|
33
|
+
def redact_local(self, payload: Dict[str, Any]) -> RedactResult:
|
|
34
|
+
"""
|
|
35
|
+
Sanitizes PII/PHI in a payload locally before making any network requests.
|
|
36
|
+
Useful for keeping sensitive data completely local.
|
|
37
|
+
"""
|
|
38
|
+
return redact_fn(payload)
|
|
39
|
+
|
|
40
|
+
def _error_result(
|
|
41
|
+
self,
|
|
42
|
+
code: str,
|
|
43
|
+
outgoing_payload: Dict[str, Any],
|
|
44
|
+
redact: bool,
|
|
45
|
+
) -> Dict[str, Any]:
|
|
46
|
+
return {
|
|
47
|
+
"verdict": "error",
|
|
48
|
+
"citation": None,
|
|
49
|
+
"redactedPayload": outgoing_payload,
|
|
50
|
+
"latencyMs": 0,
|
|
51
|
+
"llmUsed": False,
|
|
52
|
+
"piiRedacted": redact,
|
|
53
|
+
"evaluations": [],
|
|
54
|
+
"unsupportedRules": [],
|
|
55
|
+
"policyScope": "full",
|
|
56
|
+
"evaluatedRules": [],
|
|
57
|
+
"disclaimer": "",
|
|
58
|
+
"error": code,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
def evaluate(
|
|
62
|
+
self,
|
|
63
|
+
payload: Dict[str, Any],
|
|
64
|
+
rules: Optional[List[str]] = None,
|
|
65
|
+
pinned_versions: Optional[Dict[str, int]] = None,
|
|
66
|
+
redact: bool = True,
|
|
67
|
+
timeout_seconds: Optional[float] = None,
|
|
68
|
+
) -> Dict[str, Any]:
|
|
69
|
+
"""
|
|
70
|
+
Submits a payload to the LetsComplai gateway for compliance checks,
|
|
71
|
+
applying active rulesets and returning the verdict/citations.
|
|
72
|
+
|
|
73
|
+
By default, PII/PHI is redacted locally before transmission. Pass
|
|
74
|
+
redact=False as an explicit sensitive-data opt-out to send the raw payload.
|
|
75
|
+
"""
|
|
76
|
+
url = f"{self.base_url}/api/v1/evaluate"
|
|
77
|
+
headers = {
|
|
78
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
79
|
+
"Content-Type": "application/json",
|
|
80
|
+
}
|
|
81
|
+
payload = require_json_object(payload)
|
|
82
|
+
outgoing_payload = redact_fn(payload)["redacted"] if redact else payload
|
|
83
|
+
body: Dict[str, Any] = {"payload": outgoing_payload}
|
|
84
|
+
if rules is not None:
|
|
85
|
+
body["rules"] = rules
|
|
86
|
+
if pinned_versions is not None:
|
|
87
|
+
body["pinnedVersions"] = pinned_versions
|
|
88
|
+
|
|
89
|
+
timeout = timeout_seconds if timeout_seconds is not None else self.timeout_seconds
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
response = requests.post(url, json=body, headers=headers, timeout=timeout)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
if self.transport_errors == "raise":
|
|
95
|
+
raise LetsComplaiTransportError("network_error", str(e))
|
|
96
|
+
return self._error_result("network_error", outgoing_payload, redact)
|
|
97
|
+
|
|
98
|
+
if not response.ok:
|
|
99
|
+
if self.transport_errors == "raise":
|
|
100
|
+
raise LetsComplaiTransportError(
|
|
101
|
+
"gateway_error", "Gateway request failed.", response.status_code
|
|
102
|
+
)
|
|
103
|
+
return self._error_result("gateway_error", outgoing_payload, redact)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
return response.json()
|
|
107
|
+
except Exception as e:
|
|
108
|
+
if self.transport_errors == "raise":
|
|
109
|
+
raise LetsComplaiTransportError("network_error", str(e))
|
|
110
|
+
return self._error_result("network_error", outgoing_payload, redact)
|
|
111
|
+
|
|
112
|
+
def guard_action(
|
|
113
|
+
self,
|
|
114
|
+
payload: Dict[str, Any],
|
|
115
|
+
action: Callable[[], Any],
|
|
116
|
+
unavailable: str = "deny",
|
|
117
|
+
**evaluate_options: Any,
|
|
118
|
+
) -> GuardedActionResult:
|
|
119
|
+
if unavailable not in ("deny", "allow"):
|
|
120
|
+
raise ValueError('unavailable must be "deny" or "allow"')
|
|
121
|
+
redact = evaluate_options.get("redact", True)
|
|
122
|
+
try:
|
|
123
|
+
evaluation = self.evaluate(payload, **evaluate_options)
|
|
124
|
+
except LetsComplaiTransportError as exc:
|
|
125
|
+
outgoing_payload = redact_fn(payload)["redacted"] if redact else payload
|
|
126
|
+
evaluation = self._error_result(exc.code, outgoing_payload, redact)
|
|
127
|
+
if evaluation.get("verdict") == "blocked":
|
|
128
|
+
raise ComplianceBlockedError(evaluation)
|
|
129
|
+
if evaluation.get("verdict") == "error" and unavailable == "deny":
|
|
130
|
+
raise ComplianceUnavailableError(evaluation)
|
|
131
|
+
return GuardedActionResult(evaluation=evaluation, value=action())
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LetsComplaiTransportError(Exception):
|
|
5
|
+
def __init__(self, code: str, message: str, status: Optional[int] = None):
|
|
6
|
+
super().__init__(message)
|
|
7
|
+
self.code = code
|
|
8
|
+
self.status = status
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ComplianceBlockedError(Exception):
|
|
12
|
+
def __init__(self, evaluation):
|
|
13
|
+
super().__init__((evaluation.get("citation") or {}).get("ruleName", "Action blocked"))
|
|
14
|
+
self.evaluation = evaluation
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ComplianceUnavailableError(Exception):
|
|
18
|
+
def __init__(self, evaluation):
|
|
19
|
+
super().__init__(evaluation.get("error", "Compliance evaluation unavailable"))
|
|
20
|
+
self.evaluation = evaluation
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any, Dict, List, TypedDict, Union
|
|
3
|
+
|
|
4
|
+
from letscomplai.validation import require_json_object
|
|
5
|
+
|
|
6
|
+
# Compiled regex patterns matching those in typescript SDK
|
|
7
|
+
PATTERNS = [
|
|
8
|
+
{
|
|
9
|
+
"name": "SSN",
|
|
10
|
+
"re": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
|
|
11
|
+
"token": "[REDACTED_SSN]",
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "CARD",
|
|
15
|
+
"re": re.compile(r"\b(?:\d[ -]*?){13,16}\b"),
|
|
16
|
+
"token": "[REDACTED_CARD]",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "MRN",
|
|
20
|
+
"re": re.compile(r"\bMRN:?\s*\d{6,10}\b", re.IGNORECASE),
|
|
21
|
+
"token": "[REDACTED_MRN]",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "EMAIL",
|
|
25
|
+
"re": re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"),
|
|
26
|
+
"token": "[REDACTED_EMAIL]",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "PHONE",
|
|
30
|
+
"re": re.compile(r"(\+?\d{1,2}[ .-]?)?\(?\d{3}\)?[ .-]?\d{3}[ .-]?\d{4}\b"),
|
|
31
|
+
"token": "[REDACTED_PHONE]",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "ICD_CODE",
|
|
35
|
+
"re": re.compile(
|
|
36
|
+
r"\b(?:ICD-(?:9|10)(?:-CM)?:?\s*)?[A-Z]\d{2,3}(?:\.\d{1,4})?\b",
|
|
37
|
+
re.IGNORECASE,
|
|
38
|
+
),
|
|
39
|
+
"token": "[REDACTED_ICD]",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "CLINICAL_TERM",
|
|
43
|
+
"re": re.compile(
|
|
44
|
+
r"\b(cancer|diabetes|hypertension|asthma|depression|anxiety|leukemia|arthritis|dementia|covid(?:-19)?|influenza|schizophrenia|bipolar|stroke|hiv|aids)\b",
|
|
45
|
+
re.IGNORECASE,
|
|
46
|
+
),
|
|
47
|
+
"token": "[REDACTED_CLINICAL]",
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
NAME_PATTERN = re.compile(
|
|
52
|
+
r"\b(patient|Mr\.?|Ms\.?|Mrs\.?|Dr\.?)\s+[A-Z][a-z]+\s+[A-Z][a-z]+\b"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RedactResult(TypedDict):
|
|
57
|
+
redacted: Dict[str, Any]
|
|
58
|
+
hits: List[str]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def scrub_string(s: str, hits: List[str]) -> str:
|
|
62
|
+
out = s
|
|
63
|
+
# CARD before PHONE to avoid the phone regex eating card digits
|
|
64
|
+
for p in PATTERNS:
|
|
65
|
+
|
|
66
|
+
def repl(match: re.Match) -> str:
|
|
67
|
+
hits.append(p["name"])
|
|
68
|
+
return p["token"]
|
|
69
|
+
|
|
70
|
+
out = p["re"].sub(repl, out)
|
|
71
|
+
|
|
72
|
+
def repl_name(match: re.Match) -> str:
|
|
73
|
+
hits.append("NAME")
|
|
74
|
+
prefix = match.group(1)
|
|
75
|
+
return f"{prefix} [REDACTED_NAME]"
|
|
76
|
+
|
|
77
|
+
out = NAME_PATTERN.sub(repl_name, out)
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def walk(v: Any, hits: List[str]) -> Any:
|
|
82
|
+
if isinstance(v, str):
|
|
83
|
+
return scrub_string(v, hits)
|
|
84
|
+
elif isinstance(v, list):
|
|
85
|
+
return [walk(x, hits) for x in v]
|
|
86
|
+
elif isinstance(v, dict):
|
|
87
|
+
return {k: walk(val, hits) for k, val in v.items()}
|
|
88
|
+
return v
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def redact(payload: Dict[str, Any]) -> RedactResult:
|
|
92
|
+
"""
|
|
93
|
+
Scrubs PII/PHI in a JSON payload dict locally.
|
|
94
|
+
Returns a dict with:
|
|
95
|
+
- 'redacted': the modified payload
|
|
96
|
+
- 'hits': list of names of patterns that matched (e.g. 'SSN', 'NAME')
|
|
97
|
+
"""
|
|
98
|
+
payload = require_json_object(payload)
|
|
99
|
+
hits: List[str] = []
|
|
100
|
+
redacted = walk(payload, hits)
|
|
101
|
+
return {"redacted": redacted, "hits": hits}
|