privacyprobe 0.2.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.
- privacyprobe/__init__.py +45 -0
- privacyprobe/checks/__init__.py +31 -0
- privacyprobe/checks/agent_flow.py +98 -0
- privacyprobe/checks/base.py +60 -0
- privacyprobe/checks/hallucination.py +113 -0
- privacyprobe/checks/latency.py +47 -0
- privacyprobe/checks/pii_leak.py +335 -0
- privacyprobe/checks/prompt_injection.py +125 -0
- privacyprobe/checks/responsible_ai.py +93 -0
- privacyprobe/checks/schema_validation.py +56 -0
- privacyprobe/compliance.py +278 -0
- privacyprobe/py.typed +0 -0
- privacyprobe/redact.py +94 -0
- privacyprobe/regulations.py +121 -0
- privacyprobe/report.py +130 -0
- privacyprobe/result.py +102 -0
- privacyprobe/suite.py +138 -0
- privacyprobe-0.2.0.dist-info/METADATA +273 -0
- privacyprobe-0.2.0.dist-info/RECORD +22 -0
- privacyprobe-0.2.0.dist-info/WHEEL +5 -0
- privacyprobe-0.2.0.dist-info/licenses/LICENSE +21 -0
- privacyprobe-0.2.0.dist-info/top_level.txt +1 -0
privacyprobe/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""privacyprobe: privacy compliance (DPDP, GDPR) and quality testing for LLM apps."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from privacyprobe.checks import (
|
|
6
|
+
AgentFlowCheck,
|
|
7
|
+
BaseCheck,
|
|
8
|
+
HallucinationCheck,
|
|
9
|
+
LatencyCheck,
|
|
10
|
+
PIILeakCheck,
|
|
11
|
+
PromptInjectionCheck,
|
|
12
|
+
SchemaCheck,
|
|
13
|
+
ToxicityCheck,
|
|
14
|
+
)
|
|
15
|
+
from privacyprobe.compliance import ComplianceReport, build_compliance_report
|
|
16
|
+
from privacyprobe.redact import redact
|
|
17
|
+
from privacyprobe.regulations import REGULATIONS
|
|
18
|
+
from privacyprobe.report import generate_report
|
|
19
|
+
from privacyprobe.result import SuiteResult, TestResult
|
|
20
|
+
from privacyprobe.suite import Suite
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
__version__ = version("privacyprobe")
|
|
24
|
+
except PackageNotFoundError: # running from a source checkout without install
|
|
25
|
+
__version__ = "0.0.0+unknown"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"REGULATIONS",
|
|
29
|
+
"AgentFlowCheck",
|
|
30
|
+
"BaseCheck",
|
|
31
|
+
"ComplianceReport",
|
|
32
|
+
"HallucinationCheck",
|
|
33
|
+
"LatencyCheck",
|
|
34
|
+
"PIILeakCheck",
|
|
35
|
+
"PromptInjectionCheck",
|
|
36
|
+
"SchemaCheck",
|
|
37
|
+
"Suite",
|
|
38
|
+
"SuiteResult",
|
|
39
|
+
"TestResult",
|
|
40
|
+
"ToxicityCheck",
|
|
41
|
+
"__version__",
|
|
42
|
+
"build_compliance_report",
|
|
43
|
+
"generate_report",
|
|
44
|
+
"redact",
|
|
45
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""All built-in checks, importable as ``from privacyprobe.checks import X``."""
|
|
2
|
+
|
|
3
|
+
from privacyprobe.checks.agent_flow import AgentFlowCheck
|
|
4
|
+
from privacyprobe.checks.base import BaseCheck
|
|
5
|
+
from privacyprobe.checks.hallucination import HallucinationCheck, keyword_overlap
|
|
6
|
+
from privacyprobe.checks.latency import LatencyCheck
|
|
7
|
+
from privacyprobe.checks.pii_leak import PII_PATTERNS, PII_PROFILES, PIILeakCheck, PIIMatch
|
|
8
|
+
from privacyprobe.checks.prompt_injection import (
|
|
9
|
+
DEFAULT_ATTACKS,
|
|
10
|
+
DEFAULT_CANARY,
|
|
11
|
+
PromptInjectionCheck,
|
|
12
|
+
)
|
|
13
|
+
from privacyprobe.checks.responsible_ai import ToxicityCheck
|
|
14
|
+
from privacyprobe.checks.schema_validation import SchemaCheck
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"DEFAULT_ATTACKS",
|
|
18
|
+
"DEFAULT_CANARY",
|
|
19
|
+
"PII_PATTERNS",
|
|
20
|
+
"PII_PROFILES",
|
|
21
|
+
"AgentFlowCheck",
|
|
22
|
+
"BaseCheck",
|
|
23
|
+
"HallucinationCheck",
|
|
24
|
+
"LatencyCheck",
|
|
25
|
+
"PIILeakCheck",
|
|
26
|
+
"PIIMatch",
|
|
27
|
+
"PromptInjectionCheck",
|
|
28
|
+
"SchemaCheck",
|
|
29
|
+
"ToxicityCheck",
|
|
30
|
+
"keyword_overlap",
|
|
31
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Validate state transitions in a multi-step agent pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from privacyprobe.checks.base import BaseCheck
|
|
10
|
+
from privacyprobe.result import TestResult
|
|
11
|
+
|
|
12
|
+
_STATE_KEYS = ("state", "step", "name", "action", "tool")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _state_of(step: Any) -> str:
|
|
16
|
+
if isinstance(step, str):
|
|
17
|
+
return step
|
|
18
|
+
if isinstance(step, Mapping):
|
|
19
|
+
for key in _STATE_KEYS:
|
|
20
|
+
if key in step:
|
|
21
|
+
return str(step[key])
|
|
22
|
+
raise ValueError(f"Cannot determine state of trace step: {step!r}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AgentFlowCheck(BaseCheck):
|
|
26
|
+
"""Checks that an agent's trace follows an allowed state machine.
|
|
27
|
+
|
|
28
|
+
The trace is a list of steps, each a state name or a dict with one of the
|
|
29
|
+
keys ``state``/``step``/``name``/``action``/``tool``. It is read from the
|
|
30
|
+
``trace`` keyword (per test case) or, if absent, parsed from the response
|
|
31
|
+
as a JSON list (or an object with a ``trace``/``steps`` list).
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
transitions: ``{state: [allowed next states]}``.
|
|
35
|
+
start: Required first state.
|
|
36
|
+
terminal: States the trace is allowed to end on.
|
|
37
|
+
required: States that must appear somewhere in the trace.
|
|
38
|
+
max_steps: Fail traces longer than this (catches loops).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name = "agent_flow"
|
|
42
|
+
description = "Validates multi-step agent pipeline state transitions"
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
transitions: Mapping[str, Iterable[str]],
|
|
47
|
+
start: Optional[str] = None,
|
|
48
|
+
terminal: Optional[Iterable[str]] = None,
|
|
49
|
+
required: Optional[Iterable[str]] = None,
|
|
50
|
+
max_steps: Optional[int] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.transitions = {k: set(v) for k, v in transitions.items()}
|
|
53
|
+
self.start = start
|
|
54
|
+
self.terminal = set(terminal) if terminal is not None else None
|
|
55
|
+
self.required = list(required or ())
|
|
56
|
+
self.max_steps = max_steps
|
|
57
|
+
|
|
58
|
+
def _load_trace(self, response: str, kwargs: dict[str, Any]) -> list[str]:
|
|
59
|
+
trace = kwargs.get("trace")
|
|
60
|
+
if trace is None:
|
|
61
|
+
try:
|
|
62
|
+
trace = json.loads(response)
|
|
63
|
+
except json.JSONDecodeError as exc:
|
|
64
|
+
raise ValueError("No 'trace' given and response is not a JSON trace") from exc
|
|
65
|
+
if isinstance(trace, Mapping):
|
|
66
|
+
trace = trace.get("trace", trace.get("steps"))
|
|
67
|
+
if not isinstance(trace, list):
|
|
68
|
+
raise ValueError("Agent trace must be a list of steps")
|
|
69
|
+
return [_state_of(s) for s in trace]
|
|
70
|
+
|
|
71
|
+
def run(self, prompt: str, response: str, **kwargs: Any) -> TestResult:
|
|
72
|
+
states = self._load_trace(response, kwargs)
|
|
73
|
+
errors: list[str] = []
|
|
74
|
+
if not states:
|
|
75
|
+
errors.append("empty trace")
|
|
76
|
+
if self.start is not None and states and states[0] != self.start:
|
|
77
|
+
errors.append(f"starts at {states[0]!r}, expected {self.start!r}")
|
|
78
|
+
|
|
79
|
+
pairs = list(zip(states, states[1:]))
|
|
80
|
+
bad = [(a, b) for a, b in pairs if b not in self.transitions.get(a, ())]
|
|
81
|
+
errors += [f"illegal transition {a!r} -> {b!r}" for a, b in bad]
|
|
82
|
+
|
|
83
|
+
if self.terminal is not None and states and states[-1] not in self.terminal:
|
|
84
|
+
errors.append(f"ends at non-terminal state {states[-1]!r}")
|
|
85
|
+
missing = [s for s in self.required if s not in states]
|
|
86
|
+
if missing:
|
|
87
|
+
errors.append(f"required states never reached: {missing}")
|
|
88
|
+
if self.max_steps is not None and len(states) > self.max_steps:
|
|
89
|
+
errors.append(f"{len(states)} steps exceeds max_steps={self.max_steps}")
|
|
90
|
+
|
|
91
|
+
score = (len(pairs) - len(bad)) / len(pairs) if pairs else (1.0 if states else 0.0)
|
|
92
|
+
if errors:
|
|
93
|
+
return self._result(
|
|
94
|
+
prompt, response, False, score, "; ".join(errors), trace=states, errors=errors
|
|
95
|
+
)
|
|
96
|
+
return self._result(
|
|
97
|
+
prompt, response, True, 1.0, f"Valid flow: {' -> '.join(states)}", trace=states
|
|
98
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Abstract base class shared by every privacyprobe check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from privacyprobe.result import TestResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseCheck(ABC):
|
|
14
|
+
"""Every check inherits from this and must implement :meth:`run`.
|
|
15
|
+
|
|
16
|
+
Subclasses set ``name`` and ``description`` as class attributes. ``run``
|
|
17
|
+
receives the prompt, the model's response, and any extra fields from the
|
|
18
|
+
test case (e.g. ``facts``, ``latency``) as keyword arguments. Checks must
|
|
19
|
+
accept and ignore keyword arguments they do not use.
|
|
20
|
+
|
|
21
|
+
``clauses`` maps regulation keys to the clauses this check produces evidence
|
|
22
|
+
for, e.g. ``{"gdpr": ("Art. 9",)}``. It is stamped onto every result as
|
|
23
|
+
``metadata["clauses"]`` and used by compliance reports.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
name: str = "base"
|
|
27
|
+
description: str = ""
|
|
28
|
+
clauses: Mapping[str, tuple[str, ...]] = MappingProxyType({})
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def run(self, prompt: str, response: str, **kwargs: Any) -> TestResult:
|
|
32
|
+
"""Run this check. Returns a TestResult with pass/fail + details."""
|
|
33
|
+
|
|
34
|
+
def _result(
|
|
35
|
+
self,
|
|
36
|
+
prompt: str,
|
|
37
|
+
response: str,
|
|
38
|
+
passed: bool,
|
|
39
|
+
score: float,
|
|
40
|
+
details: str,
|
|
41
|
+
**metadata: Any,
|
|
42
|
+
) -> TestResult:
|
|
43
|
+
"""Build a TestResult stamped with this check's name and clauses."""
|
|
44
|
+
if self.clauses:
|
|
45
|
+
metadata.setdefault("clauses", self.clause_map())
|
|
46
|
+
return TestResult(
|
|
47
|
+
check_name=self.name,
|
|
48
|
+
passed=passed,
|
|
49
|
+
score=max(0.0, min(1.0, float(score))),
|
|
50
|
+
details=details,
|
|
51
|
+
prompt=prompt,
|
|
52
|
+
response=response,
|
|
53
|
+
metadata=metadata,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def clause_map(self) -> dict[str, list[str]]:
|
|
57
|
+
return {reg: list(ids) for reg, ids in self.clauses.items()}
|
|
58
|
+
|
|
59
|
+
def __repr__(self) -> str:
|
|
60
|
+
return f"{type(self).__name__}()"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Check an LLM response for factual consistency with ground-truth facts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any, Callable, Optional, Union
|
|
8
|
+
|
|
9
|
+
from privacyprobe.checks.base import BaseCheck
|
|
10
|
+
from privacyprobe.regulations import CHECK_CLAUSES
|
|
11
|
+
from privacyprobe.result import TestResult
|
|
12
|
+
|
|
13
|
+
_TOKEN_RE = re.compile(r"[a-z0-9]+(?:[.'][a-z0-9]+)*")
|
|
14
|
+
|
|
15
|
+
STOPWORDS = frozenset(
|
|
16
|
+
"""a an the and or but if then of to in on at by for with from as is are was were be
|
|
17
|
+
been being it its this that these those there their they he she his her him we our you
|
|
18
|
+
your i me my do does did has have had not no so than too very can will would should
|
|
19
|
+
could may might must about into over under also just which who whom what when where why
|
|
20
|
+
how all any each some such only own same other more most""".split()
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _content_tokens(text: str) -> set[str]:
|
|
25
|
+
return {t for t in _TOKEN_RE.findall(text.lower()) if t not in STOPWORDS}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def keyword_overlap(fact: str, response: str) -> float:
|
|
29
|
+
"""Fraction of the fact's content words that appear in the response (0.0-1.0)."""
|
|
30
|
+
fact_tokens = _content_tokens(fact)
|
|
31
|
+
if not fact_tokens:
|
|
32
|
+
return 1.0
|
|
33
|
+
return len(fact_tokens & _content_tokens(response)) / len(fact_tokens)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HallucinationCheck(BaseCheck):
|
|
37
|
+
"""Scores how well a response is supported by ground-truth facts.
|
|
38
|
+
|
|
39
|
+
Each fact counts as *supported* when ``similarity_fn(fact, response)`` is at
|
|
40
|
+
least ``fact_threshold``. The score is the fraction of supported facts, and
|
|
41
|
+
the check passes when the score is at least ``threshold`` and no
|
|
42
|
+
``forbidden`` statement (a known-false claim) appears in the response.
|
|
43
|
+
|
|
44
|
+
By default similarity is keyword overlap. Pass ``similarity_fn`` to plug in
|
|
45
|
+
semantic similarity (e.g. embedding cosine similarity) without adding
|
|
46
|
+
dependencies to privacyprobe itself.
|
|
47
|
+
|
|
48
|
+
Facts and forbidden claims can be given here or per test case via the
|
|
49
|
+
``facts`` / ``forbidden`` keys; per-case values take precedence.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
name = "hallucination"
|
|
53
|
+
clauses = CHECK_CLAUSES["hallucination"]
|
|
54
|
+
description = "Compares response against ground truth facts using keyword/semantic overlap"
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
facts: Optional[Union[str, Sequence[str]]] = None,
|
|
59
|
+
threshold: float = 1.0,
|
|
60
|
+
fact_threshold: float = 0.8,
|
|
61
|
+
forbidden: Optional[Sequence[str]] = None,
|
|
62
|
+
similarity_fn: Optional[Callable[[str, str], float]] = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
if not 0.0 <= threshold <= 1.0 or not 0.0 <= fact_threshold <= 1.0:
|
|
65
|
+
raise ValueError("threshold and fact_threshold must be between 0.0 and 1.0")
|
|
66
|
+
self.facts = facts
|
|
67
|
+
self.threshold = threshold
|
|
68
|
+
self.fact_threshold = fact_threshold
|
|
69
|
+
self.forbidden = forbidden
|
|
70
|
+
self.similarity_fn = similarity_fn or keyword_overlap
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _as_list(value: Optional[Union[str, Sequence[str]]]) -> list[str]:
|
|
74
|
+
if value is None:
|
|
75
|
+
return []
|
|
76
|
+
return [value] if isinstance(value, str) else list(value)
|
|
77
|
+
|
|
78
|
+
def run(self, prompt: str, response: str, **kwargs: Any) -> TestResult:
|
|
79
|
+
facts = self._as_list(kwargs.get("facts", self.facts))
|
|
80
|
+
forbidden = self._as_list(kwargs.get("forbidden", self.forbidden))
|
|
81
|
+
if not facts and not forbidden:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"HallucinationCheck needs ground truth: pass facts=/forbidden= to the "
|
|
84
|
+
"check or add a 'facts' key to the test case"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
scores = {fact: self.similarity_fn(fact, response) for fact in facts}
|
|
88
|
+
unsupported = [f for f, s in scores.items() if s < self.fact_threshold]
|
|
89
|
+
contradicted = [
|
|
90
|
+
f for f in forbidden if self.similarity_fn(f, response) >= self.fact_threshold
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
score = (len(facts) - len(unsupported)) / len(facts) if facts else 1.0
|
|
94
|
+
if contradicted:
|
|
95
|
+
score = 0.0
|
|
96
|
+
passed = score >= self.threshold and not contradicted
|
|
97
|
+
|
|
98
|
+
problems = []
|
|
99
|
+
if unsupported:
|
|
100
|
+
problems.append(f"{len(unsupported)}/{len(facts)} facts unsupported: {unsupported}")
|
|
101
|
+
if contradicted:
|
|
102
|
+
problems.append(f"forbidden claims present: {contradicted}")
|
|
103
|
+
details = "; ".join(problems) if problems else f"All {len(facts)} facts supported"
|
|
104
|
+
return self._result(
|
|
105
|
+
prompt,
|
|
106
|
+
response,
|
|
107
|
+
passed,
|
|
108
|
+
score,
|
|
109
|
+
details,
|
|
110
|
+
fact_scores=scores,
|
|
111
|
+
unsupported=unsupported,
|
|
112
|
+
contradicted=contradicted,
|
|
113
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Flag responses that take longer than a threshold."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from privacyprobe.checks.base import BaseCheck
|
|
8
|
+
from privacyprobe.result import TestResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LatencyCheck(BaseCheck):
|
|
12
|
+
"""Fails when response time exceeds ``max_seconds``.
|
|
13
|
+
|
|
14
|
+
:class:`~privacyprobe.Suite` measures the model call and passes it as the
|
|
15
|
+
``latency`` keyword (seconds). When responses are supplied pre-computed in
|
|
16
|
+
the test case, include a ``latency`` key yourself.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
name = "latency"
|
|
20
|
+
description = "Measures response time, flags if over threshold"
|
|
21
|
+
|
|
22
|
+
def __init__(self, max_seconds: float = 2.0) -> None:
|
|
23
|
+
if max_seconds <= 0:
|
|
24
|
+
raise ValueError("max_seconds must be positive")
|
|
25
|
+
self.max_seconds = max_seconds
|
|
26
|
+
|
|
27
|
+
def run(self, prompt: str, response: str, **kwargs: Any) -> TestResult:
|
|
28
|
+
latency = kwargs.get("latency")
|
|
29
|
+
if latency is None:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
"No latency measured: run via Suite with a model, or add 'latency' to the test case"
|
|
32
|
+
)
|
|
33
|
+
latency = float(latency)
|
|
34
|
+
passed = latency <= self.max_seconds
|
|
35
|
+
score = 1.0 if passed else self.max_seconds / latency
|
|
36
|
+
verdict = "within" if passed else "exceeds"
|
|
37
|
+
return self._result(
|
|
38
|
+
prompt,
|
|
39
|
+
response,
|
|
40
|
+
passed,
|
|
41
|
+
score,
|
|
42
|
+
f"{latency:.3f}s {verdict} limit of {self.max_seconds:.3f}s",
|
|
43
|
+
latency=latency,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def __repr__(self) -> str:
|
|
47
|
+
return f"LatencyCheck(max_seconds={self.max_seconds})"
|