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.
@@ -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})"