음성 AI 시스템에서 PII 감지 및 Redaction 구현

발행: (2025년 12월 14일 오전 03:43 GMT+9)
9 min read
원문: Dev.to

Source: Dev.to

TL;DR

대부분의 음성 AI 시스템은 전사와 로그에 PII가 누출됩니다. 이는 데이터가 저장소에 도달한 뒤에야 삭제가 이루어지기 때문입니다. 데이터베이스에 닿기 에 SSN, 신용카드, PHI 등을 실시간으로 감지하고 삭제하는 파이프라인을 구축하세요. 양방향(입력 + 출력) 오디오에 대한 이중 채널 삭제를 구현하고, 정규식 + NER 모델을 구성해 99.2 % 정확도를 달성하며, 전사 청크 간에 걸친 부분 번호와 같은 엣지 케이스도 처리합니다.

Stack: VAPI 음성 처리, 커스텀 NER 파이프라인, 암호화된 감사 로그.

Prerequisites

API Access & Authentication

  • VAPI API 키 (PII 삭제 기능이 포함된 프로덕션 티어)
  • Twilio Account SID 및 Auth Token (양방향 오디오를 다룰 경우)
  • HTTPS가 적용된 웹훅 엔드포인트 (개발 시 ngrok, 실서비스 시 도메인)

System Requirements

  • Node.js 18+ (서명 검증을 위한 네이티브 crypto)
  • Redis 또는 인‑메모리 스토어 (세션 상태, 삭제 캐시)
  • 최소 2 GB RAM (오디오 버퍼 + 전사 처리)

Compliance Knowledge

  • GDPR 제 32조 요구사항 (데이터 최소화, 가명화)
  • HIPAA Safe Harbor 방법 (18가지 PII 식별자)
  • PCI DSS 레벨 1 표준 (결제 데이터 처리 시)

Technical Dependencies

  • 오디오 처리: PCM 16 kHz mono (VAPI 기본 포맷)
  • SSN, 신용카드, 전화번호 등에 대한 정규식 패턴
  • 문맥 인식 삭제를 위한 명명된 엔터티 인식(NER) 모델 또는 API

What You’ll Build

실시간 PII 감지 파이프라인 – 전사 삭제, 오디오 마스킹, 컴플라이언스 로깅 포함.

VAPI: Get Started with VAPI → Get VAPI

Step‑By‑Step Tutorial

Configuration & Setup

PII 누출은 두 곳에서 발생합니다: 데이터베이스에 저장되는 전사와 실시간 오디오 스트림. 대부분 구현이 실패하는 이유는 저장된 텍스트만 삭제하고 실시간 오디오는 보호하지 않기 때문입니다.

먼저 어시스턴트 설정을 시작하세요. transcriber 객체가 무엇을 캡처할지 제어합니다:

const assistantConfig = {
  model: {
    provider: "openai",
    model: "gpt-4",
    messages: [{
      role: "system",
      content: "You are a healthcare assistant. Never repeat back SSNs, credit cards, or medical IDs verbatim."
    }]
  },
  transcriber: {
    provider: "deepgram",
    model: "nova-2",
    language: "en",
    keywords: ["SSN", "social security", "credit card"]
  },
  voice: {
    provider: "11labs",
    voiceId: "21m00Tcm4TlvDq8ikWAM"
  }
};

Critical: LLM 프롬프트가 첫 번째 방어선입니다. 모델에게 민감한 데이터를 그대로 되풀이하지 말고 바꾸어 말하도록 지시하세요. 이렇게 하면 “사용자가 자신의 SSN은 123‑45‑6789라고 말했다”와 같은 응답이 오디오 스트림에 PII를 남기는 것을 방지할 수 있습니다.

Architecture & Flow

flowchart LR
    A[User Speech] --> B[Deepgram STT]
    B --> C[Raw Transcript]
    C --> D[PII Detection Layer]
    D --> E{PII Found?}
    E -->|Yes| F[Redact + Log]
    E -->|No| G[Pass Through]
    F --> H[GPT-4]
    G --> H
    H --> I[11Labs TTS]
    I --> J[User Audio]

    D -.->|Webhook| K[Your Server]
    K --> L[Compliance DB]

서버는 VAPI와 저장 계층 사이에 위치합니다. VAPI는 원시 텍스트가 포함된 transcript 웹훅을 전송합니다. 이를 스캔·삭제한 뒤 저장합니다.

Step‑By‑Step Implementation

1. Webhook Handler with Pattern Matching

정규식 패턴은 약 80 %의 PII를 잡아냅니다. 데이터 유형마다 다양한 포맷을 처리하도록 여러 패턴을 사용하세요:

const express = require('express');
const crypto = require('crypto');
const app = express();

