python-rerouting-library 0.2.1__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.
- python_rerouting_library/__init__.py +25 -0
- python_rerouting_library/backends/__init__.py +30 -0
- python_rerouting_library/backends/cloud.py +58 -0
- python_rerouting_library/backends/local_llama.py +74 -0
- python_rerouting_library/config.py +128 -0
- python_rerouting_library/dispatcher.py +200 -0
- python_rerouting_library/exceptions.py +18 -0
- python_rerouting_library/privacy.py +171 -0
- python_rerouting_library/router.py +227 -0
- python_rerouting_library/training.py +177 -0
- python_rerouting_library-0.2.1.dist-info/METADATA +624 -0
- python_rerouting_library-0.2.1.dist-info/RECORD +15 -0
- python_rerouting_library-0.2.1.dist-info/WHEEL +5 -0
- python_rerouting_library-0.2.1.dist-info/licenses/LICENSE +21 -0
- python_rerouting_library-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .dispatcher import (
|
|
2
|
+
Dispatcher,
|
|
3
|
+
DispatchResult,
|
|
4
|
+
)
|
|
5
|
+
from .privacy import (
|
|
6
|
+
PrivacyDecision,
|
|
7
|
+
PrivacyDetector,
|
|
8
|
+
)
|
|
9
|
+
from .router import (
|
|
10
|
+
RouteDecision,
|
|
11
|
+
Router,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Router",
|
|
17
|
+
"RouteDecision",
|
|
18
|
+
"Dispatcher",
|
|
19
|
+
"DispatchResult",
|
|
20
|
+
"PrivacyDetector",
|
|
21
|
+
"PrivacyDecision",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
__version__ = "0.2.1"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from .cloud import CloudBackend
|
|
8
|
+
from .local_llama import LocalLlamaBackend
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"LocalLlamaBackend",
|
|
13
|
+
"CloudBackend",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def __getattr__(name: str) -> Any:
|
|
18
|
+
if name == "CloudBackend":
|
|
19
|
+
from .cloud import CloudBackend
|
|
20
|
+
|
|
21
|
+
return CloudBackend
|
|
22
|
+
|
|
23
|
+
if name == "LocalLlamaBackend":
|
|
24
|
+
from .local_llama import LocalLlamaBackend
|
|
25
|
+
|
|
26
|
+
return LocalLlamaBackend
|
|
27
|
+
|
|
28
|
+
raise AttributeError(
|
|
29
|
+
f"module {__name__!r} has no attribute {name!r}"
|
|
30
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from openai import OpenAI
|
|
4
|
+
|
|
5
|
+
from python_rerouting_library.exceptions import CloudBackendError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CloudBackend:
|
|
9
|
+
name = "cloud-api"
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
*,
|
|
14
|
+
base_url: str,
|
|
15
|
+
model: str,
|
|
16
|
+
api_key: str,
|
|
17
|
+
max_tokens: int = 1024,
|
|
18
|
+
temperature: float = 0.0,
|
|
19
|
+
) -> None:
|
|
20
|
+
self.client = OpenAI(
|
|
21
|
+
base_url=base_url,
|
|
22
|
+
api_key=api_key,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
self.model = model
|
|
26
|
+
self.max_tokens = max_tokens
|
|
27
|
+
self.temperature = temperature
|
|
28
|
+
|
|
29
|
+
def generate(self, query: str) -> str:
|
|
30
|
+
try:
|
|
31
|
+
response = self.client.chat.completions.create(
|
|
32
|
+
model=self.model,
|
|
33
|
+
messages=[
|
|
34
|
+
{
|
|
35
|
+
"role": "user",
|
|
36
|
+
"content": query,
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
temperature=self.temperature,
|
|
40
|
+
max_completion_tokens=self.max_tokens,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
content = response.choices[0].message.content
|
|
44
|
+
|
|
45
|
+
if not content:
|
|
46
|
+
raise CloudBackendError(
|
|
47
|
+
"Cloud provider returned no text."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return content
|
|
51
|
+
|
|
52
|
+
except CloudBackendError:
|
|
53
|
+
raise
|
|
54
|
+
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
raise CloudBackendError(
|
|
57
|
+
"Cloud API request failed."
|
|
58
|
+
) from exc
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from llama_cpp import Llama
|
|
6
|
+
|
|
7
|
+
from python_rerouting_library.exceptions import LocalBackendError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LocalLlamaBackend:
|
|
11
|
+
name = "local-llama"
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
*,
|
|
16
|
+
model_path: str | Path,
|
|
17
|
+
n_ctx: int = 4096,
|
|
18
|
+
n_threads: int | None = None,
|
|
19
|
+
max_tokens: int = 512,
|
|
20
|
+
temperature: float = 0.0,
|
|
21
|
+
) -> None:
|
|
22
|
+
self.model_path = Path(model_path)
|
|
23
|
+
|
|
24
|
+
if not self.model_path.is_file():
|
|
25
|
+
raise FileNotFoundError(
|
|
26
|
+
f"Local Llama model was not found: {self.model_path}"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
self.max_tokens = max_tokens
|
|
30
|
+
self.temperature = temperature
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
self._llm = Llama(
|
|
34
|
+
model_path=str(self.model_path),
|
|
35
|
+
n_ctx=n_ctx,
|
|
36
|
+
n_threads=n_threads,
|
|
37
|
+
verbose=False,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
raise LocalBackendError(
|
|
42
|
+
"Failed to load the local Llama model."
|
|
43
|
+
) from exc
|
|
44
|
+
|
|
45
|
+
def generate(self, query: str) -> str:
|
|
46
|
+
try:
|
|
47
|
+
result = self._llm.create_chat_completion(
|
|
48
|
+
messages=[
|
|
49
|
+
{
|
|
50
|
+
"role": "user",
|
|
51
|
+
"content": query,
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
max_tokens=self.max_tokens,
|
|
55
|
+
temperature=self.temperature,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
content = result["choices"][0]["message"]["content"]
|
|
59
|
+
|
|
60
|
+
if not content:
|
|
61
|
+
raise LocalBackendError(
|
|
62
|
+
"Local Llama returned no text."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return content
|
|
66
|
+
|
|
67
|
+
except LocalBackendError:
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
raise LocalBackendError(
|
|
72
|
+
"Local Llama generation failed."
|
|
73
|
+
) from exc
|
|
74
|
+
return result["choices"][0]["message"]["content"]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Settings:
|
|
10
|
+
router_classifier_path: Path
|
|
11
|
+
llama_model_path: Path
|
|
12
|
+
|
|
13
|
+
cloud_api_key: str
|
|
14
|
+
cloud_base_url: str
|
|
15
|
+
cloud_model: str
|
|
16
|
+
|
|
17
|
+
local_max_tokens: int = 128
|
|
18
|
+
cloud_max_tokens: int = 256
|
|
19
|
+
|
|
20
|
+
router_simple_threshold: float = 0.40
|
|
21
|
+
router_complex_threshold: float = 0.60
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_env(cls) -> "Settings":
|
|
25
|
+
llama_model_path = os.getenv(
|
|
26
|
+
"LLAMA_MODEL_PATH"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
cloud_api_key = os.getenv(
|
|
30
|
+
"CLOUD_API_KEY"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
router_classifier_path = os.getenv(
|
|
34
|
+
"ROUTER_CLASSIFIER_PATH"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if not llama_model_path:
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
"LLAMA_MODEL_PATH environment "
|
|
40
|
+
"variable is not set."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if not cloud_api_key:
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
"CLOUD_API_KEY environment "
|
|
46
|
+
"variable is not set."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if not router_classifier_path:
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
"ROUTER_CLASSIFIER_PATH environment "
|
|
52
|
+
"variable is not set. "
|
|
53
|
+
"python-rerouting-library does not "
|
|
54
|
+
"ship with a default complexity "
|
|
55
|
+
"classifier. Train a classifier with "
|
|
56
|
+
"'python -m "
|
|
57
|
+
"python_rerouting_library.training' "
|
|
58
|
+
"or provide your own compatible "
|
|
59
|
+
"classifier artifact, then set "
|
|
60
|
+
"ROUTER_CLASSIFIER_PATH to that file."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
llama_path = Path(
|
|
64
|
+
llama_model_path
|
|
65
|
+
).expanduser()
|
|
66
|
+
|
|
67
|
+
if not llama_path.is_file():
|
|
68
|
+
raise FileNotFoundError(
|
|
69
|
+
"Local Llama model was not found: "
|
|
70
|
+
f"{llama_path}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
router_path = Path(
|
|
74
|
+
router_classifier_path
|
|
75
|
+
).expanduser()
|
|
76
|
+
|
|
77
|
+
if not router_path.is_file():
|
|
78
|
+
raise FileNotFoundError(
|
|
79
|
+
"Router classifier was not found: "
|
|
80
|
+
f"{router_path}. "
|
|
81
|
+
"python-rerouting-library does not "
|
|
82
|
+
"bundle a default classifier. "
|
|
83
|
+
"Train one or provide a compatible "
|
|
84
|
+
"classifier artifact and set "
|
|
85
|
+
"ROUTER_CLASSIFIER_PATH to its path."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
cloud_base_url = os.getenv(
|
|
89
|
+
"CLOUD_BASE_URL",
|
|
90
|
+
"https://api.openai.com/v1",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
cloud_model = os.getenv(
|
|
94
|
+
"CLOUD_MODEL",
|
|
95
|
+
"gpt-5.4-nano",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return cls(
|
|
99
|
+
router_classifier_path=router_path,
|
|
100
|
+
llama_model_path=llama_path,
|
|
101
|
+
cloud_api_key=cloud_api_key,
|
|
102
|
+
cloud_base_url=cloud_base_url,
|
|
103
|
+
cloud_model=cloud_model,
|
|
104
|
+
local_max_tokens=int(
|
|
105
|
+
os.getenv(
|
|
106
|
+
"LOCAL_MAX_TOKENS",
|
|
107
|
+
"128",
|
|
108
|
+
)
|
|
109
|
+
),
|
|
110
|
+
cloud_max_tokens=int(
|
|
111
|
+
os.getenv(
|
|
112
|
+
"CLOUD_MAX_TOKENS",
|
|
113
|
+
"256",
|
|
114
|
+
)
|
|
115
|
+
),
|
|
116
|
+
router_simple_threshold=float(
|
|
117
|
+
os.getenv(
|
|
118
|
+
"ROUTER_SIMPLE_THRESHOLD",
|
|
119
|
+
"0.40",
|
|
120
|
+
)
|
|
121
|
+
),
|
|
122
|
+
router_complex_threshold=float(
|
|
123
|
+
os.getenv(
|
|
124
|
+
"ROUTER_COMPLEX_THRESHOLD",
|
|
125
|
+
"0.60",
|
|
126
|
+
)
|
|
127
|
+
),
|
|
128
|
+
)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from time import perf_counter
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from .exceptions import (
|
|
8
|
+
CloudBackendError,
|
|
9
|
+
DispatchError,
|
|
10
|
+
LocalBackendError,
|
|
11
|
+
)
|
|
12
|
+
from .privacy import PrivacyDetector
|
|
13
|
+
from .router import RouteDecision, Router
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Backend(Protocol):
|
|
17
|
+
name: str
|
|
18
|
+
|
|
19
|
+
def generate(
|
|
20
|
+
self,
|
|
21
|
+
query: str,
|
|
22
|
+
) -> str:
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class DispatchResult:
|
|
28
|
+
text: str
|
|
29
|
+
route: RouteDecision
|
|
30
|
+
backend_name: str
|
|
31
|
+
fallback_used: bool = False
|
|
32
|
+
privacy_categories: tuple[str, ...] = ()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Dispatcher:
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
router: Router,
|
|
40
|
+
simple_backend: Backend,
|
|
41
|
+
complex_backend: Backend,
|
|
42
|
+
privacy_detector: (
|
|
43
|
+
PrivacyDetector | None
|
|
44
|
+
) = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.router = router
|
|
47
|
+
|
|
48
|
+
self.simple_backend = (
|
|
49
|
+
simple_backend
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
self.complex_backend = (
|
|
53
|
+
complex_backend
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
self.privacy_detector = (
|
|
57
|
+
privacy_detector
|
|
58
|
+
or PrivacyDetector()
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def run(
|
|
62
|
+
self,
|
|
63
|
+
query: str,
|
|
64
|
+
) -> DispatchResult:
|
|
65
|
+
privacy_start = perf_counter()
|
|
66
|
+
|
|
67
|
+
privacy_decision = (
|
|
68
|
+
self.privacy_detector.detect(
|
|
69
|
+
query
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
privacy_latency_ms = (
|
|
74
|
+
perf_counter()
|
|
75
|
+
- privacy_start
|
|
76
|
+
) * 1000.0
|
|
77
|
+
|
|
78
|
+
# PRIVACY OVERRIDE:
|
|
79
|
+
# local execution only.
|
|
80
|
+
#
|
|
81
|
+
# Cloud fallback is intentionally
|
|
82
|
+
# prohibited for privacy-sensitive
|
|
83
|
+
# queries.
|
|
84
|
+
if privacy_decision.is_sensitive:
|
|
85
|
+
decision = RouteDecision(
|
|
86
|
+
label="privacy_override",
|
|
87
|
+
confidence=None,
|
|
88
|
+
complex_probability=None,
|
|
89
|
+
latency_ms=privacy_latency_ms,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
text = (
|
|
94
|
+
self.simple_backend.generate(
|
|
95
|
+
query
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return DispatchResult(
|
|
100
|
+
text=text,
|
|
101
|
+
route=decision,
|
|
102
|
+
backend_name=(
|
|
103
|
+
self.simple_backend.name
|
|
104
|
+
),
|
|
105
|
+
fallback_used=False,
|
|
106
|
+
privacy_categories=(
|
|
107
|
+
privacy_decision.categories
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
except LocalBackendError as error:
|
|
112
|
+
raise DispatchError(
|
|
113
|
+
"Privacy override requires "
|
|
114
|
+
"local processing, but the "
|
|
115
|
+
"local backend failed. "
|
|
116
|
+
"Cloud fallback was not "
|
|
117
|
+
"attempted."
|
|
118
|
+
) from error
|
|
119
|
+
|
|
120
|
+
# Clean queries continue to the
|
|
121
|
+
# semantic complexity router.
|
|
122
|
+
decision = self.router.route(query)
|
|
123
|
+
|
|
124
|
+
# SIMPLE:
|
|
125
|
+
# local first, cloud fallback.
|
|
126
|
+
if decision.label == "simple":
|
|
127
|
+
try:
|
|
128
|
+
text = (
|
|
129
|
+
self.simple_backend.generate(
|
|
130
|
+
query
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return DispatchResult(
|
|
135
|
+
text=text,
|
|
136
|
+
route=decision,
|
|
137
|
+
backend_name=(
|
|
138
|
+
self.simple_backend.name
|
|
139
|
+
),
|
|
140
|
+
fallback_used=False,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
except LocalBackendError:
|
|
144
|
+
try:
|
|
145
|
+
text = (
|
|
146
|
+
self.complex_backend.generate(
|
|
147
|
+
query
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return DispatchResult(
|
|
152
|
+
text=text,
|
|
153
|
+
route=decision,
|
|
154
|
+
backend_name=(
|
|
155
|
+
self.complex_backend.name
|
|
156
|
+
),
|
|
157
|
+
fallback_used=True,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
except CloudBackendError as error:
|
|
161
|
+
raise DispatchError(
|
|
162
|
+
"Local backend failed "
|
|
163
|
+
"and cloud fallback "
|
|
164
|
+
"also failed."
|
|
165
|
+
) from error
|
|
166
|
+
|
|
167
|
+
# COMPLEX or UNCERTAIN:
|
|
168
|
+
# cloud only.
|
|
169
|
+
if decision.label in {
|
|
170
|
+
"complex",
|
|
171
|
+
"uncertain",
|
|
172
|
+
}:
|
|
173
|
+
try:
|
|
174
|
+
text = (
|
|
175
|
+
self.complex_backend.generate(
|
|
176
|
+
query
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return DispatchResult(
|
|
181
|
+
text=text,
|
|
182
|
+
route=decision,
|
|
183
|
+
backend_name=(
|
|
184
|
+
self.complex_backend.name
|
|
185
|
+
),
|
|
186
|
+
fallback_used=False,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
except CloudBackendError as error:
|
|
190
|
+
raise DispatchError(
|
|
191
|
+
"Cloud backend failed for "
|
|
192
|
+
"a query requiring cloud "
|
|
193
|
+
"routing. Local fallback "
|
|
194
|
+
"was not attempted."
|
|
195
|
+
) from error
|
|
196
|
+
|
|
197
|
+
raise DispatchError(
|
|
198
|
+
"Unsupported route label: "
|
|
199
|
+
f"{decision.label!r}"
|
|
200
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class ReroutingLibraryError(Exception):
|
|
2
|
+
"""Base exception for the routing library."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BackendError(ReroutingLibraryError):
|
|
6
|
+
"""Base exception for backend failures."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LocalBackendError(BackendError):
|
|
10
|
+
"""Local Llama backend failed."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CloudBackendError(BackendError):
|
|
14
|
+
"""Cloud backend failed."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DispatchError(ReroutingLibraryError):
|
|
18
|
+
"""Dispatcher could not complete the request."""
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class PrivacyDecision:
|
|
9
|
+
is_sensitive: bool
|
|
10
|
+
categories: tuple[str, ...]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PrivacyDetector:
|
|
14
|
+
"""
|
|
15
|
+
Detect common sensitive-data patterns before cloud routing.
|
|
16
|
+
|
|
17
|
+
The detector returns only category names. It never returns
|
|
18
|
+
or logs the matched sensitive value.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
_EMAIL_PATTERNS = (
|
|
22
|
+
re.compile(
|
|
23
|
+
r"\b[A-Za-z0-9._%+-]+@"
|
|
24
|
+
r"[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_PHONE_PATTERNS = (
|
|
29
|
+
re.compile(
|
|
30
|
+
r"(?<!\d)"
|
|
31
|
+
r"(?:\+?1[\s.-]?)?"
|
|
32
|
+
r"(?:\(\d{3}\)|\d{3})"
|
|
33
|
+
r"[\s.-]?\d{3}"
|
|
34
|
+
r"[\s.-]?\d{4}"
|
|
35
|
+
r"(?!\d)"
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_SSN_PATTERNS = (
|
|
40
|
+
re.compile(
|
|
41
|
+
r"\b\d{3}-\d{2}-\d{4}\b"
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_API_KEY_PATTERNS = (
|
|
46
|
+
# OpenAI-style secret keys.
|
|
47
|
+
re.compile(
|
|
48
|
+
r"\bsk-[A-Za-z0-9_-]{20,}\b"
|
|
49
|
+
),
|
|
50
|
+
|
|
51
|
+
# GitHub classic-style tokens.
|
|
52
|
+
re.compile(
|
|
53
|
+
r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"
|
|
54
|
+
),
|
|
55
|
+
|
|
56
|
+
# GitHub fine-grained tokens.
|
|
57
|
+
re.compile(
|
|
58
|
+
r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"
|
|
59
|
+
),
|
|
60
|
+
|
|
61
|
+
# Google API-key style.
|
|
62
|
+
re.compile(
|
|
63
|
+
r"\bAIza[A-Za-z0-9_-]{35}\b"
|
|
64
|
+
),
|
|
65
|
+
|
|
66
|
+
# AWS access-key identifiers.
|
|
67
|
+
re.compile(
|
|
68
|
+
r"\bAKIA[0-9A-Z]{16}\b"
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
_CREDIT_CARD_CANDIDATE = re.compile(
|
|
73
|
+
r"(?<!\d)"
|
|
74
|
+
r"(?:\d[ -]*?){12,18}\d"
|
|
75
|
+
r"(?!\d)"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def detect(self, text: str) -> PrivacyDecision:
|
|
79
|
+
if not isinstance(text, str):
|
|
80
|
+
raise TypeError(
|
|
81
|
+
"PrivacyDetector input must be a string."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
categories: list[str] = []
|
|
85
|
+
|
|
86
|
+
if self._matches(
|
|
87
|
+
text,
|
|
88
|
+
self._EMAIL_PATTERNS,
|
|
89
|
+
):
|
|
90
|
+
categories.append("email")
|
|
91
|
+
|
|
92
|
+
if self._matches(
|
|
93
|
+
text,
|
|
94
|
+
self._PHONE_PATTERNS,
|
|
95
|
+
):
|
|
96
|
+
categories.append("phone")
|
|
97
|
+
|
|
98
|
+
if self._matches(
|
|
99
|
+
text,
|
|
100
|
+
self._SSN_PATTERNS,
|
|
101
|
+
):
|
|
102
|
+
categories.append("ssn")
|
|
103
|
+
|
|
104
|
+
if self._matches(
|
|
105
|
+
text,
|
|
106
|
+
self._API_KEY_PATTERNS,
|
|
107
|
+
):
|
|
108
|
+
categories.append("api_key")
|
|
109
|
+
|
|
110
|
+
if self._contains_credit_card(text):
|
|
111
|
+
categories.append("credit_card")
|
|
112
|
+
|
|
113
|
+
return PrivacyDecision(
|
|
114
|
+
is_sensitive=bool(categories),
|
|
115
|
+
categories=tuple(categories),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _matches(
|
|
120
|
+
text: str,
|
|
121
|
+
patterns: tuple[re.Pattern[str], ...],
|
|
122
|
+
) -> bool:
|
|
123
|
+
return any(
|
|
124
|
+
pattern.search(text)
|
|
125
|
+
for pattern in patterns
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _contains_credit_card(
|
|
129
|
+
self,
|
|
130
|
+
text: str,
|
|
131
|
+
) -> bool:
|
|
132
|
+
for match in self._CREDIT_CARD_CANDIDATE.finditer(
|
|
133
|
+
text
|
|
134
|
+
):
|
|
135
|
+
digits = re.sub(
|
|
136
|
+
r"\D",
|
|
137
|
+
"",
|
|
138
|
+
match.group(0),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if not 13 <= len(digits) <= 19:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
if len(set(digits)) == 1:
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
if self._passes_luhn(digits):
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _passes_luhn(number: str) -> bool:
|
|
154
|
+
digits = [
|
|
155
|
+
int(character)
|
|
156
|
+
for character in number
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
checksum = 0
|
|
160
|
+
parity = len(digits) % 2
|
|
161
|
+
|
|
162
|
+
for index, digit in enumerate(digits):
|
|
163
|
+
if index % 2 == parity:
|
|
164
|
+
digit *= 2
|
|
165
|
+
|
|
166
|
+
if digit > 9:
|
|
167
|
+
digit -= 9
|
|
168
|
+
|
|
169
|
+
checksum += digit
|
|
170
|
+
|
|
171
|
+
return checksum % 10 == 0
|