diff --git a/src/senselab/audio/workflows/audio_analysis/global_summary.py b/src/senselab/audio/workflows/audio_analysis/global_summary.py index 47ffdf9a2..223fe98df 100644 --- a/src/senselab/audio/workflows/audio_analysis/global_summary.py +++ b/src/senselab/audio/workflows/audio_analysis/global_summary.py @@ -280,14 +280,34 @@ def compute_pass_global_summary( # ─── no_pii ─── # Surface the actual detected PII spans (text + category + detector + ASR - # source + confidence) so the consumer can audit. The boolean ``contains_pii`` - # flag drives the bottom-line uncertainty; the span list lets a reviewer - # decide whether each detection is a true positive worth redacting. + # source + confidence) so the consumer can audit. The continuous + # ``detection_confidence`` (per-span score × cross-detector agreement × + # cross-ASR agreement) drives the bottom-line uncertainty; the boolean + # ``contains_pii`` and the span list let a reviewer decide whether each + # detection is a true positive worth redacting. ``None`` propagation: + # ``pii_report is None`` (PII stage skipped upstream) or + # ``pii_report.detector_used is None`` (subprocess crashed / both + # detectors failed to load / caller passed ``detectors=[]``) both + # surface as ``no_pii_uncertainty = None`` — distinct from ``0.0`` + # ("ran, found nothing") so a downstream auditor can tell "didn't + # check" from "checked clean". if pii_report is None: no_pii_uncertainty: float | None = None pii_block: dict[str, Any] | None = None + elif pii_report.detector_used is None: + no_pii_uncertainty = None + pii_block = { + "contains_pii": pii_report.contains_pii, + "n_spans": pii_report.n_spans, + "categories": pii_report.categories, + "detector_used": None, + "detection_confidence": None, + "spans_by_category": {}, + "spans": [], + "failures": pii_report.failures, + } else: - no_pii_uncertainty = 1.0 if pii_report.contains_pii else 0.0 + no_pii_uncertainty = pii_report.detection_confidence # Group spans by category for a quick at-a-glance view; full per-span # detail lives alongside. from collections import defaultdict @@ -307,6 +327,7 @@ def compute_pass_global_summary( "n_spans": pii_report.n_spans, "categories": pii_report.categories, "detector_used": pii_report.detector_used, + "detection_confidence": pii_report.detection_confidence, "spans_by_category": dict(spans_by_category), "spans": [ { diff --git a/src/senselab/audio/workflows/audio_analysis/pii.py b/src/senselab/audio/workflows/audio_analysis/pii.py index 489e42729..658c1b0b3 100644 --- a/src/senselab/audio/workflows/audio_analysis/pii.py +++ b/src/senselab/audio/workflows/audio_analysis/pii.py @@ -1,33 +1,60 @@ """PII detection over ASR transcripts. -Two ML-based detectors, layered with graceful degradation: - -1. **Microsoft Presidio Analyzer** (preferred) — industry-standard PII detection - library combining spaCy NER with purpose-built recognizers for emails, phone - numbers, SSNs, credit cards, IP addresses, dates, and locations. Each span - carries a confidence score and an entity type. -2. **spaCy NER** (fallback) — bare ``en_core_web_sm`` / ``en_core_web_trf`` for - PERSON / GPE / LOC / ORG entities. Less coverage than Presidio (no email / - phone / SSN-specific recognizers) but available wherever spaCy is installed. - -Regex-only detection is intentionally NOT supported here: ASR transcripts -routinely contain digit-heavy hallucinations and ambiguous date strings that -flip a regex-only ``contains_pii`` flag from a single false positive. ML -detectors with confidence scores let the workflow gate on a meaningful -threshold. - -When neither detector is available, the report records an explicit failure -reason (``failures["no_pii_detector"]``) and ``contains_pii`` defaults to -``False`` — the user knows the check didn't run rather than getting a silent -all-clear. +Detection runs in an isolated subprocess venv (Presidio + GLiNER on +Python 3.13) so the host process doesn't need ``presidio-analyzer`` / +``spacy`` / ``gliner`` installed — see ``pii_subprocess.py`` for the +venv contents and worker. Two detectors run in parallel inside the venv: + +1. **Microsoft Presidio Analyzer** — regex + spaCy-NER orchestrator with + purpose-built recognizers for emails, phone numbers, SSNs, credit + cards, IP addresses, dates, and locations. +2. **GLiNER PII** (``nvidia/gliner-pii`` by default) — a transformer- + based zero-shot NER model fine-tuned on ~100k synthetic PII / PHI + records. Defaults to the HIPAA Safe Harbor 18 identifiers (matches + b2aiprep #256) — catches PHI categories Presidio doesn't natively + recognize (medical record number, health plan number, account + number, fax number, URL, biometric / device / vehicle identifiers, + unique identifier, ...). + +GLiNER's lowercase labels are normalized to Presidio's uppercase scheme +inside the worker so the cross-model corroboration logic below — which +keys on ``(category, text.lower())`` — sees the two detectors' hits on +the same entity as the same finding. + +When the subprocess fails to start or both detectors fail to load, the +report records an explicit failure reason +(``failures["pii_subprocess"]``) and ``contains_pii`` defaults to +``False`` — the caller learns the check didn't actually run rather +than getting a silent all-clear. """ from __future__ import annotations import sys +from collections import Counter from dataclasses import asdict, dataclass, field from typing import Any +from senselab.audio.workflows.audio_analysis.pii_subprocess import ( + _KNOWN_DETECTORS, + DETECTOR_GLINER, + DETECTOR_PRESIDIO, + detect_pii_via_subprocess, +) + +# Re-export the canonical detector names so ``analyze_audio.py`` (and +# any other caller wiring up a ``--pii-detectors`` flag) can reference +# them as ``pii.DETECTOR_PRESIDIO`` / ``pii.DETECTOR_GLINER`` rather +# than reaching into the subprocess-specific module. +__all__ = [ + "DETECTOR_GLINER", + "DETECTOR_PRESIDIO", + "PiiPassReport", + "PiiSpan", + "detect_pii_in_pass", + "report_to_dict", +] + @dataclass class PiiSpan: @@ -35,7 +62,7 @@ class PiiSpan: text: str category: str # presidio entity_type, e.g. "PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER" - source: str # "presidio" or "spacy/