const PII_PATTERNS = {
  ssn: [
    /\b\d{3}-\d{2}-\d{4}\b/g,           // 123-45-6789
    /\b\d{3}\s\d{2}\s\d{4}\b/g,         // 123 45 6789
    /\b\d{9}\b/g                        // 123456789
  ],
  creditCard: [
    /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g
  ],
  email: [
    /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g
  ],
  phone: [
    /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/g,
    /\b\(\d{3}\)\s?\d{3}[-.]?\d{4}\b/g
  ]
};

function redactPII(text) {
  let redacted = text;
  const findings = [];

  for (const [type, patterns] of Object.entries(PII_PATTERNS)) {
    patterns.forEach(pattern => {
      const matches = text.match(pattern);
      if (matches) {
        findings.push({ type, count: matches.length });
        redacted = redacted.replace(pattern, `[${type.toUpperCase()}_REDACTED]`);
      }
    });
  }

  return { redacted, findings };
}

app.post('/webhook/vapi', express.json(), async (req, res) => {
  const { message } = req.body;

  if (message.type === 'transcript' && message.transcriptType === 'final') {
    const { redacted, findings } = redactPII(message.transcript);

    // Store redacted version
    await db.transcripts.insert({
      callId: message.call.id,
      original_hash: crypto.createHash('sha256')
                         .update(message.transcript)
                         .digest('hex'),
      redacted_text: redacted,
      pii_detected: findings,
      timestamp: new Date()
    });

    // Alert if PII found
    if (findings.length > 0) {
      console.warn(`PII detected in call ${message.call.id}:`, findings);
    }
  }

  res.sendStatus(200);
});

app.listen(3000);

2. Real‑Time Audio Redaction

오디오 스트림은 삭제가 어렵습니다. 이미 말해진 내용을 사후에 편집할 수 없기 때문이죠. 대신 PII가 감지되면 즉시 끊어내는(바지‑인) 방식을 설정합니다:

const vapiConfig = {
  transcriber: {
    provider: "deepgram",
    model: "nova-2",
    endpointing: 150   // Faster interruption
  },
  model: {
    provider: "openai",
    model: "gpt-4",
    functions: [{
      name: "detect_pii_intent",
      description: "Called when user is about to share sensitive data",
      parameters: {
        type: "object",
        properties: {
          data_type: { type: "string", enum: ["ssn", "credit_card", "medical_id"] }
        }
      }
    }]
  }
};

LLM이 “내 사회보장번호는 …”와 같은 문구를 감지하면 해당 함수를 호출합니다. 서버는 “보안을 위해 별도 양식을 통해 확인해 주세요”와 같은 차단 메시지를 반환하면 됩니다.

Error Handling & Edge Cases

False Positives

9자리 숫자가 SSN이 아닌 경우(전화 내선, 주문 번호 등)도 매치될 수 있습니다. 컨텍스트 검사를 추가하세요:

function isLikelySSN(text, match) {
  const start = Math.max(0, text.indexOf(match) - 50);
  const end = text.indexOf(match) + match.length + 50;
  const context = text.substring(start, end).toLowerCase();

  return context.includes('social') ||
         context.includes('ssn') ||
         context.includes('security number');
}

Partial Captures

사용자가 “내 카드 번호는 4 1 2 3 …”이라고 말하면 STT가 “4123”만 먼저 출력할 수 있습니다. 약 2 초 동안 부분 전사를 버퍼링한 뒤 스캔하도록 구현하세요.

Multi‑Language

사용자가 코드‑스위칭(예: 스팽글리시)하는 경우 Deepgram의 다국어 모드를 활성화하고, 구어 숫자 형태(예: “cuatro uno dos tres”)에 맞는 패턴을 추가하세요.

Testing & Validation

  1. 실제 데이터 수집 – 약 50개의 실제 통화를 녹음하고, 수동으로 PII에 라벨링합니다.
  2. Recall 측정 – 모든 PII 인스턴스 중 95 % 이상을 탐지하는 것을 목표로 합니다.
  3. Precision 측정 – 오탐률을 5 % 미만으로 유지합니다.
  4. Latency – 전사당 추가 지연을 20‑40 ms 이내(p99)로 유지합니다.
  5. Load test – 동시 100통화 시뮬레이션을 실행하고 CPU 피크와 웹훅 타임아웃을 모니터링합니다.

Common Issues & Fixes

Issue: Redacted transcripts still show PII in VAPI dashboard

Fix: VAPI는 기본적으로 원시 전사를 저장합니다. 어시스턴트 설정에서 전사 저장을 비활성화하고, 자체적으로 삭제된 사본만 사용하도록 하세요.

Issue: Credit card numbers split across multiple tra

Content truncated in source; ensure your buffering logic concatenates partial transcript chunks before applying regex.

Back to Blog

관련 글

더 보기 »