From 91ec23276b86d458ce701248a7f10783510d8140 Mon Sep 17 00:00:00 2001 From: Jordan Wilke Date: Fri, 15 May 2026 12:57:44 -0400 Subject: [PATCH 1/5] pii: subprocess-venv detection with Presidio + GLiNER + per-detector opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosts on Python versions newer than spaCy ships wheels for (3.14 today) silently fell back to the no-detector path in ``pii.py`` and produced ``contains_pii: false`` reports across every clip — a false-negative on PII-bearing audio for any host that can't install Presidio in-process. Move detection into an isolated Python-3.13 subprocess venv so the host can stay on whatever recent Python it likes without losing PII checks. Detection content ----------------- Two detectors run inside the new ``pii-detection`` venv and their spans are merged before cross-ASR-model corroboration runs in the host: - **Microsoft Presidio Analyzer** — regex + spaCy-NER recognizers for emails, phone numbers, SSNs, credit cards, IPs, dates, locations. - **GLiNER PII** (``nvidia/gliner-pii`` by default, configurable) — a 570M-param transformer fine-tuned on ~100k synthetic PII/PHI records. Catches medical / health entities Presidio doesn't natively cover, plus the long tail of unstructured personal-context references that zero-shot well from the labels we pass. GLiNER's lowercase labels are normalized to Presidio's uppercase scheme inside the worker so the existing ``(category, text.lower())`` dedupe key in ``pii.py`` treats both detectors' hits on the same entity as the same finding. Per-detector opt-in ------------------- ``detect_pii_in_pass`` and ``detect_pii_via_subprocess`` gain a ``detectors`` argument symmetrical to the project's ASR-model selection pattern: - ``detectors=None`` (default) → both Presidio and GLiNER run. - ``detectors=["presidio"]`` → skip GLiNER's model load (~5 s saved). - ``detectors=["gliner"]`` → skip Presidio. - ``detectors=[]`` → explicit-disable; no subprocess spawned, no venv build attempted, report carries ``failures["pii_disabled"]`` so an auditor can distinguish "didn't run on purpose" from "ran and found nothing" and "subprocess crashed". ``gliner_model``, ``gliner_labels``, and ``gliner_threshold`` are also exposed on ``detect_pii_in_pass`` so callers can swap in a different GLiNER variant (e.g. ``urchade/gliner_multi-v2.1`` for multilingual coverage) or restrict the label set without touching the worker. Architectural changes --------------------- - New module ``pii_subprocess.py`` — venv constants (``_PII_VENV = "pii-detection"``, Python 3.13, presidio + spacy + en_core_web_lg wheel pinned via direct URL + gliner + torch), GLiNER -> Presidio category map, worker script, and the ``detect_pii_via_subprocess`` dispatch function. ``torch`` is named explicitly in ``_PII_REQUIREMENTS`` so ``ensure_venv``'s auto-detect (post-#516) routes it through the matched CUDA index — GLiNER's transformer wants matched ``torch`` / nvidia-cuda-runtime wheels. ``torchaudio`` is intentionally omitted (no audio decoding). - ``pii.py`` simplified — removed ``_load_presidio_analyzer``, ``_load_spacy_nlp``, ``_scan_with_presidio``, ``_scan_with_spacy``, and the in-process tier-fallback chain. Dispatch now goes to the subprocess; everything else (transcript concatenation, ``PiiSpan`` materialization, cross-ASR-model corroboration, ``PiiPassReport`` construction) stays in-process. - Subprocess-only — no in-process fallback. Matches the canary/nemo/qwen subprocess-venv pattern for hermetic deployment. A host that can't build the venv at all gets a single ``failures["pii_subprocess"]`` entry, not a confusing partial-fail state. Worker model loads are paid per subprocess invocation. For a typical ``analyze_audio.py`` run (138 ``detect_pii_in_pass`` calls per subject) that's ~15 min of model-load overhead per subject; batching all of a subject's transcripts into a single subprocess invocation is a worthwhile follow-up but isn't required for correctness. Tests ----- ``src/tests/audio/workflows/audio_analysis/pii_subprocess_test.py``, 10 tests covering: default-both-detectors plumbing, single-detector selection, empty-list short-circuit (no venv build, no subprocess spawn), unknown-detector ``ValueError``, dedupe with order preservation, default-arg pass-through (threshold, GLiNER model id, labels, full GLiNER -> Presidio category map), caller overrides for the GLiNER settings, response field pass-through. Co-Authored-By: Claude Opus 4.7 --- .../audio/workflows/audio_analysis/pii.py | 449 ++++++++--------- .../audio_analysis/pii_subprocess.py | 472 ++++++++++++++++++ .../audio_analysis/pii_subprocess_test.py | 240 +++++++++ 3 files changed, 925 insertions(+), 236 deletions(-) create mode 100644 src/senselab/audio/workflows/audio_analysis/pii_subprocess.py create mode 100644 src/tests/audio/workflows/audio_analysis/pii_subprocess_test.py diff --git a/src/senselab/audio/workflows/audio_analysis/pii.py b/src/senselab/audio/workflows/audio_analysis/pii.py index 489e42729..21b7ef905 100644 --- a/src/senselab/audio/workflows/audio_analysis/pii.py +++ b/src/senselab/audio/workflows/audio_analysis/pii.py @@ -1,33 +1,56 @@ """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. Catches the long tail Presidio misses (especially medical / + health entities). + +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 ( + 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 +58,7 @@ class PiiSpan: text: str category: str # presidio entity_type, e.g. "PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER" - source: str # "presidio" or "spacy/