How It Works
jev-pii-checker detects PII through three layers: a categorical gate, regex pattern extraction, and Jev-powered name judgment. The three-layer approach balances cost, accuracy, and coverage.
Layer 1: Categorical Gate
The gate runs Jev once per document chunk to answer 12 binary questions: "Does this text contain [category]?"
12 PII Categories:
- person_name, email_or_phone, postal_address, date_of_birth
- government_id, financial_account, health_info, biometric
- ip_address_of_a_person, sns_handle, employment_info, race_or_religion
The gate also produces a sensitivity score (none / low / high) using the IBM taxonomy:
- none: No identifiable information
- low: Contact or basic details (name, phone, email, address, birthday, job) but low direct harm risk
- high: Government ID, financial account, health, biometric information, or sensitive fact lists
The sensitivity level returned by the gate is computed as the most probable rubric step (argmax over the probability distribution), not as a rounded expected value. This ensures that {none: 0.63, low: 0.12, high: 0.25} with expected value 0.62 returns "none" as the most likely answer, rather than rounding to "low".
Purpose: Screen the text before expensive detailed extraction. If the gate says "no person names," don't bother extracting names.
Cost: ~500 tokens per chunk, answered all at once.
Layer 2: Regex Extraction
Extract surface patterns:
- Email: RFC 5322 simplified (reject noreply@, corporate addresses)
- Phone: JP (+81, 090-xxxx, 0120, with spaces and parentheses) and international (+1 555, etc.); filter ISO dates, ISBNs, labelled order numbers
- Digit Strings: 10–16 chars; filter corporate patterns (payment gateways, order numbers, IPs)
- Labelled Short IDs: Keyword-gated patterns for passport, license, and driver ID (e.g., "Passport: AB123456")
Cost: Zero—purely pattern matching.
Output: Candidate spans with no judgment. Not all emails/phones are PII (e.g., support hotlines, toll-free numbers).
Layer 3: Jev Judgment
Contacts (Email / Phone)
All regex-found emails and phones are reported as findings.
piisemantics:falsefor generic mailboxes (noreply, info, support, contact, sales, admin, postmaster, mailer-daemon, notifications) and toll-free/navi-dial prefixes (0120, 0800, 0570, 1-800); otherwisetrueif personal-contact probability ≥ 0.2, elsefalse- Cost: One token per email/phone
Numbers
Pure-digit strings (10+ digits) are first disambiguated: "Is this a phone, My Number, credit card, bank account, order number, product serial, or date?"
- Output:
number_typeand full probability distribution piisemantics:truefor sensitive types (my_number, credit_card, bank_account, phone, driver_licence_or_passport);falsefor non-sensitive- Cost: One token per number
Names
Generate candidates via Intl.Segmenter, then batch-judge them with two independent questions:
- "Does this refer to a person (vs. place, organization, product, common word)?" — threshold ≥ 0.8
- "Is this the full name or part of a name?" — threshold ≥ 0.4
Both thresholds must pass. Overlapping candidates are merged and assembled into spans. Honorifics (さん, 様, Mr., Ms., Dr, etc.) and title prefixes (部長, 課長, Rabbi, Patient, etc.) are detected and reported separately in detail.honorific / detail.title; they are not part of the span value itself.
Filtering:
- Names inside emails, URLs, or phone numbers are filtered out
- Role words and titles alone are never candidates: political/military/legal/clerical titles (Governor, Senator, Mayor, Judge, etc.), and capitalized form labels (Name, Email, Phone, Subject, etc.)
- Lowercase Latin words are never candidates
- Japanese particles and title suffixes are stripped during candidate generation
- Initials are never candidates alone; they merge with the surname (T. Anderson is one candidate, but T alone is not)
Special cases:
- Katakana names joined by = or ・ (マリー=ルイーズ, イヴ・パトリック) are kept as single candidates
- Hangul names (김민수) are candidates alongside Japanese and Latin names
- Hyphenated names (Al-Rashid, Jean-Pierre) are kept whole as single candidates
Candidate cap: Up to 600 candidates per chunk (raised from 200); late candidates in long chunks are no longer silently dropped.
Batching: Candidates are judged in batches of ≤25 per request (2 questions × 25 = up to 50 questions).
Cost: Two tokens per person-name candidate.
Output: Probability per candidate, plus two-stage scores in detail.scores { person, name }. Filter by --span-threshold (default 0.8).
Chunking
Text longer than --max-chars (default 4000 bytes) is split at paragraph or sentence boundaries:
- Split on
\n\n(paragraph breaks) if chunks fit - Otherwise split on
.,!,?(sentence ends) if chunks fit - Otherwise split on
\n(line breaks) if chunks fit - Fall back to fixed 4000-byte chunks
Per-document aggregation: Results are merged (max sensitivity, max per-category probability).
Sensitivity Policy
After the gate produces its sensitivity level and category probabilities, a code-side policy applies two rules:
Escalation to high: If a named person co-occurs with a special category (health_info, biometric, government_id, financial_account, or race_or_religion, hr_or_criminal_record) at threshold, or if a personal government or financial number is found (my_number, credit_card, bank_account, driver_licence_or_passport, or national_id), the sensitivity is escalated to high even if the model returned low.
Floor to none: If no category reaches the threshold and no finding is marked as PII (when spans are computed), the sensitivity is floored to none. This filters noise: a lone toll-free number, an unlabelled digit string, or a code snippet with variable names are all rewritten to none.
These rules are policy, not judgment: they are necessary because the model is calibrated on literal text, whereas Japanese law (個人情報保護法) treats certain combinations as 要配慮個人情報 (sensitive personal information requiring care).
The JSON report carries both model_level (the model's answer before the policy) and reasons (why the policy changed it, if at all). The human output shows the model level and reason in parentheses when the policy changed the level.
Name Extraction
Japanese names and English names are both extracted via Intl.Segmenter('ja') with the same algorithm:
- Candidate generation: Word segmentation produces overlapping windows (unigrams, bigrams, trigrams); Latin multi-word names (capitalized sequences) are also merged into 1–3-word candidates with hyphenated names kept whole
- Two-stage judgment: Each candidate is asked two questions (person? name?); both must pass thresholds
- Span assembly: Overlapping candidates with high probability are merged into non-overlapping spans
- Title attachment: Trailing honorifics (さん, 様, Dr., Mr., etc.) are detected and reported separately; role prefixes (部長, Rabbi, Patient, etc.) are also stripped and reported
Improvements in recent versions:
- Sensitivity policy escalates to high when a named person co-occurs with health, biometric, government ID, financial account, or race/religion data, or when a personal government/financial number is found
- Sensitivity is floored to none when no category reaches threshold and no finding is PII, filtering noise (toll-free numbers, code snippets)
- Argmax sensitivity: the most probable rubric step, not rounded expected value
- Political/military/legal/clerical titles and capitalized form labels (Name, Email, Phone, Subject, etc.) are never candidates
- Initials merge with surname (T. Anderson) and are never candidates alone
- Katakana names joined by = or ・ and Hangul names are now handled correctly
- Japanese surnames with titles (田中部長, 千葉さん) are found as single spans
- Place names (千葉県) are no longer confused with person names
- Title words are never included in the span value itself
Why This Layering?
| Step | Cost | Why |
|---|---|---|
| Gate | ~500 tokens | Filter out irrelevant chunks early |
| Regex | $0 | Find obvious candidates fast |
| Jev judgment | ~1 token per question | Judge only what patterns found |
Total typical cost per chunk: ~600 tokens per 4000 bytes (one gate + ~100 candidate judgments).
Contrast:
- Regex only: High false positives (server IPs flagged as personal)
- Jev on all text: 2500+ tokens per chunk (cost prohibitive)
- jev-pii-checker: 600 tokens per chunk with high accuracy
API Details
All communication is with TypeSafe's Jev API via the @typesafe-ai/sdk. The SDK handles retry, rate limiting, and error recovery.
Questions are embedded literally: Jev cannot reference candidate arrays by index. Instead, candidates are spelled out in the question: 'Is the email "user@example.com" personal...'
This is why answers are returned as a simple object, not indexed — the question text is the context.
Token billing: Input tokens only (questions + text). Output tokens are free.
Performance
Typical scan:
- Small file (< 4 KB): 1 chunk, 1 gate request, ~20 name candidates → ~600 tokens
- Medium file (16–40 KB): 5–10 chunks, 5–10 gate requests, ~100 candidates total → ~3000–6000 tokens
- Large file (100+ KB): Chunks are aggressively split; cost scales linearly
Names are the most expensive layer: 1 token per candidate. Regex extraction costs nothing.
Next Steps
- Categories & Sensitivity — Full definitions of the 13 categories
- Limitations — What jev-pii-checker cannot do
- CLI Reference — All flags and configuration