phi-redactor 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.
- phi_redactor/__init__.py +71 -0
- phi_redactor/__main__.py +6 -0
- phi_redactor/audit/__init__.py +7 -0
- phi_redactor/audit/reports.py +416 -0
- phi_redactor/audit/trail.py +278 -0
- phi_redactor/cli/__init__.py +5 -0
- phi_redactor/cli/config.py +108 -0
- phi_redactor/cli/main.py +101 -0
- phi_redactor/cli/plugins.py +54 -0
- phi_redactor/cli/redact.py +86 -0
- phi_redactor/cli/report.py +159 -0
- phi_redactor/cli/serve.py +91 -0
- phi_redactor/cli/sessions.py +122 -0
- phi_redactor/config.py +260 -0
- phi_redactor/dashboard/__init__.py +1 -0
- phi_redactor/dashboard/routes.py +58 -0
- phi_redactor/dashboard/static/index.html +121 -0
- phi_redactor/detection/__init__.py +15 -0
- phi_redactor/detection/engine.py +230 -0
- phi_redactor/detection/recognizers/__init__.py +47 -0
- phi_redactor/detection/recognizers/account.py +69 -0
- phi_redactor/detection/recognizers/biometric.py +71 -0
- phi_redactor/detection/recognizers/device.py +82 -0
- phi_redactor/detection/recognizers/fax.py +58 -0
- phi_redactor/detection/recognizers/fhir.py +44 -0
- phi_redactor/detection/recognizers/health_plan.py +75 -0
- phi_redactor/detection/recognizers/hl7.py +25 -0
- phi_redactor/detection/recognizers/license.py +116 -0
- phi_redactor/detection/recognizers/mrn.py +59 -0
- phi_redactor/detection/recognizers/vehicle.py +64 -0
- phi_redactor/detection/registry.py +197 -0
- phi_redactor/masking/__init__.py +3 -0
- phi_redactor/masking/clustering.py +135 -0
- phi_redactor/masking/date_shifter.py +162 -0
- phi_redactor/masking/identity.py +139 -0
- phi_redactor/masking/providers.py +56 -0
- phi_redactor/masking/semantic.py +246 -0
- phi_redactor/models.py +271 -0
- phi_redactor/plugins/__init__.py +11 -0
- phi_redactor/plugins/example_plugin.py +43 -0
- phi_redactor/plugins/loader.py +106 -0
- phi_redactor/proxy/__init__.py +0 -0
- phi_redactor/proxy/adapters/__init__.py +0 -0
- phi_redactor/proxy/adapters/anthropic.py +292 -0
- phi_redactor/proxy/adapters/base.py +100 -0
- phi_redactor/proxy/adapters/openai.py +241 -0
- phi_redactor/proxy/app.py +160 -0
- phi_redactor/proxy/routes/__init__.py +0 -0
- phi_redactor/proxy/routes/anthropic.py +311 -0
- phi_redactor/proxy/routes/library.py +162 -0
- phi_redactor/proxy/routes/management.py +402 -0
- phi_redactor/proxy/routes/openai.py +472 -0
- phi_redactor/proxy/session.py +208 -0
- phi_redactor/proxy/streaming.py +135 -0
- phi_redactor/py.typed +1 -0
- phi_redactor/vault/__init__.py +7 -0
- phi_redactor/vault/encryption.py +143 -0
- phi_redactor/vault/session_map.py +101 -0
- phi_redactor/vault/store.py +375 -0
- phi_redactor-0.1.0.dist-info/METADATA +306 -0
- phi_redactor-0.1.0.dist-info/RECORD +64 -0
- phi_redactor-0.1.0.dist-info/WHEEL +4 -0
- phi_redactor-0.1.0.dist-info/entry_points.txt +2 -0
- phi_redactor-0.1.0.dist-info/licenses/LICENSE +190 -0
phi_redactor/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""phi-redactor: HIPAA-native PHI redaction proxy for AI/LLM interactions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
from phi_redactor.detection.engine import PhiDetectionEngine
|
|
8
|
+
from phi_redactor.masking.semantic import SemanticMasker
|
|
9
|
+
from phi_redactor.vault.store import PhiVault
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"__version__",
|
|
13
|
+
"PhiDetectionEngine",
|
|
14
|
+
"PhiRedactor",
|
|
15
|
+
"PhiVault",
|
|
16
|
+
"SemanticMasker",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PhiRedactor:
|
|
21
|
+
"""High-level facade for PHI detection, masking, and re-hydration.
|
|
22
|
+
|
|
23
|
+
Usage::
|
|
24
|
+
|
|
25
|
+
redactor = PhiRedactor()
|
|
26
|
+
result = redactor.redact("Patient John Smith, SSN 123-45-6789...")
|
|
27
|
+
print(result.redacted_text)
|
|
28
|
+
original = redactor.rehydrate(result.redacted_text, result.session_id)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
sensitivity: float = 0.5,
|
|
34
|
+
vault_path: str | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
from phi_redactor.config import PhiRedactorConfig
|
|
37
|
+
|
|
38
|
+
self._config = PhiRedactorConfig(
|
|
39
|
+
sensitivity=sensitivity,
|
|
40
|
+
**({"vault_path": vault_path} if vault_path else {}),
|
|
41
|
+
)
|
|
42
|
+
self._engine = PhiDetectionEngine(sensitivity=self._config.sensitivity)
|
|
43
|
+
self._vault = PhiVault(db_path=str(self._config.vault_path))
|
|
44
|
+
self._masker = SemanticMasker(vault=self._vault)
|
|
45
|
+
|
|
46
|
+
def redact(self, text: str, session_id: str | None = None) -> RedactionResult:
|
|
47
|
+
"""Detect and redact PHI from text, returning masked text and metadata."""
|
|
48
|
+
import uuid
|
|
49
|
+
|
|
50
|
+
if session_id is None:
|
|
51
|
+
session_id = str(uuid.uuid4())
|
|
52
|
+
|
|
53
|
+
detections = self._engine.detect(text, self._config.sensitivity)
|
|
54
|
+
masked_text, mappings = self._masker.mask(text, detections, session_id)
|
|
55
|
+
|
|
56
|
+
from phi_redactor.models import RedactionResult
|
|
57
|
+
|
|
58
|
+
return RedactionResult(
|
|
59
|
+
redacted_text=masked_text,
|
|
60
|
+
detections=detections,
|
|
61
|
+
session_id=session_id,
|
|
62
|
+
processing_time_ms=0.0,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def rehydrate(self, text: str, session_id: str) -> str:
|
|
66
|
+
"""Replace synthetic tokens back with original PHI values."""
|
|
67
|
+
return self._masker.rehydrate(text, session_id)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Lazy import to avoid circular dependency
|
|
71
|
+
from phi_redactor.models import RedactionResult as RedactionResult # noqa: E402
|
phi_redactor/__main__.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""HIPAA Safe Harbor compliance report generator.
|
|
2
|
+
|
|
3
|
+
Produces structured evidence reports that demonstrate de-identification
|
|
4
|
+
compliance with the HIPAA Safe Harbor method (45 CFR 164.514(b)(2)).
|
|
5
|
+
Reports can be used for:
|
|
6
|
+
|
|
7
|
+
- Internal compliance audits
|
|
8
|
+
- External regulatory reviews
|
|
9
|
+
- Breach risk assessments
|
|
10
|
+
- Continuous monitoring dashboards
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from phi_redactor.audit.trail import AuditTrail
|
|
22
|
+
from phi_redactor.models import AuditEvent, PHICategory
|
|
23
|
+
|
|
24
|
+
# All 18 HIPAA Safe Harbor identifier categories
|
|
25
|
+
_SAFE_HARBOR_CATEGORIES = [cat.value for cat in PHICategory]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ComplianceReportGenerator:
|
|
29
|
+
"""Generates HIPAA Safe Harbor compliance evidence reports.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
audit_trail:
|
|
34
|
+
The :class:`AuditTrail` instance to query for redaction events.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, audit_trail: AuditTrail) -> None:
|
|
38
|
+
self._audit = audit_trail
|
|
39
|
+
|
|
40
|
+
def generate_report(
|
|
41
|
+
self,
|
|
42
|
+
from_dt: datetime | None = None,
|
|
43
|
+
to_dt: datetime | None = None,
|
|
44
|
+
session_id: str | None = None,
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
"""Generate a full compliance report.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
from_dt: Inclusive start of the reporting period (UTC).
|
|
50
|
+
to_dt: Inclusive end of the reporting period (UTC).
|
|
51
|
+
session_id: Optional filter to a specific session.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
A structured dict containing the full compliance report.
|
|
55
|
+
"""
|
|
56
|
+
now = datetime.now(timezone.utc)
|
|
57
|
+
events = self._audit.query(
|
|
58
|
+
session_id=session_id,
|
|
59
|
+
from_dt=from_dt,
|
|
60
|
+
to_dt=to_dt,
|
|
61
|
+
limit=100_000,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
"report_metadata": {
|
|
66
|
+
"title": "HIPAA Safe Harbor De-identification Compliance Report",
|
|
67
|
+
"generated_at": now.isoformat(),
|
|
68
|
+
"reporting_period": {
|
|
69
|
+
"from": from_dt.isoformat() if from_dt else "inception",
|
|
70
|
+
"to": to_dt.isoformat() if to_dt else now.isoformat(),
|
|
71
|
+
},
|
|
72
|
+
"session_filter": session_id,
|
|
73
|
+
"standard": "45 CFR 164.514(b)(2) - Safe Harbor Method",
|
|
74
|
+
},
|
|
75
|
+
"summary": self._build_summary(events),
|
|
76
|
+
"category_coverage": self._build_category_coverage(events),
|
|
77
|
+
"confidence_analysis": self._build_confidence_analysis(events),
|
|
78
|
+
"detection_methods": self._build_detection_methods(events),
|
|
79
|
+
"integrity_verification": self._verify_integrity(),
|
|
80
|
+
"compliance_status": self._assess_compliance(events),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
def generate_summary(
|
|
84
|
+
self,
|
|
85
|
+
from_dt: datetime | None = None,
|
|
86
|
+
to_dt: datetime | None = None,
|
|
87
|
+
) -> dict[str, Any]:
|
|
88
|
+
"""Generate a lightweight summary report.
|
|
89
|
+
|
|
90
|
+
Suitable for dashboards and quick status checks.
|
|
91
|
+
"""
|
|
92
|
+
events = self._audit.query(from_dt=from_dt, to_dt=to_dt, limit=100_000)
|
|
93
|
+
return {
|
|
94
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
95
|
+
**self._build_summary(events),
|
|
96
|
+
"compliance_status": self._assess_compliance(events),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def export_report(
|
|
100
|
+
self,
|
|
101
|
+
output_path: str | Path,
|
|
102
|
+
from_dt: datetime | None = None,
|
|
103
|
+
to_dt: datetime | None = None,
|
|
104
|
+
session_id: str | None = None,
|
|
105
|
+
) -> Path:
|
|
106
|
+
"""Generate and write the full report as a JSON file.
|
|
107
|
+
|
|
108
|
+
Returns the path to the written file.
|
|
109
|
+
"""
|
|
110
|
+
report = self.generate_report(
|
|
111
|
+
from_dt=from_dt, to_dt=to_dt, session_id=session_id,
|
|
112
|
+
)
|
|
113
|
+
path = Path(output_path).expanduser()
|
|
114
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
path.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8")
|
|
116
|
+
return path
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
# Report sections
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _build_summary(events: list[AuditEvent]) -> dict[str, Any]:
|
|
124
|
+
"""High-level statistics."""
|
|
125
|
+
if not events:
|
|
126
|
+
return {
|
|
127
|
+
"total_redaction_events": 0,
|
|
128
|
+
"unique_sessions": 0,
|
|
129
|
+
"unique_requests": 0,
|
|
130
|
+
"phi_entities_detected": 0,
|
|
131
|
+
"categories_detected": 0,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
sessions = {e.session_id for e in events}
|
|
135
|
+
requests = {e.request_id for e in events}
|
|
136
|
+
categories = {e.phi_category.value for e in events}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
"total_redaction_events": len(events),
|
|
140
|
+
"unique_sessions": len(sessions),
|
|
141
|
+
"unique_requests": len(requests),
|
|
142
|
+
"phi_entities_detected": len(events),
|
|
143
|
+
"categories_detected": len(categories),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
def _build_category_coverage(self, events: list[AuditEvent]) -> dict[str, Any]:
|
|
147
|
+
"""Show which of the 18 HIPAA categories were detected and redacted."""
|
|
148
|
+
detected = Counter(e.phi_category.value for e in events)
|
|
149
|
+
|
|
150
|
+
coverage = {}
|
|
151
|
+
for cat in _SAFE_HARBOR_CATEGORIES:
|
|
152
|
+
count = detected.get(cat, 0)
|
|
153
|
+
coverage[cat] = {
|
|
154
|
+
"detected_count": count,
|
|
155
|
+
"status": "covered" if count > 0 else "not_observed",
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
covered_count = sum(1 for c in coverage.values() if c["status"] == "covered")
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
"total_categories": len(_SAFE_HARBOR_CATEGORIES),
|
|
162
|
+
"categories_covered": covered_count,
|
|
163
|
+
"coverage_percentage": round(covered_count / len(_SAFE_HARBOR_CATEGORIES) * 100, 1),
|
|
164
|
+
"categories": coverage,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _build_confidence_analysis(events: list[AuditEvent]) -> dict[str, Any]:
|
|
169
|
+
"""Analyze detection confidence distribution."""
|
|
170
|
+
if not events:
|
|
171
|
+
return {
|
|
172
|
+
"average_confidence": 0.0,
|
|
173
|
+
"min_confidence": 0.0,
|
|
174
|
+
"max_confidence": 0.0,
|
|
175
|
+
"distribution": {},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
confidences = [e.confidence for e in events]
|
|
179
|
+
avg = sum(confidences) / len(confidences)
|
|
180
|
+
|
|
181
|
+
# Bucket into ranges
|
|
182
|
+
buckets = {"0.0-0.5": 0, "0.5-0.7": 0, "0.7-0.9": 0, "0.9-1.0": 0}
|
|
183
|
+
for c in confidences:
|
|
184
|
+
if c < 0.5:
|
|
185
|
+
buckets["0.0-0.5"] += 1
|
|
186
|
+
elif c < 0.7:
|
|
187
|
+
buckets["0.5-0.7"] += 1
|
|
188
|
+
elif c < 0.9:
|
|
189
|
+
buckets["0.7-0.9"] += 1
|
|
190
|
+
else:
|
|
191
|
+
buckets["0.9-1.0"] += 1
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
"average_confidence": round(avg, 4),
|
|
195
|
+
"min_confidence": round(min(confidences), 4),
|
|
196
|
+
"max_confidence": round(max(confidences), 4),
|
|
197
|
+
"distribution": buckets,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def _build_detection_methods(events: list[AuditEvent]) -> dict[str, int]:
|
|
202
|
+
"""Count events by detection method."""
|
|
203
|
+
return dict(Counter(e.detection_method.value for e in events))
|
|
204
|
+
|
|
205
|
+
def _verify_integrity(self) -> dict[str, Any]:
|
|
206
|
+
"""Verify audit trail hash-chain integrity."""
|
|
207
|
+
is_valid = self._audit.verify_integrity()
|
|
208
|
+
return {
|
|
209
|
+
"hash_chain_valid": is_valid,
|
|
210
|
+
"status": "passed" if is_valid else "FAILED",
|
|
211
|
+
"verified_at": datetime.now(timezone.utc).isoformat(),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
def generate_safe_harbor(
|
|
215
|
+
self,
|
|
216
|
+
from_dt: datetime | None = None,
|
|
217
|
+
to_dt: datetime | None = None,
|
|
218
|
+
session_id: str | None = None,
|
|
219
|
+
) -> dict[str, Any]:
|
|
220
|
+
"""Generate full Safe Harbor attestation document."""
|
|
221
|
+
report = self.generate_report(from_dt=from_dt, to_dt=to_dt, session_id=session_id)
|
|
222
|
+
report["attestation"] = {
|
|
223
|
+
"method": "Safe Harbor",
|
|
224
|
+
"standard": "45 CFR 164.514(b)(2)",
|
|
225
|
+
"statement": (
|
|
226
|
+
"This report attests that the PHI redaction system employs the "
|
|
227
|
+
"HIPAA Safe Harbor method for de-identification. All 18 categories "
|
|
228
|
+
"of identifiers specified in 45 CFR 164.514(b)(2) are addressed "
|
|
229
|
+
"by the detection and masking pipeline."
|
|
230
|
+
),
|
|
231
|
+
"methodology": (
|
|
232
|
+
"Detection uses a combination of pattern-based regular expressions "
|
|
233
|
+
"and named-entity recognition (NER) via spaCy and Microsoft Presidio. "
|
|
234
|
+
"Masking replaces detected PHI with clinically coherent synthetic values "
|
|
235
|
+
"generated by Faker with healthcare-specific providers. All mappings are "
|
|
236
|
+
"encrypted at rest using Fernet (AES-128-CBC) and tracked in a tamper-evident "
|
|
237
|
+
"hash-chain audit trail."
|
|
238
|
+
),
|
|
239
|
+
}
|
|
240
|
+
return report
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def _assess_compliance(events: list[AuditEvent]) -> dict[str, Any]:
|
|
244
|
+
"""Assess overall compliance status based on evidence."""
|
|
245
|
+
if not events:
|
|
246
|
+
return {
|
|
247
|
+
"overall": "no_data",
|
|
248
|
+
"message": "No redaction events found in the reporting period.",
|
|
249
|
+
"checks": {},
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
checks: dict[str, dict[str, Any]] = {}
|
|
253
|
+
|
|
254
|
+
# Check 1: All detections were redacted
|
|
255
|
+
redacted = sum(1 for e in events if e.action.value == "redacted")
|
|
256
|
+
checks["all_detections_redacted"] = {
|
|
257
|
+
"passed": redacted == len(events),
|
|
258
|
+
"detail": f"{redacted}/{len(events)} detections were redacted",
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
# Check 2: Confidence threshold
|
|
262
|
+
low_confidence = sum(1 for e in events if e.confidence < 0.3)
|
|
263
|
+
checks["confidence_threshold"] = {
|
|
264
|
+
"passed": low_confidence == 0,
|
|
265
|
+
"detail": f"{low_confidence} detections below 0.3 confidence threshold",
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
# Check 3: Multiple detection methods used
|
|
269
|
+
methods = {e.detection_method.value for e in events}
|
|
270
|
+
checks["multiple_detection_methods"] = {
|
|
271
|
+
"passed": len(methods) >= 2,
|
|
272
|
+
"detail": f"Used {len(methods)} detection method(s): {', '.join(sorted(methods))}",
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
# Check 4: Multiple PHI categories handled
|
|
276
|
+
categories = {e.phi_category.value for e in events}
|
|
277
|
+
checks["multi_category_coverage"] = {
|
|
278
|
+
"passed": len(categories) >= 3,
|
|
279
|
+
"detail": f"Covered {len(categories)} PHI categories",
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
all_passed = all(c["passed"] for c in checks.values())
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
"overall": "compliant" if all_passed else "review_needed",
|
|
286
|
+
"message": (
|
|
287
|
+
"All compliance checks passed."
|
|
288
|
+
if all_passed
|
|
289
|
+
else "Some checks require review. See details."
|
|
290
|
+
),
|
|
291
|
+
"checks": checks,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
# Report rendering helpers (module-level)
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def render_markdown(report: dict) -> str:
|
|
301
|
+
"""Render a compliance report as Markdown."""
|
|
302
|
+
lines = []
|
|
303
|
+
meta = report.get("report_metadata", {})
|
|
304
|
+
lines.append(f"# {meta.get('title', 'Compliance Report')}")
|
|
305
|
+
lines.append(f"\n**Generated:** {meta.get('generated_at', 'N/A')}")
|
|
306
|
+
lines.append(f"**Standard:** {meta.get('standard', 'N/A')}")
|
|
307
|
+
lines.append(
|
|
308
|
+
f"**Period:** "
|
|
309
|
+
f"{report.get('report_metadata', {}).get('reporting_period', {}).get('from', 'N/A')}"
|
|
310
|
+
f" to "
|
|
311
|
+
f"{report.get('report_metadata', {}).get('reporting_period', {}).get('to', 'N/A')}"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Summary
|
|
315
|
+
summary = report.get("summary", {})
|
|
316
|
+
lines.append("\n## Summary\n")
|
|
317
|
+
lines.append("| Metric | Value |")
|
|
318
|
+
lines.append("|--------|-------|")
|
|
319
|
+
for key, val in summary.items():
|
|
320
|
+
lines.append(f"| {key.replace('_', ' ').title()} | {val} |")
|
|
321
|
+
|
|
322
|
+
# Category coverage
|
|
323
|
+
coverage = report.get("category_coverage", {})
|
|
324
|
+
if coverage:
|
|
325
|
+
lines.append(f"\n## Category Coverage ({coverage.get('coverage_percentage', 0)}%)\n")
|
|
326
|
+
lines.append("| Category | Count | Status |")
|
|
327
|
+
lines.append("|----------|-------|--------|")
|
|
328
|
+
for cat, info in coverage.get("categories", {}).items():
|
|
329
|
+
lines.append(f"| {cat} | {info['detected_count']} | {info['status']} |")
|
|
330
|
+
|
|
331
|
+
# Confidence
|
|
332
|
+
confidence = report.get("confidence_analysis", {})
|
|
333
|
+
if confidence:
|
|
334
|
+
lines.append("\n## Confidence Analysis\n")
|
|
335
|
+
lines.append(f"- Average: {confidence.get('average_confidence', 0)}")
|
|
336
|
+
lines.append(f"- Min: {confidence.get('min_confidence', 0)}")
|
|
337
|
+
lines.append(f"- Max: {confidence.get('max_confidence', 0)}")
|
|
338
|
+
|
|
339
|
+
# Compliance status
|
|
340
|
+
status = report.get("compliance_status", {})
|
|
341
|
+
lines.append(f"\n## Compliance Status: {status.get('overall', 'unknown').upper()}\n")
|
|
342
|
+
lines.append(f"{status.get('message', '')}\n")
|
|
343
|
+
for name, check in status.get("checks", {}).items():
|
|
344
|
+
icon = "PASS" if check["passed"] else "FAIL"
|
|
345
|
+
lines.append(f"- [{icon}] **{name}**: {check['detail']}")
|
|
346
|
+
|
|
347
|
+
# Integrity
|
|
348
|
+
integrity = report.get("integrity_verification", {})
|
|
349
|
+
if integrity:
|
|
350
|
+
lines.append(f"\n## Audit Trail Integrity: {integrity.get('status', 'unknown')}\n")
|
|
351
|
+
|
|
352
|
+
# Attestation
|
|
353
|
+
attestation = report.get("attestation", {})
|
|
354
|
+
if attestation:
|
|
355
|
+
lines.append("\n## Attestation\n")
|
|
356
|
+
lines.append(f"**Method:** {attestation.get('method', 'N/A')}")
|
|
357
|
+
lines.append(f"**Standard:** {attestation.get('standard', 'N/A')}")
|
|
358
|
+
lines.append(f"\n{attestation.get('statement', '')}")
|
|
359
|
+
lines.append(f"\n### Methodology\n\n{attestation.get('methodology', '')}")
|
|
360
|
+
|
|
361
|
+
return "\n".join(lines)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def render_html(report: dict) -> str:
|
|
365
|
+
"""Render a compliance report as standalone HTML."""
|
|
366
|
+
md_content = render_markdown(report)
|
|
367
|
+
# Convert basic markdown to HTML
|
|
368
|
+
html_lines = []
|
|
369
|
+
for line in md_content.split("\n"):
|
|
370
|
+
if line.startswith("# "):
|
|
371
|
+
html_lines.append(f"<h1>{line[2:]}</h1>")
|
|
372
|
+
elif line.startswith("## "):
|
|
373
|
+
html_lines.append(f"<h2>{line[3:]}</h2>")
|
|
374
|
+
elif line.startswith("### "):
|
|
375
|
+
html_lines.append(f"<h3>{line[4:]}</h3>")
|
|
376
|
+
elif line.startswith("| ") and "---|" not in line:
|
|
377
|
+
cells = [c.strip() for c in line.split("|")[1:-1]]
|
|
378
|
+
if html_lines and "<thead>" not in "".join(html_lines[-5:]):
|
|
379
|
+
html_lines.append("<tr>" + "".join(f"<td>{c}</td>" for c in cells) + "</tr>")
|
|
380
|
+
else:
|
|
381
|
+
html_lines.append("<tr>" + "".join(f"<th>{c}</th>" for c in cells) + "</tr>")
|
|
382
|
+
elif line.startswith("|") and "---" in line:
|
|
383
|
+
html_lines.append("") # skip separator
|
|
384
|
+
elif line.startswith("- ["):
|
|
385
|
+
html_lines.append(f"<li>{line[2:]}</li>")
|
|
386
|
+
elif line.startswith("- "):
|
|
387
|
+
html_lines.append(f"<li>{line[2:]}</li>")
|
|
388
|
+
elif line.startswith("**"):
|
|
389
|
+
html_lines.append(f"<p><strong>{line.strip('*')}</strong></p>")
|
|
390
|
+
elif line.strip():
|
|
391
|
+
html_lines.append(f"<p>{line}</p>")
|
|
392
|
+
|
|
393
|
+
body = "\n".join(html_lines)
|
|
394
|
+
return f"""<!DOCTYPE html>
|
|
395
|
+
<html lang="en">
|
|
396
|
+
<head>
|
|
397
|
+
<meta charset="UTF-8">
|
|
398
|
+
<title>HIPAA Compliance Report</title>
|
|
399
|
+
<style>
|
|
400
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 900px; margin: 0 auto; padding: 2rem; color: #333; }}
|
|
401
|
+
h1 {{ color: #1a365d; border-bottom: 2px solid #2b6cb0; padding-bottom: 0.5rem; }}
|
|
402
|
+
h2 {{ color: #2b6cb0; margin-top: 2rem; }}
|
|
403
|
+
table {{ border-collapse: collapse; width: 100%; margin: 1rem 0; }}
|
|
404
|
+
th, td {{ border: 1px solid #e2e8f0; padding: 0.5rem 1rem; text-align: left; }}
|
|
405
|
+
th {{ background: #edf2f7; font-weight: 600; }}
|
|
406
|
+
tr:nth-child(even) {{ background: #f7fafc; }}
|
|
407
|
+
li {{ margin: 0.3rem 0; }}
|
|
408
|
+
.pass {{ color: #276749; }} .fail {{ color: #c53030; }}
|
|
409
|
+
@media print {{ body {{ max-width: 100%; }} }}
|
|
410
|
+
</style>
|
|
411
|
+
</head>
|
|
412
|
+
<body>
|
|
413
|
+
{body}
|
|
414
|
+
<footer><p style="color:#718096;font-size:0.85rem;margin-top:3rem;border-top:1px solid #e2e8f0;padding-top:1rem;">Generated by phi-redactor Compliance Report Engine</p></footer>
|
|
415
|
+
</body>
|
|
416
|
+
</html>"""
|