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.
Files changed (64) hide show
  1. phi_redactor/__init__.py +71 -0
  2. phi_redactor/__main__.py +6 -0
  3. phi_redactor/audit/__init__.py +7 -0
  4. phi_redactor/audit/reports.py +416 -0
  5. phi_redactor/audit/trail.py +278 -0
  6. phi_redactor/cli/__init__.py +5 -0
  7. phi_redactor/cli/config.py +108 -0
  8. phi_redactor/cli/main.py +101 -0
  9. phi_redactor/cli/plugins.py +54 -0
  10. phi_redactor/cli/redact.py +86 -0
  11. phi_redactor/cli/report.py +159 -0
  12. phi_redactor/cli/serve.py +91 -0
  13. phi_redactor/cli/sessions.py +122 -0
  14. phi_redactor/config.py +260 -0
  15. phi_redactor/dashboard/__init__.py +1 -0
  16. phi_redactor/dashboard/routes.py +58 -0
  17. phi_redactor/dashboard/static/index.html +121 -0
  18. phi_redactor/detection/__init__.py +15 -0
  19. phi_redactor/detection/engine.py +230 -0
  20. phi_redactor/detection/recognizers/__init__.py +47 -0
  21. phi_redactor/detection/recognizers/account.py +69 -0
  22. phi_redactor/detection/recognizers/biometric.py +71 -0
  23. phi_redactor/detection/recognizers/device.py +82 -0
  24. phi_redactor/detection/recognizers/fax.py +58 -0
  25. phi_redactor/detection/recognizers/fhir.py +44 -0
  26. phi_redactor/detection/recognizers/health_plan.py +75 -0
  27. phi_redactor/detection/recognizers/hl7.py +25 -0
  28. phi_redactor/detection/recognizers/license.py +116 -0
  29. phi_redactor/detection/recognizers/mrn.py +59 -0
  30. phi_redactor/detection/recognizers/vehicle.py +64 -0
  31. phi_redactor/detection/registry.py +197 -0
  32. phi_redactor/masking/__init__.py +3 -0
  33. phi_redactor/masking/clustering.py +135 -0
  34. phi_redactor/masking/date_shifter.py +162 -0
  35. phi_redactor/masking/identity.py +139 -0
  36. phi_redactor/masking/providers.py +56 -0
  37. phi_redactor/masking/semantic.py +246 -0
  38. phi_redactor/models.py +271 -0
  39. phi_redactor/plugins/__init__.py +11 -0
  40. phi_redactor/plugins/example_plugin.py +43 -0
  41. phi_redactor/plugins/loader.py +106 -0
  42. phi_redactor/proxy/__init__.py +0 -0
  43. phi_redactor/proxy/adapters/__init__.py +0 -0
  44. phi_redactor/proxy/adapters/anthropic.py +292 -0
  45. phi_redactor/proxy/adapters/base.py +100 -0
  46. phi_redactor/proxy/adapters/openai.py +241 -0
  47. phi_redactor/proxy/app.py +160 -0
  48. phi_redactor/proxy/routes/__init__.py +0 -0
  49. phi_redactor/proxy/routes/anthropic.py +311 -0
  50. phi_redactor/proxy/routes/library.py +162 -0
  51. phi_redactor/proxy/routes/management.py +402 -0
  52. phi_redactor/proxy/routes/openai.py +472 -0
  53. phi_redactor/proxy/session.py +208 -0
  54. phi_redactor/proxy/streaming.py +135 -0
  55. phi_redactor/py.typed +1 -0
  56. phi_redactor/vault/__init__.py +7 -0
  57. phi_redactor/vault/encryption.py +143 -0
  58. phi_redactor/vault/session_map.py +101 -0
  59. phi_redactor/vault/store.py +375 -0
  60. phi_redactor-0.1.0.dist-info/METADATA +306 -0
  61. phi_redactor-0.1.0.dist-info/RECORD +64 -0
  62. phi_redactor-0.1.0.dist-info/WHEEL +4 -0
  63. phi_redactor-0.1.0.dist-info/entry_points.txt +2 -0
  64. phi_redactor-0.1.0.dist-info/licenses/LICENSE +190 -0
@@ -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
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m phi_redactor."""
2
+
3
+ from phi_redactor.cli.main import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
@@ -0,0 +1,7 @@
1
+ """Append-only hash-chain audit trail for PHI redaction events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from phi_redactor.audit.trail import AuditTrail
6
+
7
+ __all__ = ["AuditTrail"]
@@ -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>"""